feat: Add multisig wallet setup (#7218)
What changed, and why it matters
This commit adds a new multisignature wallet setup feature to BTCPay Server. It introduces new database tables, UI flows, controllers, services, and tests for creating and managing multisig wallets. The changes are a feature implementation, not a security fix or vulnerability patch. There is no indication in the commit or supplied references that this addresses a security issue.
No security action required. This is a feature commit. Standard code review and testing for a new multisig feature should continue.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit implements a multisig wallet setup plugin. It adds migrations for multisig_setups and multisig_setups_participants, splits pending transaction IDs into NoSignatureTransactionId and TransactionId to handle transaction malleability, adds concurrency tokens, and introduces new controllers (UIMultisigSetupController, UIMultisigWalletsController), services (MultisigService, MultisigNotificationService), models, views, and tests. The PendingTransactionService is refactored to track per-input signature progress and handle concurrent signature collection with retry logic. No security vulnerability is evident from the diff.
Changed components
BTCPayServer.DataBTCPayServer.Plugins.MultisigBTCPayServer.HostedServices.PendingTransactionServiceBTCPayServer.Controllers.UIStoresControllerBTCPayServer.DerivationSchemeSettingsBTCPayServer.Extensions.UserExtensionsBTCPayServer.Plugins.GlobalSearchBTCPayServer.Plugins.WalletsBTCPayServer.Plugins.WebhooksInspect captured patch +3659 / −173
diff --git a/BTCPayServer.Data/Data/PendingTransaction.cs b/BTCPayServer.Data/Data/PendingTransaction.cs
index 4cdf8c5..2fc21ca 100644
--- a/BTCPayServer.Data/Data/PendingTransaction.cs
+++ b/BTCPayServer.Data/Data/PendingTransaction.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -9,6 +10,7 @@ namespace BTCPayServer.Data;
public class PendingTransaction: IHasBlob<PendingTransactionBlob>
{
public string Id { get; set; }
+ public string NoSignatureTransactionId { get; set; }
public string TransactionId { get; set; }
public string CryptoCode { get; set; }
public string StoreId { get; set; }
@@ -22,6 +24,9 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
public string Blob2 { get; set; }
+ [Timestamp]
+ public uint XMin { get; set; }
+
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
@@ -33,7 +38,7 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
builder.Entity<PendingTransaction>().HasKey(t => t.Id);
builder.Entity<PendingTransaction>().HasIndex(t => new { t.StoreId });
- builder.Entity<PendingTransaction>().HasIndex(t => new { t.TransactionId });
+ builder.Entity<PendingTransaction>().HasIndex(t => new { t.NoSignatureTransactionId });
builder.Entity<PendingTransaction>()
.Property(o => o.Blob2)
diff --git a/BTCPayServer.Data/Migrations/20260521120000_multi_sig.cs b/BTCPayServer.Data/Migrations/20260521120000_multi_sig.cs
new file mode 100644
index 0000000..fd8dcce
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20260521120000_multi_sig.cs
@@ -0,0 +1,84 @@
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260521120000_multi_sig")]
+ public partial class split_pending_transaction_ids : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ """
+ CREATE TABLE "multisig_setups" (
+ id text NOT NULL,
+ store_id text NOT NULL,
+ crypto_code text NOT NULL,
+ data JSONB NOT NULL,
+ expires_at timestamp with time zone NOT NULL,
+ CONSTRAINT "PK_multisig_setups" PRIMARY KEY ("id"),
+ CONSTRAINT "FK_multisig_setups_Stores_store_id" FOREIGN KEY (store_id) REFERENCES "Stores" ("Id") ON DELETE CASCADE,
+ CONSTRAINT "UQ_multisig_setups_store_id_crypto_code" UNIQUE (store_id, crypto_code)
+ );
+
+ CREATE INDEX "IX_multisig_setups_store_id" ON "multisig_setups" ("store_id");
+
+ CREATE TABLE multisig_setups_participants (
+ multisig_setup_id text NOT NULL,
+ user_id text NOT NULL,
+ account_key text NULL,
+ account_key_path text NULL,
+ CONSTRAINT "PK_multisig_setups_participants" PRIMARY KEY (multisig_setup_id, user_id),
+ CONSTRAINT "FK_multisig_setups_participants_multisig_setups_multisig_setup_id" FOREIGN KEY (multisig_setup_id) REFERENCES multisig_setups (id) ON DELETE CASCADE,
+ CONSTRAINT "FK_multisig_setups_participants_AspNetUsers_user_id" FOREIGN KEY (user_id) REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE,
+ CONSTRAINT "UQ_multisig_setups_participants_multisig_setup_id_account_key" UNIQUE (multisig_setup_id, account_key)
+ );
+
+ CREATE INDEX "IX_multisig_setups_participants_user_id" ON "multisig_setups_participants" ("user_id");
+ """);
+
+ migrationBuilder.RenameColumn(
+ name: "TransactionId",
+ table: "PendingTransactions",
+ newName: "NoSignatureTransactionId");
+
+ migrationBuilder.RenameIndex(
+ name: "IX_PendingTransactions_TransactionId",
+ table: "PendingTransactions",
+ newName: "IX_PendingTransactions_NoSignatureTransactionId");
+
+ migrationBuilder.AddColumn<string>(
+ name: "TransactionId",
+ table: "PendingTransactions",
+ type: "text",
+ nullable: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ """
+ DROP TABLE IF EXISTS "multisig_setups_participants";
+ DROP TABLE IF EXISTS "multisig_setups";
+ """);
+
+ migrationBuilder.DropColumn(
+ name: "TransactionId",
+ table: "PendingTransactions");
+
+ migrationBuilder.RenameIndex(
+ name: "IX_PendingTransactions_NoSignatureTransactionId",
+ table: "PendingTransactions",
+ newName: "IX_PendingTransactions_TransactionId");
+
+ migrationBuilder.RenameColumn(
+ name: "NoSignatureTransactionId",
+ table: "PendingTransactions",
+ newName: "TransactionId");
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index 3053603..f5ddbec 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -851,6 +851,9 @@ namespace BTCPayServer.Migrations
b.PrimitiveCollection<string[]>("OutpointsUsed")
.HasColumnType("text[]");
+ b.Property<string>("NoSignatureTransactionId")
+ .HasColumnType("text");
+
b.Property<int>("State")
.HasColumnType("integer");
@@ -860,11 +863,17 @@ namespace BTCPayServer.Migrations
b.Property<string>("TransactionId")
.HasColumnType("text");
+ b.Property<uint>("XMin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
b.HasKey("Id");
b.HasIndex("StoreId");
- b.HasIndex("TransactionId");
+ b.HasIndex("NoSignatureTransactionId");
b.ToTable("PendingTransactions");
});
diff --git a/BTCPayServer.Tests/DatabaseTests.cs b/BTCPayServer.Tests/DatabaseTests.cs
index 11cc847..99b41e7 100644
--- a/BTCPayServer.Tests/DatabaseTests.cs
+++ b/BTCPayServer.Tests/DatabaseTests.cs
@@ -2,13 +2,16 @@ using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions;
using BTCPayServer.Data;
+using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using Dapper;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NBitcoin;
+using NBXplorer.Models;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -156,17 +159,27 @@ namespace BTCPayServer.Tests
var networkProvider = CreateNetworkProvider();
var network = networkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork;
- var service = new PendingTransactionService(
+ var service = new TestPendingTransactionService(
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 p2shMultisig = CreateP2SHMultisigPendingTransaction(network, 1, Money.Satoshis(10_000));
+ var psbtA = p2shMultisig.Psbt;
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);
+ var noSignatureHash = psbtA.GetGlobalTransaction().GetHash();
+ var finalTransactionHash = p2shMultisig.FinalTransaction.GetHash();
+ Assert.Equal(noSignatureHash.ToString(), pendingA.NoSignatureTransactionId);
+ Assert.Null(pendingA.TransactionId);
+ Assert.NotEqual(noSignatureHash, finalTransactionHash);
+ var finalTransactionWithoutSignatures = p2shMultisig.FinalTransaction.Clone();
+ finalTransactionWithoutSignatures.RemoveSignatures();
+ Assert.Equal(noSignatureHash, finalTransactionWithoutSignatures.GetHash());
+
await service.Broadcasted(new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingA.Id), psbtB.GetGlobalTransaction());
await using (var ctx = tester.CreateContext())
@@ -175,20 +188,95 @@ namespace BTCPayServer.Tests
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 service.Broadcasted(new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingA.Id), p2shMultisig.FinalTransaction);
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(noSignatureHash.ToString(), reloadedPendingA.NoSignatureTransactionId);
+ Assert.Equal(finalTransactionHash.ToString(), reloadedPendingA.TransactionId);
+ Assert.NotEqual(reloadedPendingA.NoSignatureTransactionId, reloadedPendingA.TransactionId);
Assert.Equal(PendingTransactionState.Pending, reloadedPendingB.State);
}
}
+ [Fact]
+ public async Task CanMatchPendingTransactionBroadcastEventByNoSignatureHash()
+ {
+ 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 TestPendingTransactionService(
+ networkProvider,
+ tester.CreateContextFactory(),
+ new EventAggregator(new BTCPayServer.Logging.Logs()),
+ NullLogger<PendingTransactionService>.Instance);
+ var requestBaseUrl = RequestBaseUrl.FromUrl("https://example.com");
+ var p2shMultisig = CreateP2SHMultisigPendingTransaction(network, 1, Money.Satoshis(10_000));
+ var conflictingPsbt = CreatePendingTransactionPSBT(network, 1, Money.Satoshis(20_000));
+ var matchingPending = await service.CreatePendingTransaction(storeId, "BTC", p2shMultisig.Psbt, requestBaseUrl);
+ var conflictingPending = await service.CreatePendingTransaction(storeId, "BTC", conflictingPsbt, requestBaseUrl);
+
+ await service.ProcessEventForTest(new NewOnChainTransactionEvent
+ {
+ PaymentMethodId = PaymentMethodId.Parse("BTC-CHAIN"),
+ NewTransactionEvent = new NewTransactionEvent
+ {
+ CryptoCode = "BTC",
+ TransactionData = new TransactionResult
+ {
+ Transaction = p2shMultisig.FinalTransaction,
+ TransactionHash = p2shMultisig.FinalTransaction.GetHash()
+ }
+ }
+ });
+
+ await using (var ctx = tester.CreateContext())
+ {
+ var reloadedMatching = await ctx.PendingTransactions.SingleAsync(p => p.Id == matchingPending.Id);
+ var reloadedConflict = await ctx.PendingTransactions.SingleAsync(p => p.Id == conflictingPending.Id);
+ Assert.Equal(PendingTransactionState.Broadcast, reloadedMatching.State);
+ Assert.Equal(p2shMultisig.FinalTransaction.GetHash().ToString(), reloadedMatching.TransactionId);
+ Assert.Equal(PendingTransactionState.Invalidated, reloadedConflict.State);
+ Assert.Null(reloadedConflict.TransactionId);
+ }
+ }
+
+ private static (PSBT Psbt, Transaction FinalTransaction) CreateP2SHMultisigPendingTransaction(Network network, uint prevTxNonce, Money amount)
+ {
+ var keys = new[] { new Key(), new Key(), new Key() };
+ var redeemScript = PayToMultiSigTemplate.Instance.GenerateScriptPubKey(2, keys.Select(key => key.PubKey).ToArray());
+ 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));
+ var psbt = PSBT.FromTransaction(tx, network);
+ psbt.Inputs[0].RedeemScript = redeemScript;
+
+ var finalTransaction = tx.Clone();
+ finalTransaction.Inputs[0].ScriptSig = new Script(
+ Op.GetPushOp(new byte[] { 1, 2, 3 }),
+ Op.GetPushOp(new byte[] { 4, 5, 6 }),
+ Op.GetPushOp(redeemScript.ToBytes()));
+ return (psbt, finalTransaction);
+ }
+
private static PSBT CreatePendingTransactionPSBT(Network network, uint prevTxNonce, Money amount)
{
var tx = Transaction.Create(network);
@@ -202,6 +290,47 @@ namespace BTCPayServer.Tests
return PSBT.FromTransaction(tx, network);
}
+ private class TestPendingTransactionService(
+ BTCPayNetworkProvider networkProvider,
+ ApplicationDbContextFactory dbContextFactory,
+ EventAggregator eventAggregator,
+ ILogger<PendingTransactionService> logger)
+ : PendingTransactionService(networkProvider, dbContextFactory, eventAggregator, logger)
+ {
+ public Task ProcessEventForTest(object evt)
+ {
+ return base.ProcessEvent(evt, default);
+ }
+ }
+
+ [Fact]
+ public async Task CanMigratePendingTransactionIds()
+ {
+ var tester = CreateDBTester();
+ await tester.MigrateUntil("20260521120000_split_pending_transaction_ids");
+
+ const string storeId = "TestStore";
+ const string pendingTransactionId = "pending-transaction-id";
+ const string noSignatureTransactionId = "no-signature-transaction-id";
+ await using (var ctx = tester.CreateContext())
+ {
+ await ctx.Database.GetDbConnection().ExecuteAsync("""
+ INSERT INTO "Stores" ("Id", "SpeedPolicy") VALUES (@storeId, 0);
+ INSERT INTO "PendingTransactions" ("Id", "TransactionId", "CryptoCode", "StoreId", "State", "OutpointsUsed", "Blob2")
+ VALUES (@pendingTransactionId, @noSignatureTransactionId, 'BTC', @storeId, 0, ARRAY['prevout:0']::TEXT[], '{}'::JSONB);
+ """, new { storeId, pendingTransactionId, noSignatureTransactionId });
+ }
+
+ await tester.CompleteMigrations();
+
+ await using (var ctx = tester.CreateContext())
+ {
+ var pendingTransaction = await ctx.PendingTransactions.SingleAsync(p => p.Id == pendingTransactionId);
+ Assert.Equal(noSignatureTransactionId, pendingTransaction.NoSignatureTransactionId);
+ Assert.Null(pendingTransaction.TransactionId);
+ }
+ }
+
[Fact]
public async Task CanMigrateInvoiceAddresses()
{
diff --git a/BTCPayServer.Tests/MultisigTests.cs b/BTCPayServer.Tests/MultisigTests.cs
index 46e0da6..7d236c7 100644
--- a/BTCPayServer.Tests/MultisigTests.cs
+++ b/BTCPayServer.Tests/MultisigTests.cs
@@ -1,11 +1,29 @@
using System;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
using BTCPayServer.Controllers;
+using BTCPayServer.Data;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Plugins.Multisig.Models;
+using BTCPayServer.Plugins.Multisig.Services;
+using BTCPayServer.Plugins.Wallets;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Stores;
using BTCPayServer.Views.Wallets;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Playwright;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
+using Newtonsoft.Json;
using Xunit;
+using static Microsoft.Playwright.Assertions;
namespace BTCPayServer.Tests;
@@ -14,13 +32,10 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
[Fact]
[Trait("Integration", "Integration")]
- public async Task SignTestPSBT()
+ public Task SignTestPSBT()
{
- var cryptoCode = "BTC";
- using var s = CreateServerTester();
- await s.StartAsync();
-
- var network = s.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
+ const string cryptoCode = "BTC";
+ var network = CreateNetworkProvider().GetNetwork<BTCPayNetwork>(cryptoCode);
var resp1 = generateWalletResp("tprv8ZgxMBicQKsPeGSkDtxjScBmmHP4rfSEPkf1vNmoqt5QjPTco2zPd6UVWkJf2fU8gdKPYRdDMizxtMRqmpVpxsWuqRxVs2d5VsEhwxaK3h7",
"57b3f43a/84'/1'/0'", "tpubDCzBHRPRcv7Y3utw1hZVrCar21gsj8vsXcehAG4z3R4NnmdMAASQwYYxGBd2f4q5s5ZFGvQBBFs1jVcGsXYoSTA1YFQPwizjsQLU12ibLyu", network);
var resp2 = generateWalletResp("tprv8ZgxMBicQKsPeC6Xuw83UJHgjnszEUjwH9E5f5FZ3fHgJHBQApo8CmFCsowcdwbRM119UnTqSzVWUsWGtLsxc8wnZa5L8xmEsvEpiyRj4Js",
@@ -36,11 +51,211 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
strategy.Source = "ManualDerivationScheme";
var derivationScheme = strategy.AccountDerivation;
- var testPSBT =
- "cHNidP8BAIkCAAAAAQmiSunnaKN7F4Jv5uHROfYbIZOckCck/Wo7gAQmi9hfAAAAAAD9////AtgbZgAAAAAAIgAgWCUFlU9eWkyxn0l0yQxs2rXQZ7d9Ry8LaYECaVC0TUGAlpgAAAAAACIAIFZxT+UIdhHZC4qFPhPQ6IXdX+44HIxCYcoh/bNOhB0hAAAAAAABAStAAf8AAAAAACIAIL2DDkfKwKHxZj2EKxXUd4uwf0IvPaCxUtAPq9snpq9TAQDqAgAAAAABAVuHuou9E5y6zUJaUreQD0wUeiPnT2aY+YU7QaPJOiQCAAAAAAD9////AkAB/wAAAAAAIgAgvYMOR8rAofFmPYQrFdR3i7B/Qi89oLFS0A+r2yemr1PM5AYpAQAAABYAFIlFupZkD07+GRo24WRS3IFcf+EuAkcwRAIgGi9wAcTfc0d0+j+Vg82aYklXCUsPg+g3jS+PTBTSQwkCIAPh5CZF18DTBKqWU2qdhNCbZ8Tp/NCEHjLJRHcH0oluASECWnI1s9ozQRL2qbK6JbLHzj9LlU9Pras3nZfq/njBJwhwAAAAAQVpUiECMCCasr2FRmRMiWkM/l1iraFR18td5SZ2APyQiaI0yY8hA8K96vH64BelUJiEPGwM6UTwRSfAJUR2j8dkw7i31fFTIQMlHLlaAPxw3fl1vaM1EofIirt79MXOryM54zpHwu1GlVOuIgIDwr3q8frgF6VQmIQ8bAzpRPBFJ8AlRHaPx2TDuLfV8VNHMEQCIANnprskJz8oVsetqOEViHtzhmSG8c36r3zmUIHwIoOhAiAZ1jBqj40iu2S/nMfiGyuCC/jSiSGik7YVwiwN+bbxPAEiBgIwIJqyvYVGZEyJaQz+XWKtoVHXy13lJnYA/JCJojTJjxhXs/Q6VAAAgAEAAIAAAACAAAAAAAUAAAAiBgMlHLlaAPxw3fl1vaM1EofIirt79MXOryM54zpHwu1GlRhsAU+zVAAAgAEAAIAAAACAAAAAAAUAAAAiBgPCverx+uAXpVCYhDxsDOlE8EUnwCVEdo/HZMO4t9XxUxjufTbEVAAAgAEAAIAAAACAAAAAAAUAAAAAAQFpUiEDa/J6SaiRjP1jhq9jpNxFKovEuWBz28seNMvsn0JC/ZIhA7p3bS7vLYB5UxlNN6YqkEDITyaMlk/i450q6+4woveAIQPTchIOrd+TNGBOX6il1HRZnBndyRoUj/hahbjTaAGHglOuIgIDa/J6SaiRjP1jhq9jpNxFKovEuWBz28seNMvsn0JC/ZIYV7P0OlQAAIABAACAAAAAgAEAAAABAAAAIgIDundtLu8tgHlTGU03piqQQMhPJoyWT+LjnSrr7jCi94AY7n02xFQAAIABAACAAAAAgAEAAAABAAAAIgID03ISDq3fkzRgTl+opdR0WZwZ3ckaFI/4WoW402gBh4IYbAFPs1QAAIABAACAAAAAgAEAAAABAAAAAAEBaVIhA/fCRR3MWwCgNuXMvlWLonY+TurUKOHXOSHALCck62deIQPqeQXD8ws9SDEDXSyD6a3WFlIGH+gDUf2/xAfw8HxE8iEC3LBRJYYxRzIeg9NxLGvtfATvFaKsO9D7AUjoTLZzke5TriICAtywUSWGMUcyHoPTcSxr7XwE7xWirDvQ+wFI6Ey2c5HuGGwBT7NUAACAAQAAgAAAAIAAAAAADAAAACICA+p5BcPzCz1IMQNdLIPprdYWUgYf6ANR/b/EB/DwfETyGO59NsRUAACAAQAAgAAAAIAAAAAADAAAACICA/fCRR3MWwCgNuXMvlWLonY+TurUKOHXOSHALCck62deGFez9DpUAACAAQAAgAAAAIAAAAAADAAAAAA=";
+ const string testPSBT = "cHNidP8BAIkCAAAAAQmiSunnaKN7F4Jv5uHROfYbIZOckCck/Wo7gAQmi9hfAAAAAAD9////AtgbZgAAAAAAIgAgWCUFlU9eWkyxn0l0yQxs2rXQZ7d9Ry8LaYECaVC0TUGAlpgAAAAAACIAIFZxT+UIdhHZC4qFPhPQ6IXdX+44HIxCYcoh/bNOhB0hAAAAAAABAStAAf8AAAAAACIAIL2DDkfKwKHxZj2EKxXUd4uwf0IvPaCxUtAPq9snpq9TAQDqAgAAAAABAVuHuou9E5y6zUJaUreQD0wUeiPnT2aY+YU7QaPJOiQCAAAAAAD9////AkAB/wAAAAAAIgAgvYMOR8rAofFmPYQrFdR3i7B/Qi89oLFS0A+r2yemr1PM5AYpAQAAABYAFIlFupZkD07+GRo24WRS3IFcf+EuAkcwRAIgGi9wAcTfc0d0+j+Vg82aYklXCUsPg+g3jS+PTBTSQwkCIAPh5CZF18DTBKqWU2qdhNCbZ8Tp/NCEHjLJRHcH0oluASECWnI1s9ozQRL2qbK6JbLHzj9LlU9Pras3nZfq/njBJwhwAAAAAQVpUiECMCCasr2FRmRMiWkM/l1iraFR18td5SZ2APyQiaI0yY8hA8K96vH64BelUJiEPGwM6UTwRSfAJUR2j8dkw7i31fFTIQMlHLlaAPxw3fl1vaM1EofIirt79MXOryM54zpHwu1GlVOuIgIDwr3q8frgF6VQmIQ8bAzpRPBFJ8AlRHaPx2TDuLfV8VNHMEQCIANnprskJz8oVsetqOEViHtzhmSG8c36r3zmUIHwIoOhAiAZ1jBqj40iu2S/nMfiGyuCC/jSiSGik7YVwiwN+bbxPAEiBgIwIJqyvYVGZEyJaQz+XWKtoVHXy13lJnYA/JCJojTJjxhXs/Q6VAAAgAEAAIAAAACAAAAAAAUAAAAiBgMlHLlaAPxw3fl1vaM1EofIirt79MXOryM54zpHwu1GlRhsAU+zVAAAgAEAAIAAAACAAAAAAAUAAAAiBgPCverx+uAXpVCYhDxsDOlE8EUnwCVEdo/HZMO4t9XxUxjufTbEVAAAgAEAAIAAAACAAAAAAAUAAAAAAQFpUiEDa/J6SaiRjP1jhq9jpNxFKovEuWBz28seNMvsn0JC/ZIhA7p3bS7vLYB5UxlNN6YqkEDITyaMlk/i450q6+4woveAIQPTchIOrd+TNGBOX6il1HRZnBndyRoUj/hahbjTaAGHglOuIgIDa/J6SaiRjP1jhq9jpNxFKovEuWBz28seNMvsn0JC/ZIYV7P0OlQAAIABAACAAAAAgAEAAAABAAAAIgIDundtLu8tgHlTGU03piqQQMhPJoyWT+LjnSrr7jCi94AY7n02xFQAAIABAACAAAAAgAEAAAABAAAAIgID03ISDq3fkzRgTl+opdR0WZwZ3ckaFI/4WoW402gBh4IYbAFPs1QAAIABAACAAAAAgAEAAAABAAAAAAEBaVIhA/fCRR3MWwCgNuXMvlWLonY+TurUKOHXOSHALCck62deIQPqeQXD8ws9SDEDXSyD6a3WFlIGH+gDUf2/xAfw8HxE8iEC3LBRJYYxRzIeg9NxLGvtfATvFaKsO9D7AUjoTLZzke5TriICAtywUSWGMUcyHoPTcSxr7XwE7xWirDvQ+wFI6Ey2c5HuGGwBT7NUAACAAQAAgAAAAIAAAAAADAAAACICA+p5BcPzCz1IMQNdLIPprdYWUgYf6ANR/b/EB/DwfETyGO59NsRUAACAAQAAgAAAAIAAAAAADAAAACICA/fCRR3MWwCgNuXMvlWLonY+TurUKOHXOSHALCck62deGFez9DpUAACAAQAAgAAAAIAAAAAADAAAAAA=";
var signedPsbt = SignWithSeed(testPSBT, derivationScheme, resp1);
- s.TestLogs.LogInformation($"Signed PSBT: {signedPsbt}");
+ TestLogs.LogInformation($"Signed PSBT: {signedPsbt}");
+ return Task.CompletedTask;
+ }
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task PendingMultisigTransactionTracksPerInputProgress()
+ {
+ var dbTester = CreateDBTester();
+ await dbTester.MigrateAsync();
+ var contextFactory = dbTester.CreateContextFactory();
+ var storeId = await CreateTestStore(contextFactory);
+ var eventAggregator = new EventAggregator(BTCPayLogs);
+ var signatureCollectedEvents = 0;
+ using var subscription = eventAggregator.Subscribe<PendingTransactionService.PendingTransactionEvent>((_, evt) =>
+ {
+ if (evt.Type == PendingTransactionService.PendingTransactionEvent.SignatureCollected)
+ signatureCollectedEvents++;
+ });
+ var pendingTransactionService = new PendingTransactionService(
+ CreateNetworkProvider(),
+ dbTester.CreateContextFactory(),
+ eventAggregator,
+ LoggerFactory.CreateLogger<PendingTransactionService>());
+ var requestBaseUrl = RequestBaseUrl.FromUrl("https://example.com");
+ var testPsbt = CreatePendingMultisigPsbt();
+
+ var pendingTransaction = await pendingTransactionService.CreatePendingTransaction(
+ storeId,
+ "BTC",
+ testPsbt.BasePsbt,
+ requestBaseUrl,
+ cancellationToken: CancellationToken.None);
+ var blob = pendingTransaction.GetBlob();
+ Assert.NotNull(blob);
+ Assert.Equal(2, blob.SignaturesNeeded);
+ Assert.Equal(3, blob.SignaturesTotal);
+ Assert.Equal(0, blob.SignaturesCollected);
+
+ var signerAAllInputs = SignInputs(testPsbt.BasePsbt, testPsbt.SignerA, 0, 1);
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id),
+ signerAAllInputs,
+ CancellationToken.None);
+ Assert.NotNull(pendingTransaction);
+ blob = pendingTransaction.GetBlob();
+ Assert.Equal(1, blob.SignaturesCollected);
+ Assert.Equal(1, Math.Max(0, (blob.SignaturesNeeded ?? 0) - (blob.SignaturesCollected ?? 0)));
+ Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
+ Assert.Single(blob.CollectedSignatures);
+ Assert.Equal(1, signatureCollectedEvents);
+
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id),
+ signerAAllInputs,
+ CancellationToken.None);
+ blob = pendingTransaction!.GetBlob();
+ Assert.Single(blob.CollectedSignatures);
+ Assert.Equal(1, blob.SignaturesCollected);
+ Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
+ Assert.Equal(1, signatureCollectedEvents);
+
+ var signerAPartial = SignInputs(testPsbt.BasePsbt, testPsbt.SignerA, 0);
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id),
+ signerAPartial,
+ CancellationToken.None);
+ blob = pendingTransaction!.GetBlob();
+ Assert.Single(blob.CollectedSignatures);
+ Assert.Equal(1, blob.SignaturesCollected);
+ Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
+ Assert.Equal(1, signatureCollectedEvents);
+
+ var signerBFirstInput = SignInputs(testPsbt.BasePsbt, testPsbt.SignerB, 0);
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id),
+ signerBFirstInput,
+ CancellationToken.None);
+ blob = pendingTransaction!.GetBlob();
+ Assert.Equal(2, blob.CollectedSignatures.Count);
+ Assert.Equal(1, blob.SignaturesCollected);
+ Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
+ Assert.Equal(2, signatureCollectedEvents);
+
+ var signerBSecondInput = SignInputs(testPsbt.BasePsbt, testPsbt.SignerB, 1);
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id),
+ signerBSecondInput,
+ CancellationToken.None);
+ blob = pendingTransaction!.GetBlob();
+ Assert.Equal(3, blob.CollectedSignatures.Count);
+ Assert.Equal(2, blob.SignaturesCollected);
+ Assert.Equal(0, Math.Max(0, (blob.SignaturesNeeded ?? 0) - (blob.SignaturesCollected ?? 0)));
+ Assert.Equal(PendingTransactionState.Signed, pendingTransaction.State);
+ Assert.Equal(3, signatureCollectedEvents);
+
+ var secondPendingTransaction = await pendingTransactionService.CreatePendingTransaction(
+ storeId,
+ "BTC",
+ testPsbt.BasePsbt,
+ requestBaseUrl,
+ cancellationToken: CancellationToken.None);
+ secondPendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, secondPendingTransaction.Id),
+ signerAAllInputs,
+ CancellationToken.None);
+ blob = secondPendingTransaction!.GetBlob();
+ Assert.Equal(1, blob.SignaturesCollected);
+
+ var signerBAllInputs = SignInputs(testPsbt.BasePsbt, testPsbt.SignerB, 0, 1);
+ secondPendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, secondPendingTransaction.Id),
+ signerBAllInputs,
+ CancellationToken.None);
+ blob = secondPendingTransaction!.GetBlob();
+ Assert.Equal(2, blob.SignaturesCollected);
+ Assert.Equal(PendingTransactionState.Signed, secondPendingTransaction.State);
+ Assert.Equal(2, blob.CollectedSignatures.Count);
+ }
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task PendingMultisigTransactionCountsFinalizedInputsAndSelfHealsStoredProgress()
+ {
+ var dbTester = CreateDBTester();
+ await dbTester.MigrateAsync();
+ var dbContextFactory = dbTester.CreateContextFactory();
+ var storeId = await CreateTestStore(dbContextFactory);
+ var pendingTransactionService = new PendingTransactionService(
+ CreateNetworkProvider(),
+ dbTester.CreateContextFactory(),
+ new EventAggregator(BTCPayLogs),
+ LoggerFactory.CreateLogger<PendingTransactionService>());
+ var requestBaseUrl = RequestBaseUrl.FromUrl("https://example.com");
+ var testPsbt = CreatePendingMultisigPsbt();
+
+ var pendingTransaction = await pendingTransactionService.CreatePendingTransaction(
+ storeId,
+ "BTC",
+ testPsbt.BasePsbt,
+ requestBaseUrl,
+ cancellationToken: CancellationToken.None);
+
+ var finalizedInputSubmission = SignInputs(testPsbt.BasePsbt, testPsbt.SignerA, 0, 1);
+ finalizedInputSubmission.Inputs[0].Sign(testPsbt.SignerB);
+ finalizedInputSubmission.Inputs[0].FinalizeInput();
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id),
+ finalizedInputSubmission,
+ CancellationToken.None);
+
+ var blob = pendingTransaction!.GetBlob();
+ Assert.Equal(1, blob.SignaturesCollected);
+ Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
+ Assert.True(finalizedInputSubmission.Inputs[0].IsFinalized());
+ Assert.NotNull(finalizedInputSubmission.Inputs[0].FinalScriptWitness);
+ Assert.Empty(finalizedInputSubmission.Inputs[0].PartialSigs);
+
+ await using (var ctx = dbContextFactory.CreateContext())
+ {
+ var staleTransaction = await ctx.PendingTransactions.FindAsync(pendingTransaction.Id);
+ Assert.NotNull(staleTransaction);
+ staleTransaction!.SetBlob(new PendingTransactionBlob
+ {
+ PSBT = blob.PSBT,
+ RequestBaseUrl = blob.RequestBaseUrl,
+ CollectedSignatures = blob.CollectedSignatures,
+ SignaturesNeeded = 0,
+ SignaturesTotal = 0,
+ SignaturesCollected = 99
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ var refreshed = await pendingTransactionService.GetPendingTransaction(
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id));
+ blob = refreshed!.GetBlob();
+ Assert.Equal(2, blob.SignaturesNeeded);
+ Assert.Equal(3, blob.SignaturesTotal);
+ Assert.Equal(1, blob.SignaturesCollected);
+
+ await using (var ctx = dbContextFactory.CreateContext())
+ {
+ var staleTransaction = await ctx.PendingTransactions.FindAsync(pendingTransaction.Id);
+ Assert.NotNull(staleTransaction);
+ staleTransaction!.SetBlob(new PendingTransactionBlob
+ {
+ PSBT = blob.PSBT,
+ RequestBaseUrl = blob.RequestBaseUrl,
+ CollectedSignatures = blob.CollectedSignatures,
+ SignaturesNeeded = 9,
+ SignaturesTotal = 9,
+ SignaturesCollected = 9
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ var refreshedList = await pendingTransactionService.GetPendingTransactions("BTC", storeId);
+ blob = Assert.Single(refreshedList).GetBlob();
+ Assert.Equal(2, blob.SignaturesNeeded);
+ Assert.Equal(3, blob.SignaturesTotal);
+ Assert.Equal(1, blob.SignaturesCollected);
}
[Fact]
@@ -50,7 +265,7 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
var cryptoCode = "BTC";
await using var s = CreatePlaywrightTester();
await s.StartAsync();
- await s.RegisterNewUser(true);
+ var owner = await s.RegisterNewUser(true);
var network = s.Server.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var resp1 = generateWalletResp("tprv8ZgxMBicQKsPeGSkDtxjScBmmHP4rfSEPkf1vNmoqt5QjPTco2zPd6UVWkJf2fU8gdKPYRdDMizxtMRqmpVpxsWuqRxVs2d5VsEhwxaK3h7",
@@ -68,6 +283,22 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
strategy.Source = "ManualDerivationScheme";
var derivationScheme = strategy.AccountDerivation;
await s.CreateNewStore();
+ var storeId = s.StoreId;
+ await s.Logout();
+ await s.GoToRegister();
+ var multisigner = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(owner);
+ await using (var scope = s.Server.PayTester.GetService<IServiceScopeFactory>().CreateAsyncScope())
+ {
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+ var storeRepo = scope.ServiceProvider.GetRequiredService<StoreRepository>();
+ var multisignerUser = await userManager.FindByEmailAsync(multisigner);
+ Assert.NotNull(multisignerUser);
+ await storeRepo.AddOrUpdateStoreUser(storeId, multisignerUser.Id, new StoreRoleId("Multisigner"));
+ }
await s.GoToWalletSettings();
await s.Page.ClickAsync("#ImportWalletOptionsLink");
await s.Page.ClickAsync("#ImportXpubLink");
@@ -95,14 +326,17 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
// validating the state of UI
Assert.Equal("0", await s.Page.TextContentAsync("#Sigs_0__Collected"));
+ Assert.Equal("2", await s.Page.TextContentAsync("#Sigs_0__Missing"));
Assert.Equal("2/3", await s.Page.TextContentAsync("#Sigs_0__Scheme"));
// now proceeding to click on sign button and sign transactions
await SignPendingTransactionWithKey(s, address, derivationScheme, resp1);
Assert.Equal("1", await s.Page.TextContentAsync("#Sigs_0__Collected"));
+ Assert.Equal("1", await s.Page.TextContentAsync("#Sigs_0__Missing"));
await SignPendingTransactionWithKey(s, address, derivationScheme, resp2);
Assert.Equal("2", await s.Page.TextContentAsync("#Sigs_0__Collected"));
+ Assert.Equal("0", await s.Page.TextContentAsync("#Sigs_0__Missing"));
// we should now have enough signatures to broadcast transaction
await s.Page.ClickAsync("//a[text()='Broadcast']");
@@ -123,11 +357,260 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.ClickAsync("#ConfirmContinue");
await s.FindAlertMessage(partialText: "Aborted Pending Transaction");
+ // Collecting without a user id should still update the pending transaction.
+ await s.GoToWallet(navPages: WalletsNavPages.Send);
+ await s.Page.FillAsync("#Outputs_0__DestinationAddress", address);
+ await s.Page.FillAsync("#Outputs_0__Amount", "0.3");
+ await s.Page.ClickAsync("#CreatePendingTransaction");
+
+ var pendingTransactionService = s.Server.PayTester.GetService<PendingTransactionService>();
+ var pendingWithoutSigner = Assert.Single(await pendingTransactionService.GetPendingTransactions(cryptoCode, storeId));
+ var pendingWithoutSignerBlob = pendingWithoutSigner.GetBlob();
+ Assert.NotNull(pendingWithoutSignerBlob?.PSBT);
+ var signedWithoutSigner = SignWithSeed(pendingWithoutSignerBlob.PSBT, derivationScheme, resp1);
+ await pendingTransactionService.CollectSignature(
+ new PendingTransactionService.PendingTransactionFullId(cryptoCode, storeId, pendingWithoutSigner.Id),
+ PSBT.Parse(signedWithoutSigner, network.NBitcoinNetwork),
+ CancellationToken.None,
+ signerUserId: null);
+
s.TestLogs.LogInformation($"Finished MultiSig Flow");
}
- private async Task SignPendingTransactionWithKey(PlaywrightTester s, string address,
- DerivationStrategyBase derivationScheme, GenerateWalletResponse signingKey)
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task CanUseMultisigSetup()
+ {
+ await using var s = CreatePlaywrightTester(newDb: true);
+ await s.StartAsync();
+
+ var owner = await s.RegisterNewUser(true);
+ await s.SkipWizard();
+ var (_, storeId) = await s.CreateNewStore();
+ await s.ConfigureServerEmailWithMailPit(from: "multisig-setup@test.com", login: "multisig-setup@test.com", password: "multisig-setup@test.com");
+ await s.Logout();
+ await s.GoToRegister();
+ var signerA = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var signerB = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var walletManager = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var employee = await s.RegisterNewUser();
+ await s.SkipWizard();
+
+ await using var scope = s.Server.PayTester.GetService<IServiceScopeFactory>().CreateAsyncScope();
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+ var storeRepo = scope.ServiceProvider.GetRequiredService<StoreRepository>();
+ var multisigService = scope.ServiceProvider.GetRequiredService<MultisigService>();
+
+ var signerAUser = await userManager.FindByEmailAsync(signerA);
+ var signerBUser = await userManager.FindByEmailAsync(signerB);
+ var walletManagerUser = await userManager.FindByEmailAsync(walletManager);
+ var employeeUser = await userManager.FindByEmailAsync(employee);
+ Assert.NotNull(signerAUser);
+ Assert.NotNull(signerBUser);
+ Assert.NotNull(walletManagerUser);
+ Assert.NotNull(employeeUser);
+
+ var walletSignerRole = new StoreRoleId(storeId, "Wallet Signer");
+ await storeRepo.AddOrUpdateStoreRole(walletSignerRole, new[] { WalletPolicies.CanSignWalletTransactions });
+ await storeRepo.AddOrUpdateStoreUser(storeId, signerAUser.Id, walletSignerRole);
+ await storeRepo.AddOrUpdateStoreUser(storeId, signerBUser.Id, walletSignerRole);
+ await storeRepo.AddOrUpdateStoreUser(storeId, walletManagerUser.Id, new StoreRoleId("Wallet Manager"));
+ await storeRepo.AddOrUpdateStoreUser(storeId, employeeUser.Id, StoreRoleId.Employee);
+
+ async Task<string> CreateRequest(params string[] participantEmails)
+ {
+ await s.GoToHome();
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(owner);
+ await s.GoToUrl($"/stores/{storeId}/onchain/BTC/import/multisig");
+ await Expect(s.Page.Locator($"label.multisig-signer-item:has-text('{employee}')")).ToHaveCountAsync(0);
+ foreach (var participantEmail in participantEmails)
+ {
+ await s.Page.Locator($"label.multisig-signer-item:has-text('{participantEmail}') input[type='checkbox']").CheckAsync();
+ }
+ await s.Page.FillAsync("#MultisigRequiredSigners", "2");
+ await s.Page.FillAsync("#MultisigTotalSigners", "2");
+ await s.Page.SelectOptionAsync("#MultisigScriptType", "p2wsh");
+ var signerRequestEmail = await s.Server.AssertHasEmail(async () =>
+ {
+ await s.Page.ClickAsync("#CreateSignerRequest");
+ });
+ Assert.Equal("Multisig signer request for BTC", signerRequestEmail.Subject);
+ var currentPending = (await multisigService.GetPendingMultisigSetup(storeId))[0];
+ Assert.NotNull(currentPending);
+ Assert.Equal(storeId, currentPending.StoreId);
+ Assert.Equal("BTC", currentPending.CryptoCode);
+ Assert.Equal(2, currentPending.Participants.Count);
+ Assert.All(currentPending.Participants, p => Assert.True(string.IsNullOrEmpty(p.AccountKey)));
+ Assert.Contains($"/multisig-setups/{currentPending.RequestId}", signerRequestEmail.Html ?? signerRequestEmail.Text ?? string.Empty, StringComparison.OrdinalIgnoreCase);
+ Assert.EndsWith($"/multisig-setups/{currentPending.RequestId}", new Uri(s.Page.Url).AbsolutePath);
+ await Expect(s.Page.Locator("#MultisigRequiredSigners")).ToHaveCountAsync(0);
+ await Expect(s.Page.Locator("#MultisigTotalSigners")).ToHaveCountAsync(0);
+ await Expect(s.Page.Locator("#MultisigScriptType")).ToHaveCountAsync(0);
+ await Expect(s.Page.Locator("#CreateSignerRequest")).ToHaveCountAsync(0);
+ return currentPending.RequestId;
+ }
+
+ async Task SubmitSigner(string email, string requestId, string accountKey, RootedKeyPath accountKeyPath, string expectedMessage, bool submitForm = true, bool useVaultCallback = false, bool expectNotification = true)
+ {
+ await s.GoToHome();
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(email);
+ if (useVaultCallback)
+ {
+ await InstallMockVaultAsync(s.Page, accountKey, accountKeyPath.MasterFingerprint.ToString());
+ }
+ await s.GoToUrl(submitForm
+ ? $"/multisig-setups/{Uri.EscapeDataString(requestId)}"
+ : $"/multisig-setups/{Uri.EscapeDataString(requestId)}/signer-key");
+ if (submitForm)
+ {
+ await Expect(s.Page.Locator("#SubmitSignerKeyCta")).ToBeVisibleAsync();
+ await s.Page.ClickAsync("#SubmitSignerKeyCta");
+ if (useVaultCallback)
+ {
+ await s.Page.ClickAsync("#SubmitSignerKeyHardware");
+ await Expect(s.Page.Locator("#SubmitSignerKeyButton")).ToBeDisabledAsync();
+ await Expect(s.Page.Locator("#SignerKeyFields")).ToHaveClassAsync(new Regex("d-none"));
+ await Expect(s.Page.Locator("#vault-confirm")).ToBeVisibleAsync();
+ await Expect(s.Page.Locator("#keyPath")).ToHaveValueAsync("m/48'/1'/0'/2'");
+ await s.Page.FillAsync("#accountNumber", "3");
+ await Expect(s.Page.Locator("#keyPath")).ToHaveValueAsync("m/48'/1'/3'/2'");
+ await s.Page.FillAsync("#accountNumber", "0");
+ await Expect(s.Page.Locator("#keyPath")).ToHaveValueAsync("m/48'/1'/0'/2'");
+ await s.Page.ClickAsync("#vault-confirm");
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ var vaultRequests = await s.Page.EvaluateAsync<string[]>("window.__vaultRequests || []");
+ Assert.Contains(vaultRequests, command => command.Contains("getxpub", StringComparison.OrdinalIgnoreCase));
+ }, 30000);
+ await Expect(s.Page.Locator("#DisplayAccountKey")).ToHaveValueAsync(accountKey, new LocatorAssertionsToHaveValueOptions { Timeout = 30000 });
+ await Expect(s.Page.Locator("#SignerKeyFields")).Not.ToHaveClassAsync(new Regex("d-none"));
+ await Expect(s.Page.Locator("#SubmitSignerKeyButton")).ToBeEnabledAsync();
+ Assert.Equal(accountKey, await s.Page.InputValueAsync("#DisplayAccountKey"));
+ Assert.Equal(accountKeyPath.ToString(), await s.Page.InputValueAsync("#AccountKeyPath"));
+ }
+ else
+ {
+ await s.Page.ClickAsync("#SubmitSignerKeyManual");
+ await s.Page.FillAsync("#DisplayAccountKey", accountKey);
+ await s.Page.FillAsync("#AccountKeyPath", accountKeyPath.ToString());
+ }
+ if (expectNotification)
+ {
+ var submissionEmail = await s.Server.AssertHasEmail(async () =>
+ {
+ await s.Page.ClickAsync("button[type='submit']");
+ });
+ Assert.Equal("Multisig signer submitted (BTC)", submissionEmail.Subject);
+ }
+ else
+ {
+ await s.Page.ClickAsync("button[type='submit']");
+ }
+ }
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(expectedMessage);
+ }
+
+ var signerAKey = CreateTestMultisigSignerKey("all all all all all all all all all all all all", "48'/1'/0'/2'");
+ var signerBKey = CreateTestMultisigSignerKey("click chunk owner kingdom faint steak safe evidence bicycle repeat bulb wheel");
+ var walletManagerKey = CreateTestMultisigSignerKey("letter advice cage absurd amount doctor acoustic avoid letter advice cage above");
+
+ var firstRequestId = await CreateRequest(signerA, signerB);
+ await s.GoToUrl($"/stores/{storeId}/onchain/BTC/import/multisig");
+ await s.Page.EvaluateAsync(
+ "(employeeId) => { const button = document.querySelector('#CreateSignerRequest'); const form = button.form; const input = document.createElement('input'); input.type = 'hidden'; input.name = 'MultisigParticipantUserIds'; input.value = employeeId; form.appendChild(input); button.disabled = false; }",
+ employeeUser.Id);
+ await s.Page.ClickAsync("#CreateSignerRequest");
+ await Expect(s.Page.Locator("body")).ToContainTextAsync("One or more selected users are invalid.");
+ var pendingAfterInvalidSigner = (await multisigService.GetPendingMultisigSetup(storeId))[0];
+ Assert.DoesNotContain(pendingAfterInvalidSigner.Participants, p => p.UserId == employeeUser.Id);
+
+ await s.GoToHome();
+ var dashboardMultisigSetup = s.Page.Locator($"#SetupGuide-Multisig-{firstRequestId}");
+ await Expect(dashboardMultisigSetup).ToBeVisibleAsync();
+ await Expect(dashboardMultisigSetup).ToContainTextAsync("Multisig wallet setup");
+ var firstSessionPath = $"/multisig-setups/{Uri.EscapeDataString(firstRequestId)}";
+
+ await s.Page.Locator("#menu-item-Account").WaitForAsync();
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(employee);
+ await s.GoToUrl(firstSessionPath, ignoreResponse: true);
+ Assert.Contains("/errors/403", new Uri(s.Page.Url).AbsolutePath, StringComparison.OrdinalIgnoreCase);
+
+ await SubmitSigner(signerA, firstRequestId, signerAKey.AccountKey, signerAKey.AccountKeyPath, "Signer key submitted successfully.", useVaultCallback: true);
+ var pendingAfterFirstSubmit = (await multisigService.GetPendingMultisigSetup(storeId))[0];
+ var signerAParticipant = pendingAfterFirstSubmit.Participants.Single(p => p.UserId == signerAUser.Id);
+ Assert.Equal(signerAKey.AccountKey, signerAParticipant.AccountKey);
+
+ await SubmitSigner(signerA, firstRequestId, signerBKey.AccountKey, signerBKey.AccountKeyPath, "Your signer key is submitted.", submitForm: false);
+ var pendingAfterResubmit = (await multisigService.GetPendingMultisigSetup(storeId))[0];
+ var signerAAfterResubmit = pendingAfterResubmit.Participants.Single(p => p.UserId == signerAUser.Id);
+ Assert.Equal(signerAKey.AccountKey, signerAAfterResubmit.AccountKey);
+
+ await SubmitSigner(signerB, firstRequestId, signerAKey.AccountKey, signerAKey.AccountKeyPath, "This signer key is already used in this multisig request.", expectNotification: false);
+ var pendingAfterDuplicate = (await multisigService.GetPendingMultisigSetup(storeId))[0];
+ var signerBParticipant = pendingAfterDuplicate.Participants.Single(p => p.UserId == signerBUser.Id);
+ Assert.True(string.IsNullOrWhiteSpace(signerBParticipant.AccountKey));
+
+ await s.GoToHome();
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(owner);
+ await s.GoToUrl(firstSessionPath);
+ await Expect(s.Page.Locator("#FinalizeMultisig")).ToHaveCountAsync(0);
+
+ var secondRequestId = await CreateRequest(walletManager, signerB);
+ Assert.NotEqual(firstRequestId, secondRequestId);
+ var pendingAfterSecondRequest = (await multisigService.GetPendingMultisigSetup(storeId))[0];
+ Assert.Equal(secondRequestId, pendingAfterSecondRequest?.RequestId);
+ var staleSessionResponse = await s.Page.GotoAsync(s.Link(firstSessionPath), new() { WaitUntil = WaitUntilState.Commit });
+ Assert.Equal(404, staleSessionResponse?.Status);
+ await SubmitSigner(walletManager, secondRequestId, walletManagerKey.AccountKey, walletManagerKey.AccountKeyPath, "Signer key submitted successfully.");
+ await SubmitSigner(signerB, secondRequestId, signerBKey.AccountKey, signerBKey.AccountKeyPath, "Signer key submitted successfully.");
+
+ await s.GoToHome();
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(signerA);
+ await s.GoToUrl($"/multisig-setups/{Uri.EscapeDataString(secondRequestId)}", ignoreResponse: true);
+ Assert.Contains("/errors/403", new Uri(s.Page.Url).AbsolutePath, StringComparison.OrdinalIgnoreCase);
+
+ await s.GoToHome();
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(walletManager);
+ await s.GoToUrl($"/multisig-setups/{Uri.EscapeDataString(secondRequestId)}");
+ await Expect(s.Page.Locator("#Nav-Wallets a").Filter(new() { HasText = "Send" })).ToHaveCountAsync(0);
+ await Expect(s.Page.Locator("#Nav-Wallets a").Filter(new() { HasText = "Receive" })).ToHaveCountAsync(0);
+ await Expect(s.Page.Locator("body")).ToContainTextAsync("2/2 submitted");
+ await Expect(s.Page.Locator("#FinalizeMultisig")).ToBeVisibleAsync();
+ await s.Page.ClickAsync("#FinalizeMultisig");
+ await Expect(s.Page.Locator("#Confirm")).ToBeVisibleAsync();
+ await Expect(s.Page.Locator("#CancelWizard")).ToHaveAttributeAsync("href", $"/stores/{storeId}/index");
+ var walletCreatedEmail = await s.Server.AssertHasEmail(async () =>
+ {
+ await s.Page.ClickAsync("#Confirm");
+ });
+ Assert.Equal("Multisig wallet created for BTC", walletCreatedEmail.Subject);
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(walletManager);
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(signerB);
+ }
+
+ private async Task<MailPitClient.Message> SignPendingTransactionWithKey(PlaywrightTester s, string address,
+ DerivationStrategyBase derivationScheme, GenerateWalletResponse signingKey, string expectedNotificationSubject = null)
{
// getting to pending transaction page
await s.Page.ClickAsync("//a[text()='View']");
@@ -147,7 +630,20 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.ClickAsync("#PSBTOptionsImportHeader");
await s.Page.FillAsync("#ImportedPSBT", signedPsbt);
- await s.Page.ClickAsync("#Collect");
+ if (string.IsNullOrEmpty(expectedNotificationSubject))
+ {
+ await s.Page.ClickAsync("#Collect");
+ return null;
+ }
+ else
+ {
+ var signatureCollectedEmail = await s.Server.AssertHasEmail(async () =>
+ {
+ await s.Page.ClickAsync("#Collect");
+ });
+ Assert.Equal(expectedNotificationSubject, signatureCollectedEmail.Subject);
+ return signatureCollectedEmail;
+ }
}
private GenerateWalletResponse generateWalletResp(string tpriv, string keypath, string derivation, BTCPayNetwork network)
@@ -168,6 +664,63 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
return resp1;
}
+ private static async Task InstallMockVaultAsync(IPage page, string accountKey, string fingerprint)
+ {
+ await page.Context.AddInitScriptAsync($$"""
+ (() => {
+ const originalFetch = window.fetch.bind(window);
+ const accountKey = {{System.Text.Json.JsonSerializer.Serialize(accountKey)}};
+ const fingerprint = {{System.Text.Json.JsonSerializer.Serialize(fingerprint)}};
+ window.__vaultRequests = [];
+ window.fetch = async (input, init) => {
+ const url = typeof input === 'string' ? input : input.url;
+ if (!url.startsWith('http://127.0.0.1:65092/hwi-bridge/v1')) {
+ return originalFetch(input, init);
+ }
+
+ if (url.endsWith('/request-permission')) {
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/plain' } });
+ }
+
+ const requestBody = init && typeof init.body === 'string' ? JSON.parse(init.body) : {};
+ const params = Array.isArray(requestBody.params) ? requestBody.params : [];
+ const command = params.join(' ');
+ window.__vaultRequests.push(command);
+
+ if (command.includes('--version')) {
+ return new Response('hwi 2.4.0', { status: 200, headers: { 'Content-Type': 'text/plain' } });
+ }
+
+ if (command.includes('enumerate')) {
+ return new Response(JSON.stringify([{
+ model: 'trezor_safe_3',
+ path: 'mock-device-path',
+ fingerprint,
+ needs_pin_sent: false,
+ needs_passphrase_sent: false
+ }]), { status: 200, headers: { 'Content-Type': 'text/plain' } });
+ }
+
+ if (command.includes('getxpub')) {
+ return new Response(JSON.stringify({ xpub: accountKey }), { status: 200, headers: { 'Content-Type': 'text/plain' } });
+ }
+
+ return new Response(JSON.stringify({ success: false, error: 'unexpected command: ' + command }), { status: 200, headers: { 'Content-Type': 'text/plain' } });
+ };
+ })();
+ """);
+ }
+
+ private static (string AccountKey, RootedKeyPath AccountKeyPath) CreateTestMultisigSignerKey(string mnemonic, string accountKeyPath = "84'/1'/0'")
+ {
+ var rootKey = new Mnemonic(mnemonic).DeriveExtKey();
+ var path = KeyPath.Parse(accountKeyPath);
+ return (
+ rootKey.Derive(path).Neuter().ToString(Network.RegTest),
+ new RootedKeyPath(rootKey.GetPublicKey().GetHDFingerPrint(),
+ KeyPath.Parse($"m/{path}")));
+ }
+
public string SignWithSeed(string psbtBase64, DerivationStrategyBase derivationStrategyBase,
GenerateWalletResponse resp)
@@ -196,4 +749,64 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
// Return the updated and signed PSBT
return psbt.ToBase64();
}
+
+ private static TestPendingMultisigPsbt CreatePendingMultisigPsbt()
+ {
+ var network = Network.RegTest;
+ var signerA = new Key();
+ var signerB = new Key();
+ var signerC = new Key();
+ var witnessScript = PayToMultiSigTemplate.Instance.GenerateScriptPubKey(2, signerA.PubKey, signerB.PubKey, signerC.PubKey);
+ var scriptPubKey = witnessScript.WitHash.ScriptPubKey;
+
+ var previousTransactionA = network.CreateTransaction();
+ previousTransactionA.Outputs.Add(Money.Coins(1.0m), scriptPubKey);
+ var previousTransactionB = network.CreateTransaction();
+ previousTransactionB.Outputs.Add(Money.Coins(1.1m), scriptPubKey);
+
+ var builder = network.CreateTransactionBuilder();
+ builder.AddCoins(
+ previousTransactionA.Outputs.AsCoins().First().ToScriptCoin(witnessScript),
+ previousTransactionB.Outputs.AsCoins().First().ToScriptCoin(witnessScript));
+ builder.Send(new Key().PubKey.WitHash.ScriptPubKey, Money.Coins(1.5m));
+ builder.SetChange(new Key().PubKey.WitHash.ScriptPubKey);
+ builder.SendFees(Money.Satoshis(10_000));
+
+ return new TestPendingMultisigPsbt(builder.BuildPSBT(false), signerA, signerB, signerC);
+ }
+
+ private static PSBT SignInputs(PSBT basePsbt, Key signer, params int[] inputIndexes)
+ {
+ var psbt = basePsbt.Clone();
+ foreach (var inputIndex in inputIndexes)
+ {
+ psbt.Inputs[inputIndex].Sign(signer);
+ }
+
+ return psbt;
+ }
+
+ private static async Task<string> CreateTestStore(ApplicationDbContextFactory contextFactory)
+ {
+ await using var ctx = contextFactory.CreateContext();
+ var store = new StoreData
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ StoreName = "Test Store",
+ DerivationStrategies = "{}",
+ StoreBlob = "{}"
+ };
+ ctx.Stores.Add(store);
+ await ctx.SaveChangesAsync();
+ return store.Id;
+ }
+
+ private StoreRepository CreateStoreRepository(ApplicationDbContextFactory contextFactory)
+ {
+ var eventAggregator = new EventAggregator(BTCPayLogs);
+ var settingsRepository = new SettingsRepository(contextFactory, eventAggregator, new MemoryCache(new MemoryCacheOptions()));
+ return new StoreRepository(contextFactory, new JsonSerializerSettings(), eventAggregator, settingsRepository);
+ }
+
+ private sealed record TestPendingMultisigPsbt(PSBT BasePsbt, Key SignerA, Key SignerB, Key SignerC);
}
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index c200f19..0c2e9b9 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -10,6 +10,8 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
+using BTCPayServer.Plugins.Emails.Services;
+using BTCPayServer.Services;
using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
@@ -534,6 +536,20 @@ namespace BTCPayServer.Tests
await Page.Locator("#page-primary").ClickAsync();
}
+ public async Task ConfigureServerEmailWithMailPit(string from = "test@example.com", string login = "test@example.com", string password = "password")
+ {
+ var settings = Server.PayTester.GetService<SettingsRepository>();
+ await settings.UpdateSetting(new PoliciesSettings { DisableStoresToUseServerEmailSettings = false });
+ await settings.UpdateSetting(new EmailSettings
+ {
+ From = from,
+ Login = login,
+ Password = password,
+ Port = Server.MailPitSettings.SmtpPort,
+ Server = Server.MailPitSettings.Hostname
+ });
+ }
+
public async Task AddStoreLabelAsync(ILocator row, string label)
{
var labelInput = row.Locator(".ts-control input");
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index 54ee3ed..2005a00 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -296,13 +296,6 @@
btn.setAttribute('aria-expanded', 'false')
}
})
- // hide empty plugins drawer
- const pluginsNav = document.getElementById('Nav-Plugins')
- const pluginsItem = pluginsNav?.closest('.accordion-item')
- const pluginsContent = pluginsItem?.querySelector('.navbar-nav')?.innerHTML.trim()
- if (pluginsItem && pluginsContent === '') {
- pluginsItem.setAttribute('hidden', true)
- }
})()
</script>
}
diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs
index 4576119..3b8d662 100644
--- a/BTCPayServer/Controllers/UIStoresController.cs
+++ b/BTCPayServer/Controllers/UIStoresController.cs
@@ -149,7 +149,8 @@ public partial class UIStoresController : Controller
}
else if ((await _authorizationService.AuthorizeAsync(User, storeId, WalletPolicies.CanViewWallet)).Succeeded)
{
- redirect = RedirectToAction(nameof(UIWalletsController.ListWallets), "UIWallets", new { area = WalletsPlugin.Area });
+ redirect = RedirectToConfiguredWallet(store) ??
+ RedirectToAction(nameof(UIWalletsController.ListWallets), "UIWallets", new { area = WalletsPlugin.Area });
}
if (redirect is null)
@@ -160,6 +161,26 @@ public partial class UIStoresController : Controller
return redirect;
}
+ private IActionResult? RedirectToConfiguredWallet(StoreData store)
+ {
+ AddPaymentMethods(store, store.GetStoreBlob(), out var derivationSchemes, out _);
+ var defaultPaymentId = store.GetDefaultPaymentId();
+ var walletId = derivationSchemes
+ .Where(s => s.Enabled && s.WalletSupported)
+ .OrderByDescending(s => s.PaymentMethodId == defaultPaymentId)
+ .ThenByDescending(s => s.Crypto == _networkProvider.DefaultCryptoCode)
+ .Select(s => s.WalletId)
+ .FirstOrDefault();
+
+ return walletId is null
+ ? null
+ : RedirectToAction(nameof(UIWalletsController.WalletTransactions), "UIWallets", new
+ {
+ area = WalletsPlugin.Area,
+ walletId = walletId.ToString()
+ });
+ }
+
public StoreData CurrentStore => HttpContext.GetStoreData();
public PaymentMethodOptionViewModel.Format[] GetEnabledPaymentMethodChoices(StoreData storeData)
diff --git a/BTCPayServer/DerivationSchemeSettings.cs b/BTCPayServer/DerivationSchemeSettings.cs
index 89b9619..d453061 100644
--- a/BTCPayServer/DerivationSchemeSettings.cs
+++ b/BTCPayServer/DerivationSchemeSettings.cs
@@ -146,6 +146,7 @@ namespace BTCPayServer
{
public HDFingerprint? RootFingerprint { get; set; }
public KeyPath AccountKeyPath { get; set; }
+ public string SignerUserId { get; set; }
public RootedKeyPath GetRootedKeyPath()
{
diff --git a/BTCPayServer/Extensions/UserExtensions.cs b/BTCPayServer/Extensions/UserExtensions.cs
index f080718..a1552c0 100644
--- a/BTCPayServer/Extensions/UserExtensions.cs
+++ b/BTCPayServer/Extensions/UserExtensions.cs
@@ -1,3 +1,4 @@
+#nullable enable
using System;
using BTCPayServer.Data;
@@ -9,10 +10,16 @@ namespace BTCPayServer
{
if (user is null)
throw new ArgumentNullException(nameof(user));
- var name = user.UserName ?? String.Empty;
- if (user.Email == user.UserName)
- name = String.Empty;
- return new MimeKit.MailboxAddress(name, user.Email);
+
+ var name = user.GetBlob()?.Name;
+ try
+ {
+ return new MimeKit.MailboxAddress(name ?? "", user.Email!);
+ }
+ catch // Invalid encoding or format; treat as no valid mailbox
+ {
+ }
+ return new MimeKit.MailboxAddress(string.Empty, user.Email!);
}
}
}
diff --git a/BTCPayServer/HostedServices/PendingTransactionService.cs b/BTCPayServer/HostedServices/PendingTransactionService.cs
index 31a263b..3097ed6 100644
--- a/BTCPayServer/HostedServices/PendingTransactionService.cs
+++ b/BTCPayServer/HostedServices/PendingTransactionService.cs
@@ -1,5 +1,6 @@
#nullable enable
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -52,11 +53,13 @@ public class PendingTransactionService(
{
await using var ctx = dbContextFactory.CreateContext();
var cryptoCode = newTransactionEvent.NewTransactionEvent.CryptoCode;
- var txInputs = newTransactionEvent.NewTransactionEvent.TransactionData.Transaction.Inputs
+ var transaction = newTransactionEvent.NewTransactionEvent.TransactionData.Transaction;
+ var txInputs = transaction.Inputs
.Select(i => i.PrevOut.ToString()).ToArray();
var txHash = newTransactionEvent.NewTransactionEvent.TransactionData.TransactionHash.ToString();
+ var noSignatureTxHash = GetNoSignatureHash(transaction).ToString();
var pendingTransactions = await ctx.PendingTransactions
- .Where(p => p.CryptoCode == cryptoCode && (p.TransactionId == txHash || p.OutpointsUsed.Any(o => txInputs.Contains(o))))
+ .Where(p => p.CryptoCode == cryptoCode && (p.NoSignatureTransactionId == noSignatureTxHash || p.OutpointsUsed.Any(o => txInputs.Contains(o))))
.ToArrayAsync(cancellationToken: cancellationToken);
if (!pendingTransactions.Any())
{
@@ -65,9 +68,13 @@ public class PendingTransactionService(
foreach (var pendingTransaction in pendingTransactions)
{
- if (pendingTransaction.TransactionId == txHash)
+ if (pendingTransaction.State == PendingTransactionState.Broadcast)
+ continue;
+
+ if (pendingTransaction.NoSignatureTransactionId == noSignatureTxHash)
{
pendingTransaction.State = PendingTransactionState.Broadcast;
+ pendingTransaction.TransactionId = txHash;
continue;
}
@@ -91,46 +98,29 @@ public class PendingTransactionService(
if (network is null)
throw new NotSupportedException("CryptoCode not supported");
- var txId = psbt.GetGlobalTransaction().GetHash();
-
- int signaturesNeeded = 0;
- int signaturesTotal = 0;
-
- foreach (var input in psbt.Inputs)
- {
- var script = input.WitnessScript ?? input.RedeemScript;
- if (script is null)
- continue;
-
- var multisigParams = PayToMultiSigTemplate.Instance.ExtractScriptPubKeyParameters(script);
- if (multisigParams != null)
- {
- signaturesNeeded = multisigParams.SignatureCount;
- signaturesTotal = multisigParams.PubKeys.Length;
- break; // assume consistent multisig scheme across all inputs
- }
- }
-
+ var noSignatureTransactionId = psbt.GetGlobalTransaction().GetHash();
+ // If the transaction can't be malleated by a third party, it is safe to show the transaction ID.
+ var malleabilitySafe = psbt.Inputs.All(i => i.GetCoin()?.IsMalleable is false);
await using var ctx = dbContextFactory.CreateContext();
var pendingTransaction = new PendingTransaction
{
Id = Guid.NewGuid().ToString(),
CryptoCode = cryptoCode,
- TransactionId = txId.ToString(),
+ NoSignatureTransactionId = noSignatureTransactionId.ToString(),
+ TransactionId = malleabilitySafe ? noSignatureTransactionId.ToString() : null,
State = PendingTransactionState.Pending,
OutpointsUsed = psbt.Inputs.Select(i => i.PrevOut.ToString()).ToArray(),
Expiry = expiry,
StoreId = storeId,
};
- pendingTransaction.SetBlob(new PendingTransactionBlob
+ var blob = new PendingTransactionBlob
{
PSBT = psbt.ToBase64(),
- SignaturesCollected = 0,
- SignaturesNeeded = signaturesNeeded,
- SignaturesTotal = signaturesTotal,
RequestBaseUrl = requestBaseUrl.ToString()
- });
+ };
+ ApplyProgress(blob, GetSignatureProgress(psbt));
+ pendingTransaction.SetBlob(blob);
ctx.PendingTransactions.Add(pendingTransaction);
await ctx.SaveChangesAsync(cancellationToken);
@@ -138,88 +128,92 @@ public class PendingTransactionService(
EventAggregator.Publish(new PendingTransactionEvent
{
Data = pendingTransaction,
+ SignerUserId = null,
Type = PendingTransactionEvent.Created
});
return pendingTransaction;
}
- public async Task<PendingTransaction?> CollectSignature(PendingTransactionFullId id, PSBT psbt, CancellationToken cancellationToken)
+ public async Task<PendingTransaction?> CollectSignature(PendingTransactionFullId id, PSBT psbt, CancellationToken cancellationToken, string? signerUserId = null)
+ {
+ const int maxAttempts = 3;
+ var attempt = 0;
+ var newPsbtBase64 = psbt.ToBase64();
+
+ retry:
+ await using (var ctx = dbContextFactory.CreateContext())
+ {
+ try
+ {
+ var result = await TryCollectSignatureOnce(ctx, id, psbt, newPsbtBase64, cancellationToken);
+ if (result.PendingTransaction is null)
+ return null;
+
+ if (!result.Changed)
+ return result.PendingTransaction;
+
+ await ctx.SaveChangesAsync(cancellationToken);
+ EventAggregator.Publish(new PendingTransactionEvent
+ {
+ Data = result.PendingTransaction,
+ SignerUserId = signerUserId,
+ Type = PendingTransactionEvent.SignatureCollected
+ });
+ return result.PendingTransaction;
+ }
+ catch (DbUpdateConcurrencyException) when (++attempt < maxAttempts)
+ {
+ // Another signer updated the row first; re-read and merge onto the latest PSBT.
+ goto retry;
+ }
+ }
+ }
+
+ private async Task<(PendingTransaction? PendingTransaction, bool Changed)> TryCollectSignatureOnce(
+ ApplicationDbContext ctx,
+ PendingTransactionFullId id,
+ PSBT psbt,
+ string newPsbtBase64,
+ CancellationToken cancellationToken)
{
- await using var ctx = dbContextFactory.CreateContext();
var pendingTransaction = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id, cancellationToken);
if (pendingTransaction?.State is not PendingTransactionState.Pending)
- {
- return null;
- }
+ return (null, false);
var blob = pendingTransaction.GetBlob();
if (blob?.PSBT is null)
- {
- return null;
- }
+ return (null, false);
- var dbPsbt = PSBT.Parse(blob.PSBT, psbt.Network);
+ var network = networkProvider.GetNetwork<BTCPayNetwork>(pendingTransaction.CryptoCode)?.NBitcoinNetwork ?? psbt.Network;
- // Deduplicate: Check if this exact PSBT (Base64) was already collected
- var newPsbtBase64 = psbt.ToBase64();
if (blob.CollectedSignatures.Any(s => s.ReceivedPSBT == newPsbtBase64))
- {
- return pendingTransaction; // Avoid duplicate signature collection
- }
-
- foreach (var collectedSignature in blob.CollectedSignatures)
- {
- var collectedPsbt = PSBT.Parse(collectedSignature.ReceivedPSBT, psbt.Network);
- dbPsbt.Combine(collectedPsbt); // combine changes the object
- }
-
- var newWorkingCopyPsbt = dbPsbt.Clone(); // Clone before modifying
- newWorkingCopyPsbt.Combine(psbt);
-
- // Check if new signatures were actually added
- var oldPubKeys = dbPsbt.Inputs
- .SelectMany(input => input.PartialSigs.Keys)
- .ToHashSet();
+ return (pendingTransaction, false);
- var newPubKeys = newWorkingCopyPsbt.Inputs
- .SelectMany(input => input.PartialSigs.Keys)
- .ToHashSet();
+ var beforeProgress = GetSignatureProgress(BuildEffectivePsbt(blob, network));
+ var mergedPsbt = BuildEffectivePsbt(blob, network, psbt);
+ var afterProgress = GetSignatureProgress(mergedPsbt);
- newPubKeys.ExceptWith(oldPubKeys);
+ if (!HasMeaningfulDelta(beforeProgress, afterProgress))
+ return (pendingTransaction, false);
- var newSignatures = newPubKeys.Count;
- if (newSignatures > 0)
+ blob.CollectedSignatures.Add(new CollectedSignature
{
- // TODO: For now we're going with estimation of how many signatures were collected until we find better way
- // so for example if we have 4 new signatures and only 2 inputs - number of collected signatures will be 2
- blob.SignaturesCollected += newSignatures / newWorkingCopyPsbt.Inputs.Count;
- blob.CollectedSignatures.Add(new CollectedSignature
- {
- ReceivedPSBT = newPsbtBase64,
- Timestamp = DateTimeOffset.UtcNow
- });
- pendingTransaction.SetBlob(blob);
- }
+ ReceivedPSBT = newPsbtBase64,
+ Timestamp = DateTimeOffset.UtcNow
+ });
+ ApplyProgress(blob, afterProgress);
- if (newWorkingCopyPsbt.TryFinalize(out _))
+ if (mergedPsbt.TryFinalize(out _))
{
- // TODO: Better logic here
- if (blob.SignaturesCollected < blob.SignaturesNeeded)
+ if ((blob.SignaturesCollected ?? 0) < (blob.SignaturesNeeded ?? 0))
blob.SignaturesCollected = blob.SignaturesNeeded;
-
pendingTransaction.State = PendingTransactionState.Signed;
}
-
- await ctx.SaveChangesAsync(cancellationToken);
- EventAggregator.Publish(new PendingTransactionEvent
- {
- Data = pendingTransaction,
- Type = PendingTransactionEvent.SignatureCollected
- });
- return pendingTransaction;
+ pendingTransaction.SetBlob(blob);
+ return (pendingTransaction, true);
}
@@ -227,17 +221,27 @@ public class PendingTransactionService(
public async Task<PendingTransaction?> GetPendingTransaction(PendingTransactionFullId id)
{
await using var ctx = dbContextFactory.CreateContext();
- return await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
+ var pendingTransaction = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id);
+ if (pendingTransaction is null)
+ return null;
+ if (TryRefreshStoredProgress(pendingTransaction))
+ await ctx.SaveChangesAsync();
+ return pendingTransaction;
}
public async Task<PendingTransaction[]> GetPendingTransactions(string cryptoCode, string storeId)
{
await using var ctx = dbContextFactory.CreateContext();
- return await ctx.PendingTransactions.Where(p =>
+ var pendingTransactions = await ctx.PendingTransactions.Where(p =>
p.CryptoCode == cryptoCode && p.StoreId == storeId && (p.State == PendingTransactionState.Pending ||
p.State == PendingTransactionState.Signed))
.ToArrayAsync();
+ var changed = pendingTransactions.Aggregate(false, (current, pendingTransaction) => current | TryRefreshStoredProgress(pendingTransaction));
+
+ if (changed)
+ await ctx.SaveChangesAsync();
+ return pendingTransactions;
}
public async Task CancelPendingTransaction(PendingTransactionFullId id)
@@ -252,6 +256,7 @@ public class PendingTransactionService(
EventAggregator.Publish(new PendingTransactionEvent
{
Data = pt,
+ SignerUserId = null,
Type = PendingTransactionEvent.Cancelled
});
}
@@ -263,72 +268,153 @@ public class PendingTransactionService(
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))
+ if (pt.NoSignatureTransactionId != GetNoSignatureHash(transaction).ToString())
return;
pt.State = PendingTransactionState.Broadcast;
+ pt.TransactionId = transaction.GetHash().ToString();
await ctx.SaveChangesAsync();
EventAggregator.Publish(new PendingTransactionEvent
{
Data = pt,
+ SignerUserId = null,
Type = PendingTransactionEvent.Broadcast
});
}
- private PSBT? TryParsePendingPSBT(PendingTransaction pendingTransaction)
+ private static uint256 GetNoSignatureHash(Transaction transaction)
{
- 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;
- }
+ var noSignatureTransaction = transaction.Clone();
+ noSignatureTransaction.RemoveSignatures();
+ return noSignatureTransaction.GetHash();
+ }
+
+ public record PendingTransactionEvent
+ {
+ public const string Created = nameof(Created);
+ public const string SignatureCollected = nameof(SignatureCollected);
+ public const string Broadcast = nameof(Broadcast);
+ public const string Cancelled = nameof(Cancelled);
+
+ public PendingTransaction Data { get; set; } = null!;
+ public string? SignerUserId { get; set; }
+ public string Type { get; set; } = null!;
}
- private static bool HasSameTransactionIntent(Transaction pendingTransaction, Transaction broadcastTransaction)
+ private bool TryRefreshStoredProgress(PendingTransaction pendingTransaction)
{
- if (pendingTransaction.Version != broadcastTransaction.Version ||
- pendingTransaction.LockTime != broadcastTransaction.LockTime ||
- pendingTransaction.Inputs.Count != broadcastTransaction.Inputs.Count ||
- pendingTransaction.Outputs.Count != broadcastTransaction.Outputs.Count)
+ var blob = pendingTransaction.GetBlob();
+ if (blob?.PSBT is null)
+ return false;
+
+ var network = networkProvider.GetNetwork<BTCPayNetwork>(pendingTransaction.CryptoCode)?.NBitcoinNetwork;
+ if (network is null)
return false;
- for (var i = 0; i < pendingTransaction.Inputs.Count; i++)
+ var progress = GetSignatureProgress(BuildEffectivePsbt(blob, network));
+ if (blob.SignaturesNeeded == progress.SignaturesNeeded &&
+ blob.SignaturesTotal == progress.SignaturesTotal &&
+ blob.SignaturesCollected == progress.SignaturesCollected)
+ return false;
+
+ ApplyProgress(blob, progress);
+ pendingTransaction.SetBlob(blob);
+ return true;
+ }
+
+ private static PSBT BuildEffectivePsbt(PendingTransactionBlob blob, Network network, PSBT? additionalPsbt = null)
+ {
+ var effectivePsbt = PSBT.Parse(blob.PSBT, network);
+ foreach (var collectedSignature in blob.CollectedSignatures)
{
- var pendingInput = pendingTransaction.Inputs[i];
- var broadcastInput = broadcastTransaction.Inputs[i];
- if (pendingInput.PrevOut != broadcastInput.PrevOut ||
- pendingInput.Sequence != broadcastInput.Sequence)
- return false;
+ effectivePsbt.Combine(PSBT.Parse(collectedSignature.ReceivedPSBT, network));
}
- for (var i = 0; i < pendingTransaction.Outputs.Count; i++)
+ if (additionalPsbt is not null)
+ effectivePsbt.Combine(additionalPsbt);
+
+ return effectivePsbt;
+ }
+
+ private static PendingTransactionSignatureProgress GetSignatureProgress(PSBT psbt)
+ {
+ var inputs = new List<PendingTransactionInputProgress>(psbt.Inputs.Count);
+ foreach (var input in psbt.Inputs)
{
- var pendingOutput = pendingTransaction.Outputs[i];
- var broadcastOutput = broadcastTransaction.Outputs[i];
- if (pendingOutput.Value != broadcastOutput.Value ||
- pendingOutput.ScriptPubKey != broadcastOutput.ScriptPubKey)
- return false;
+ var finalized = input.FinalScriptSig is not null || input.FinalScriptWitness is not null;
+ var script = input.WitnessScript ?? input.RedeemScript;
+ var multisigParams = script is null
+ ? null
+ : PayToMultiSigTemplate.Instance.ExtractScriptPubKeyParameters(script);
+
+ if (multisigParams is null)
+ {
+ inputs.Add(new PendingTransactionInputProgress(false, 0, 0, input.PartialSigs.Count, finalized));
+ continue;
+ }
+
+ var validExpectedPartialSigCount = input.PartialSigs.Keys.Count(multisigParams.PubKeys.Contains);
+ var collected = finalized
+ ? multisigParams.SignatureCount
+ : Math.Min(validExpectedPartialSigCount, multisigParams.SignatureCount);
+ inputs.Add(new PendingTransactionInputProgress(
+ true,
+ multisigParams.SignatureCount,
+ multisigParams.PubKeys.Length,
+ collected,
+ finalized));
}
- return true;
+ var multisigInputs = inputs.Where(i => i.IsMultisig).ToArray();
+ if (multisigInputs.Length == 0)
+ return new PendingTransactionSignatureProgress(0, 0, 0, inputs);
+
+ return new PendingTransactionSignatureProgress(
+ multisigInputs[0].SignaturesNeeded,
+ multisigInputs[0].SignaturesTotal,
+ multisigInputs.Min(i => i.SignaturesCollected),
+ inputs);
}
- public record PendingTransactionEvent
+ private static bool HasMeaningfulDelta(PendingTransactionSignatureProgress before, PendingTransactionSignatureProgress after)
{
- public const string Created = nameof(Created);
- public const string SignatureCollected = nameof(SignatureCollected);
- public const string Broadcast = nameof(Broadcast);
- public const string Cancelled = nameof(Cancelled);
+ if (before.Inputs.Count != after.Inputs.Count)
+ return true;
- public PendingTransaction Data { get; set; } = null!;
- public string Type { get; set; } = null!;
+ for (var i = 0; i < before.Inputs.Count; i++)
+ {
+ var previous = before.Inputs[i];
+ var current = after.Inputs[i];
+ if (previous.IsMultisig != current.IsMultisig ||
+ previous.SignaturesNeeded != current.SignaturesNeeded ||
+ previous.SignaturesTotal != current.SignaturesTotal ||
+ previous.SignaturesCollected != current.SignaturesCollected ||
+ previous.IsFinalized != current.IsFinalized)
+ {
+ return true;
+ }
+ }
+
+ return false;
}
+ private static void ApplyProgress(PendingTransactionBlob blob, PendingTransactionSignatureProgress progress)
+ {
+ blob.SignaturesNeeded = progress.SignaturesNeeded;
+ blob.SignaturesTotal = progress.SignaturesTotal;
+ blob.SignaturesCollected = progress.SignaturesCollected;
+ }
+
+ private sealed record PendingTransactionSignatureProgress(
+ int SignaturesNeeded,
+ int SignaturesTotal,
+ int SignaturesCollected,
+ IReadOnlyList<PendingTransactionInputProgress> Inputs);
+
+ private sealed record PendingTransactionInputProgress(
+ bool IsMultisig,
+ int SignaturesNeeded,
+ int SignaturesTotal,
+ int SignaturesCollected,
+ bool IsFinalized);
+
}
diff --git a/BTCPayServer/JsonConverters/RequestBaseUrlConverter.cs b/BTCPayServer/JsonConverters/RequestBaseUrlConverter.cs
new file mode 100644
index 0000000..8772485
--- /dev/null
+++ b/BTCPayServer/JsonConverters/RequestBaseUrlConverter.cs
@@ -0,0 +1,27 @@
+using System;
+using BTCPayServer.Abstractions;
+using NBitcoin.JsonConverters;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.JsonConverters
+{
+ public class RequestBaseUrlConverter : JsonConverter<RequestBaseUrl>
+ {
+ public override RequestBaseUrl ReadJson(JsonReader reader, Type objectType, RequestBaseUrl existingValue, bool hasExistingValue, JsonSerializer serializer)
+ {
+ if (reader.TokenType == JsonToken.Null)
+ return null;
+ if (reader.TokenType != JsonToken.String)
+ throw new JsonObjectException("A request base URL should be a string", reader);
+ if (RequestBaseUrl.TryFromUrl((string)reader.Value, out var result))
+ return result;
+ throw new JsonObjectException("Invalid request base URL", reader);
+ }
+
+ public override void WriteJson(JsonWriter writer, RequestBaseUrl value, JsonSerializer serializer)
+ {
+ if (value != null)
+ writer.WriteValue(value.ToString());
+ }
+ }
+}
diff --git a/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs b/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs
index 280fb08..c36f4c1 100644
--- a/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs
+++ b/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs
@@ -51,5 +51,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Account key path")]
[Validation.KeyPathValidator]
public string AccountKeyPath { get; set; }
+ [JsonIgnore]
+ public string SignerEmail { get; set; }
}
}
diff --git a/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs b/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
index 765a003..8020931 100644
--- a/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
@@ -42,9 +42,10 @@ public class SearchResultItemProviders(
}
await FilterAuthorizedItems(ctx);
- Translate(ctx);
+ ctx.ItemResults.Sort((a, b) => a.Order.CompareTo(b.Order));
if (ctx.ItemResults.Count > maxResult)
ctx.ItemResults = ctx.ItemResults.Take(maxResult.Value).ToList();
+ Translate(ctx);
return new GlobalSearchViewModel()
{
Items = ctx.ItemResults,
@@ -83,6 +84,7 @@ public class SearchResultItemProviders(
result.Keywords[i] = stringLocalizer[result.Keywords[i]];
}
}
+ result.Order = o.Order;
ctx.ItemResults.Add(result);
}
}
diff --git a/BTCPayServer/Plugins/GlobalSearch/Views/GlobalSearchViewModel.cs b/BTCPayServer/Plugins/GlobalSearch/Views/GlobalSearchViewModel.cs
index 878f9d2..cabd3fe 100644
--- a/BTCPayServer/Plugins/GlobalSearch/Views/GlobalSearchViewModel.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/Views/GlobalSearchViewModel.cs
@@ -37,6 +37,7 @@ public class ResultItemViewModel
Category = other.Category;
Url = other.Url;
Keywords = other.Keywords?.ToArray();
+ Order = other.Order;
}
[JsonIgnore]
public string RequiredPolicy { get; set; }
@@ -45,5 +46,11 @@ public class ResultItemViewModel
public string Category { get; set; }
public string Url { get; set; }
public string[] Keywords { get; set; }
+
+ /// <summary>
+ /// Lower order values appear first (higher up), and higher order values appear later (lower down).
+ /// </summary>
+ [JsonIgnore]
+ public int Order { get; set; }
}
diff --git a/BTCPayServer/Plugins/Multisig/Components/MultisigDashboardSetupGuide.cs b/BTCPayServer/Plugins/Multisig/Components/MultisigDashboardSetupGuide.cs
new file mode 100644
index 0000000..a2e4892
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Components/MultisigDashboardSetupGuide.cs
@@ -0,0 +1,25 @@
+using System.Threading.Tasks;
+using BTCPayServer.Models.StoreViewModels;
+using BTCPayServer.Plugins.Multisig.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Plugins.Multisig.Components;
+
+public class MultisigDashboardSetupGuide(MultisigService multisigService, IAuthorizationService authorizationService) : ViewComponent
+{
+ public async Task<IViewComponentResult> InvokeAsync(StoreDashboardViewModel model)
+ {
+ var httpContext = ViewContext.HttpContext;
+ var store = httpContext.GetStoreData();
+ var userId = httpContext.User.GetIdOrNull();
+ if (string.IsNullOrEmpty(userId))
+ return Content(string.Empty);
+
+ var items = await multisigService.GetInProgressForStore(authorizationService, store, httpContext.User);
+ if (items.Count == 0)
+ return Content(string.Empty);
+
+ return View("/Plugins/Multisig/Views/Shared/Components/MultisigDashboardSetupGuide/Default.cshtml", items);
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Controllers/UIMultisigSetupController.cs b/BTCPayServer/Plugins/Multisig/Controllers/UIMultisigSetupController.cs
new file mode 100644
index 0000000..3ccaa44
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Controllers/UIMultisigSetupController.cs
@@ -0,0 +1,204 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Plugins.Multisig.Models;
+using BTCPayServer.Plugins.Multisig.Services;
+using BTCPayServer.Plugins.Wallets;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+using NBitcoin;
+using Npgsql;
+
+namespace BTCPayServer.Plugins.Multisig.Controllers;
+
+[Route("multisig-setups")]
+[Authorize(Policy = WalletPolicies.CanViewWallet, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Area(MultisigPlugin.Area)]
+public class UIMultisigSetupController(
+ MultisigService multisigService,
+ IStringLocalizer stringLocalizer,
+ BTCPayNetworkProvider networkProvider,
+ MultisigNotificationService multisigNotificationService,
+ IAuthorizationService authorizationService) : Controller
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ [HttpGet("{multisigSetupId}")]
+ public async Task<IActionResult> SetupMultisigStatus(string multisigSetupId)
+ {
+ var setup = await multisigService.GetPendingMultisigSetupContext(this.HttpContext.GetStoreData().Id, multisigSetupId);
+ var store = HttpContext.GetStoreDataOrNull();
+ if (setup is null || store is null)
+ return NotFound();
+
+ if (!setup.ReplacesExistingWallet && multisigService.HasOnChainWallet(store, setup.CryptoCode))
+ return NotFound();
+
+ var setupAccess = await authorizationService.GetSetupAccess(setup.StoreId, User, setup);
+ if (!setupAccess.CanViewStatus)
+ return Forbid();
+
+ var model = multisigService.CreateInProgressViewModel(setup.StoreId, User.GetId(), setup, setupAccess.CanManageWalletSettings);
+ return View(model);
+ }
+
+ private const string HardwareInputMethod = "hardware";
+ private const string ManualInputMethod = "manual";
+
+ private static bool IsSupportedCryptoCode(string? cryptoCode) =>
+ string.Equals(cryptoCode, "BTC", StringComparison.OrdinalIgnoreCase);
+
+ private static string? NormalizeInputMethod(string? method) =>
+ string.Equals(method, HardwareInputMethod, StringComparison.OrdinalIgnoreCase) ? HardwareInputMethod :
+ string.Equals(method, ManualInputMethod, StringComparison.OrdinalIgnoreCase) ? ManualInputMethod :
+ null;
+
+ private enum SignerKeyLoadStatus
+ {
+ Ok,
+ Invalid,
+ Forbidden
+ }
+
+ private sealed class SignerKeyLoadResult
+ {
+ public SignerKeyLoadStatus Status { get; init; }
+ public string? UserId { get; init; }
+ public MultisigSignerKeyViewModel? ViewModel { get; init; }
+ }
+
+ [HttpGet("{multisigSetupId}/signer-key")]
+ public async Task<IActionResult> SetupMultisigSubmitKey(string multisigSetupId, string? method = null)
+ {
+ var result = await LoadSignerKeyViewModel(this.HttpContext.GetStoreData().Id, multisigSetupId);
+ if (result.Status is SignerKeyLoadStatus.Forbidden)
+ return Forbid();
+ if (result.Status is not SignerKeyLoadStatus.Ok)
+ return NotFound();
+ var vm = result.ViewModel!;
+ if (!string.IsNullOrWhiteSpace(vm.DisplayAccountKey))
+ {
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Your signer key is submitted."].Value;
+ return RedirectToAction(nameof(UIMultisigSetupController.SetupMultisigStatus), "UIMultisigSetup",
+ new { area = MultisigPlugin.Area, multisigSetupId = vm.RequestId });
+ }
+
+ vm.InputMethod = NormalizeInputMethod(method);
+ return View(vm);
+ }
+
+ [HttpPost("{multisigSetupId}/signer-key")]
+ public async Task<IActionResult> SetupMultisigSubmitKey(string multisigSetupId, MultisigSignerKeyViewModel vm)
+ {
+ var result = await LoadSignerKeyViewModel(this.HttpContext.GetStoreData().Id, multisigSetupId);
+ if (result.Status is SignerKeyLoadStatus.Forbidden)
+ return Forbid();
+ if (result.Status is not SignerKeyLoadStatus.Ok)
+ return NotFound();
+ var current = result.ViewModel!;
+ var currentUserId = result.UserId!;
+
+ var network = networkProvider.GetNetwork(current.CryptoCode) as BTCPayNetwork;
+ if (network is null)
+ return NotFound();
+
+ current.DisplayAccountKey = vm.DisplayAccountKey?.Trim();
+ current.AccountKeyPath = vm.AccountKeyPath?.Trim();
+ current.InputMethod = NormalizeInputMethod(vm.InputMethod) ?? ManualInputMethod;
+
+ BitcoinExtPubKey? accountKey = null;
+ if (string.IsNullOrWhiteSpace(current.DisplayAccountKey))
+ ModelState.AddModelError(nameof(vm.DisplayAccountKey), StringLocalizer["Please provide your account key."].Value);
+ else
+ {
+ try
+ {
+ accountKey = new BitcoinExtPubKey(current.DisplayAccountKey, network.NBitcoinNetwork);
+ }
+ catch
+ {
+ ModelState.AddModelError(string.Empty, StringLocalizer["Invalid account key format."].Value);
+ }
+ }
+
+ if (!RootedKeyPath.TryParse(current.AccountKeyPath ?? "", out var accountKeyPath))
+ ModelState.AddModelError(nameof(vm.AccountKeyPath), StringLocalizer["Invalid account key path."].Value);
+ else
+ current.AccountKeyPath = accountKeyPath.ToString();
+
+ if (!ModelState.IsValid)
+ return View(current);
+
+ var storeId = this.HttpContext.GetStoreData().Id;
+ var normalizedAccountKey = accountKey!.ToString();
+ var pending = await multisigService.GetPendingMultisigSetupContext(storeId, current.RequestId);
+ if (pending is null)
+ return NotFound();
+
+ var participant = pending.Participants.Find(p => string.Equals(p.UserId, currentUserId, StringComparison.Ordinal));
+ if (participant is null)
+ return NotFound();
+
+ if (!string.IsNullOrWhiteSpace(participant.AccountKey))
+ {
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Your signer key is submitted."].Value;
+ return RedirectToAction(nameof(UIMultisigSetupController.SetupMultisigStatus), "UIMultisigSetup",
+ new { area = MultisigPlugin.Area, multisigSetupId = current.RequestId });
+ }
+
+ participant.AccountKey = normalizedAccountKey;
+ participant.AccountKeyPath = accountKeyPath;
+
+ try
+ {
+ await multisigService.UpdateParticipant(storeId, pending.RequestId, currentUserId, participant);
+ }
+ catch (PostgresException e) when (e is { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "UQ_multisig_setups_participants_multisig_setup_id_account_key" })
+ {
+ ModelState.AddModelError(string.Empty, StringLocalizer["This signer key is already used in this multisig request."].Value);
+ return View(current);
+ }
+
+ multisigNotificationService.PublishSignerKeySubmittedEvent(pending, participant);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Signer key submitted successfully."].Value;
+ return RedirectToAction(nameof(UIMultisigSetupController.SetupMultisigStatus), "UIMultisigSetup",
+ new { area = MultisigPlugin.Area, multisigSetupId = current.RequestId });
+ }
+
+ private async Task<SignerKeyLoadResult> LoadSignerKeyViewModel(string storeId, string multisigSetupId)
+ {
+ var currentUserId = User.GetId();
+ var pending = await multisigService.GetPendingMultisigSetupContext(storeId, multisigSetupId);
+ if (pending is null || !IsSupportedCryptoCode(pending.CryptoCode))
+ return new SignerKeyLoadResult { Status = SignerKeyLoadStatus.Invalid };
+
+ var access = await authorizationService.GetSetupAccess(storeId, User, pending);
+ if (!access.CanSignWalletTransactions || !access.IsParticipant)
+ return new SignerKeyLoadResult { Status = SignerKeyLoadStatus.Forbidden };
+
+ var participant = pending.Participants.Find(p => string.Equals(p.UserId, currentUserId, StringComparison.Ordinal));
+ if (participant is null)
+ return new SignerKeyLoadResult { Status = SignerKeyLoadStatus.Invalid };
+
+ return new SignerKeyLoadResult
+ {
+ Status = SignerKeyLoadStatus.Ok,
+ UserId = participant.UserId,
+ ViewModel = new MultisigSignerKeyViewModel
+ {
+ CryptoCode = pending.CryptoCode,
+ RequestId = pending.RequestId,
+ RequiredSigners = pending.RequiredSigners,
+ TotalSigners = pending.TotalSigners,
+ ScriptType = pending.ScriptType,
+ DisplayAccountKey = participant.AccountKey,
+ AccountKeyPath = participant.AccountKeyPath?.ToString()
+ }
+ };
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Controllers/UIMultisigWalletsController.cs b/BTCPayServer/Plugins/Multisig/Controllers/UIMultisigWalletsController.cs
new file mode 100644
index 0000000..d92f207
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Controllers/UIMultisigWalletsController.cs
@@ -0,0 +1,250 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Data;
+using BTCPayServer.Payments;
+using BTCPayServer.Plugins.Multisig.Models;
+using BTCPayServer.Plugins.Multisig.Services;
+using BTCPayServer.Plugins.Wallets;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Stores;
+using BTCPayServer.Services.Wallets;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+
+namespace BTCPayServer.Plugins.Multisig.Controllers;
+
+[Route("stores")]
+[Authorize(Policy = WalletPolicies.CanManageWalletSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Area(MultisigPlugin.Area)]
+public class UIMultisigWalletsController(
+ StoreRepository storeRepository,
+ ExplorerClientProvider explorerProvider,
+ BTCPayWalletProvider walletProvider,
+ MultisigService multisigService,
+ MultisigNotificationService multisigNotificationService,
+ PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
+ IStringLocalizer stringLocalizer) : Controller
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ private static bool IsSupportedCryptoCode(string? cryptoCode) =>
+ string.Equals(cryptoCode, "BTC", StringComparison.OrdinalIgnoreCase);
+
+ [HttpGet("{storeId}/onchain/{cryptoCode}/import/multisig")]
+ public async Task<IActionResult> SetupMultisig(string storeId, string cryptoCode)
+ {
+ var vm = new MultisigSetupViewModel();
+ vm.StoreId = storeId;
+ vm.CryptoCode = cryptoCode;
+
+ if (!IsSupportedCryptoCode(vm.CryptoCode))
+ return NotFound();
+
+ await multisigService.PopulateSetupViewModel(vm);
+ return View(vm);
+ }
+
+ [HttpPost("{storeId}/onchain/{cryptoCode}/import/multisig")]
+ public async Task<IActionResult> SetupMultisig(string storeId, string cryptoCode, MultisigSetupViewModel vm)
+ {
+ var store = HttpContext.GetStoreData();
+ vm.StoreId = store.Id;
+ vm.CryptoCode = cryptoCode;
+
+ if (!IsSupportedCryptoCode(vm.CryptoCode))
+ return NotFound();
+
+ var selectedIds = (vm.MultisigParticipantUserIds ?? Array.Empty<string>())
+ .Where(id => !string.IsNullOrWhiteSpace(id))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ vm.MultisigStoreUsers = await multisigService.GetStoreUsers(vm.StoreId, selectedIds);
+ var totalSigners = vm.MultisigTotalSigners ?? 0;
+ if (totalSigners is <= 0 or > 15)
+ {
+ ModelState.AddModelError(nameof(vm.MultisigTotalSigners), StringLocalizer["Total signers must be between 1 and 15."].Value);
+ return View(vm);
+ }
+
+ var scriptType = NormalizeMultisigScriptType(vm.MultisigScriptType);
+ if (scriptType is null)
+ {
+ ModelState.AddModelError(nameof(vm.MultisigScriptType), StringLocalizer["Invalid multisig script type."].Value);
+ return View(vm);
+ }
+ vm.MultisigScriptType = scriptType;
+ var usersById = vm.MultisigStoreUsers.ToDictionary(u => u.UserId, u => u, StringComparer.Ordinal);
+ if (!selectedIds.All(usersById.ContainsKey))
+ {
+ ModelState.AddModelError(nameof(vm.MultisigParticipantUserIds), StringLocalizer["One or more selected users are invalid."].Value);
+ return View(vm);
+ }
+
+ if (selectedIds.Length == 0)
+ {
+ ModelState.AddModelError(nameof(vm.MultisigParticipantUserIds), StringLocalizer["Select at least one signer."].Value);
+ return View(vm);
+ }
+
+ if (selectedIds.Length != totalSigners)
+ {
+ var missing = totalSigners - selectedIds.Length;
+ ModelState.AddModelError(nameof(vm.MultisigParticipantUserIds), StringLocalizer["Select exactly {0} signers for this request. Missing {1} signer(s).", totalSigners, Math.Max(0, missing)].Value);
+ return View(vm);
+ }
+
+ var required = vm.MultisigRequiredSigners ?? 0;
+ if (required <= 0 || required > totalSigners)
+ {
+ ModelState.AddModelError(nameof(vm.MultisigRequiredSigners), StringLocalizer["Required signatures must be between 1 and total signers (N)."].Value);
+ return View(vm);
+ }
+
+ var pending = new MultisigSetupData
+ {
+ RequestId = Guid.NewGuid().ToString("N"),
+ StoreId = vm.StoreId,
+ CryptoCode = vm.CryptoCode.ToUpperInvariant(),
+ RequestedByUserId = User.GetId(),
+ ScriptType = scriptType,
+ RequiredSigners = required,
+ TotalSigners = totalSigners,
+ ReplacesExistingWallet = multisigService.HasOnChainWallet(store, vm.CryptoCode),
+ ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
+ Participants = selectedIds
+ .Select(selectedId =>
+ new PendingMultisigSetupParticipantData
+ {
+ UserId = selectedId
+ })
+ .ToList(),
+ RequestBaseUrl = HttpContext.Request.GetRequestBaseUrl()
+ };
+
+ await multisigService.SavePendingMultisigSetup(pending);
+
+ await multisigNotificationService.EnsureDefaultEmailRules(vm.StoreId);
+
+ await multisigNotificationService.PublishSignerKeyRequestedEvents(pending);
+
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Multisig signer requests were created."].Value;
+ return RedirectToMultisigSetup(pending.RequestId);
+ }
+
+ [HttpPost("/multisig-setups/{multisigSetupId}/finalize")]
+ public async Task<IActionResult> FinalizeMultisigSetup(string multisigSetupId, MultisigSetupViewModel vm)
+ {
+ var store = HttpContext.GetStoreData();
+ var pending = await multisigService.GetPendingMultisigSetupContext(store.Id, multisigSetupId);
+ if (pending is null)
+ return NotFound();
+
+ vm.StoreId = pending.StoreId;
+ vm.CryptoCode = pending.CryptoCode;
+ vm.MultisigRequestId = pending.RequestId;
+
+ var network = explorerProvider.GetNetwork(vm.CryptoCode);
+ if (network is null)
+ return NotFound();
+
+ return vm.Confirmation
+ ? await ConfirmMultisigSetup(vm, store, network)
+ : await FinalizeMultisigRequest(pending, network);
+ }
+
+ private async Task<IActionResult> ConfirmMultisigSetup(MultisigSetupViewModel vm, StoreData store, BTCPayNetwork network)
+ {
+ if (string.IsNullOrEmpty(vm.MultisigRequestId))
+ {
+ ModelState.AddModelError(nameof(vm.MultisigRequestId), StringLocalizer["The multisig request was not found or has expired."].Value);
+ return View("MultisigConfirm", vm);
+ }
+
+ var pending = await multisigService.GetPendingMultisigSetupContext(vm.StoreId, vm.MultisigRequestId);
+ if (pending is null)
+ {
+ ModelState.AddModelError(nameof(vm.MultisigRequestId), StringLocalizer["The multisig request was not found or has expired."].Value);
+ return View("MultisigConfirm", vm);
+ }
+
+ var strategy = pending.GetDiredivationSchemeSettings(network);
+ var wallet = walletProvider.GetWallet(network);
+ if (wallet is null)
+ {
+ ModelState.AddModelError(nameof(vm.MultisigRequestId), StringLocalizer["Wallet is not available."].Value);
+ return View("MultisigConfirm", vm);
+ }
+
+ await wallet.TrackAsync(strategy.AccountDerivation);
+ var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
+ store.SetPaymentMethodConfig(paymentMethodHandlerDictionary[paymentMethodId], strategy);
+ var storeBlob = store.GetStoreBlob();
+ storeBlob.SetExcluded(paymentMethodId, false);
+ store.SetStoreBlob(storeBlob);
+ await storeRepository.UpdateStore(store);
+
+ var finalizedPendingSetting = await multisigService.GetPendingMultisigSetupContext(vm.StoreId, vm.MultisigRequestId);
+ if (finalizedPendingSetting is not null)
+ {
+ await multisigService.DeletePendingMultisigSetup(vm.StoreId, vm.MultisigRequestId);
+ multisigNotificationService.PublishWalletCreatedEvent(finalizedPendingSetting);
+ }
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Wallet settings for {0} have been updated.", network.CryptoCode].Value;
+ return RedirectToAction(nameof(BTCPayServer.Controllers.UIStoreOnChainWalletsController.WalletSettings), "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, storeId = vm.StoreId, cryptoCode = vm.CryptoCode });
+ }
+
+ private async Task<IActionResult> FinalizeMultisigRequest(MultisigSetupData pending, BTCPayNetwork network)
+ {
+ if (pending.Participants.Count != pending.TotalSigners || pending.Participants.Any(p => string.IsNullOrWhiteSpace(p.AccountKey)))
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Complete signer collection before creating the multisig wallet."].Value;
+ return RedirectToMultisigSetup(pending.RequestId);
+ }
+
+ var eligibleParticipants = await multisigService.GetStoreUsers(pending.StoreId, pending.Participants.Select(p => p.UserId));
+ var eligibleParticipantIds = eligibleParticipants.Select(p => p.UserId).ToHashSet(StringComparer.Ordinal);
+ if (pending.Participants.Any(p => !eligibleParticipantIds.Contains(p.UserId)))
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["One or more signers no longer have wallet signing permission."].Value;
+ return RedirectToMultisigSetup(pending.RequestId);
+ }
+
+ var strategy = pending.GetDiredivationSchemeSettings(network);
+ var vm = new MultisigSetupViewModel
+ {
+ MultisigRequestId = pending.RequestId,
+ Confirmation = true
+ };
+ vm.AddressSamples = new List<(string KeyPath, string Address)>();
+ var result = BTCPayServer.Controllers.Greenfield.GreenfieldStoreOnChainPaymentMethodsController.GetPreviewResultData(0, 10, network, strategy.AccountDerivation);
+ foreach (var sample in result.Addresses)
+ {
+ vm.AddressSamples.Add((sample.KeyPath, sample.Address));
+ }
+ return View("MultisigConfirm", vm);
+ }
+
+ IActionResult RedirectToMultisigSetup(string multisigSetupId)
+ => RedirectToAction(nameof(UIMultisigSetupController.SetupMultisigStatus), "UIMultisigSetup", new { multisigSetupId });
+
+ private static string? NormalizeMultisigScriptType(string? scriptType)
+ {
+ return scriptType?.Trim().ToLowerInvariant() switch
+ {
+ null or "" => "p2wsh",
+ "p2wsh" => "p2wsh",
+ "p2sh-p2wsh" => "p2sh-p2wsh",
+ "p2sh" => "p2sh",
+ _ => null
+ };
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Events/MultisigEvents.cs b/BTCPayServer/Plugins/Multisig/Events/MultisigEvents.cs
new file mode 100644
index 0000000..1e1ab7d
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Events/MultisigEvents.cs
@@ -0,0 +1,29 @@
+#nullable enable
+
+using System.Collections.Generic;
+using BTCPayServer.Plugins.Multisig.Models;
+
+namespace BTCPayServer.Plugins.Multisig.Events;
+
+public record MultisigSignerInfo(string Email, string Name);
+public record MultisigSignerKeyRequestedEvent(
+ MultisigSetupData Setup,
+ MultisigSignerInfo Signer)
+{
+ public override string ToString() => nameof(MultisigSignerKeyRequestedEvent);
+}
+
+public record MultisigSignerKeySubmittedEvent(
+ MultisigSetupData Setup,
+ MultisigSignerInfo Signer)
+{
+ public override string ToString() => nameof(MultisigSignerKeySubmittedEvent);
+}
+
+public record MultisigWalletCreatedEvent(
+ MultisigSetupData Setup,
+ string WalletLink,
+ IReadOnlyCollection<string> ParticipantUserIds)
+{
+ public override string ToString() => nameof(MultisigWalletCreatedEvent);
+}
diff --git a/BTCPayServer/Plugins/Multisig/Extensions.Multisig.cs b/BTCPayServer/Plugins/Multisig/Extensions.Multisig.cs
new file mode 100644
index 0000000..478a696
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Extensions.Multisig.cs
@@ -0,0 +1,21 @@
+#nullable enable
+using System.Security.Claims;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Multisig.Models;
+using BTCPayServer.Plugins.Wallets;
+using Microsoft.AspNetCore.Authorization;
+
+namespace BTCPayServer.Plugins.Multisig;
+
+public static class Extensions
+{
+ public static async Task<MultisigSetupAccess> GetSetupAccess(this IAuthorizationService authorizationService, string storeId, ClaimsPrincipal user, MultisigSetupData? pending)
+ => new(
+ (await authorizationService.AuthorizeAsync(user, storeId, WalletPolicies.CanManageWalletSettings)).Succeeded,
+ (await authorizationService.AuthorizeAsync(user, storeId, WalletPolicies.CanSignWalletTransactions)).Succeeded,
+ pending?.IsPendingParticipant(user.GetId()) is true);
+}
+public readonly record struct MultisigSetupAccess(bool CanManageWalletSettings, bool CanSignWalletTransactions, bool IsParticipant)
+{
+ public bool CanViewStatus => CanManageWalletSettings || (CanSignWalletTransactions && IsParticipant);
+}
diff --git a/BTCPayServer/Plugins/Multisig/HostedServices/MultisigEmailTriggerHostedService.cs b/BTCPayServer/Plugins/Multisig/HostedServices/MultisigEmailTriggerHostedService.cs
new file mode 100644
index 0000000..6e7ac06
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/HostedServices/MultisigEmailTriggerHostedService.cs
@@ -0,0 +1,300 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
+using BTCPayServer.Client;
+using BTCPayServer.Controllers;
+using BTCPayServer.Data;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Plugins.Emails.HostedServices;
+using BTCPayServer.Plugins.Multisig.Events;
+using BTCPayServer.Plugins.Multisig.Models;
+using BTCPayServer.Plugins.Wallets;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Multisig.HostedServices;
+
+public class MultisigEmailTriggerHostedService(
+ EventAggregator eventAggregator,
+ IServiceScopeFactory serviceScopeFactory,
+ LinkGenerator linkGenerator,
+ ILogger<MultisigEmailTriggerHostedService> logger)
+ : EventHostedServiceBase(eventAggregator, logger)
+{
+ protected override void SubscribeToEvents()
+ {
+ Subscribe<MultisigSignerKeyRequestedEvent>();
+ Subscribe<MultisigSignerKeySubmittedEvent>();
+ Subscribe<MultisigWalletCreatedEvent>();
+ Subscribe<PendingTransactionService.PendingTransactionEvent>();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ switch (evt)
+ {
+ case MultisigSignerKeyRequestedEvent signerKeyRequested:
+ await PublishSignerKeyRequested(signerKeyRequested, cancellationToken);
+ break;
+ case MultisigSignerKeySubmittedEvent signerKeySubmitted:
+ await PublishSignerKeySubmitted(signerKeySubmitted, cancellationToken);
+ break;
+ case MultisigWalletCreatedEvent walletCreated:
+ await PublishWalletCreated(walletCreated, cancellationToken);
+ break;
+ case PendingTransactionService.PendingTransactionEvent pendingEvent:
+ await PublishPendingTransactionEvent(pendingEvent, cancellationToken);
+ break;
+ }
+ }
+
+ private async Task PublishSignerKeyRequested(MultisigSignerKeyRequestedEvent evt, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(evt.Signer.Email))
+ return;
+ var model = new JObject();
+ AddSetup(model, evt.Setup);
+ AddSigner(model, evt.Signer);
+ await PublishTrigger(evt.Setup.StoreId, MultisigEmailTriggers.SignerKeyRequested, model, cancellationToken);
+ }
+
+ private void AddSigner(JObject obj, MultisigSignerInfo info)
+ {
+ obj["Signer"] ??= new JObject();
+ var signer = (JObject)obj["Signer"]!;
+ signer["Email"] = info.Email;
+ signer["Name"] = info.Name ?? info.Email ?? string.Empty;
+ signer["MailboxAddress"] = GetMailboxAddress(info);
+ }
+ private void AddRecipient(JObject obj, RecipientInfo recipient)
+ {
+ obj["Recipient"] ??= new JObject();
+ var signer = (JObject)obj["Recipient"]!;
+ signer["Email"] = recipient.Email;
+ signer["Name"] = recipient.Name ?? recipient.Email ?? string.Empty;
+ signer["MailboxAddress"] = GetMailboxAddress(new(recipient.Email ?? "", recipient.Name ?? ""));
+ }
+
+ private string? GetMailboxAddress(MultisigSignerInfo info)
+ {
+ var name = info.Name;
+ var email = info.Email;
+ if (name == email)
+ return email;
+ try
+ {
+ return new MimeKit.MailboxAddress(name ?? "", email).ToString();
+ }
+ catch // Invalid encoding or format; treat as no valid mailbox
+ {
+ }
+ return email;
+ }
+
+ private async Task PublishSignerKeySubmitted(MultisigSignerKeySubmittedEvent evt, CancellationToken cancellationToken)
+ {
+
+ await using var scope = serviceScopeFactory.CreateAsyncScope();
+ var storeRepository = scope.ServiceProvider.GetRequiredService<StoreRepository>();
+ var permissionService = scope.ServiceProvider.GetRequiredService<PermissionService>();
+
+ var recipients = await GetWalletScopedRecipients(
+ storeRepository,
+ permissionService,
+ evt.Setup.StoreId,
+ allPolicies: new[] { WalletPolicies.CanManageWalletSettings });
+
+ foreach (var recipient in recipients)
+ {
+ var model = new JObject();
+ AddSetup(model, evt.Setup);
+ AddRecipient(model, recipient);
+ AddSigner(model, evt.Signer);
+ await PublishTrigger(evt.Setup.StoreId, MultisigEmailTriggers.SignerKeySubmitted, model, cancellationToken);
+ }
+ }
+
+ private void AddSetup(JObject model, MultisigSetupData setup)
+ {
+ model["CryptoCode"] = setup.CryptoCode;
+ model["Setup"] ??= new JObject();
+ var req = (JObject)model["Setup"]!;
+ req["Id"] = setup.RequestId;
+ req["Link"] = linkGenerator.MultisigSetupSessionLink(setup.RequestId, setup.RequestBaseUrl);
+ }
+
+ private async Task PublishWalletCreated(MultisigWalletCreatedEvent evt, CancellationToken cancellationToken)
+ {
+ await using var scope = serviceScopeFactory.CreateAsyncScope();
+ var storeRepository = scope.ServiceProvider.GetRequiredService<StoreRepository>();
+ var permissionService = scope.ServiceProvider.GetRequiredService<PermissionService>();
+ var recipients = await GetWalletScopedRecipients(
+ storeRepository,
+ permissionService,
+ evt.Setup.StoreId,
+ includeUserIds: evt.ParticipantUserIds);
+
+ foreach (var recipient in recipients)
+ {
+ var model = new JObject
+ {
+ ["Wallet"] = new JObject
+ {
+ ["Link"] = evt.WalletLink
+ }
+ };
+ AddSetup(model, evt.Setup);
+ AddRecipient(model, recipient);
+ await PublishTrigger(evt.Setup.StoreId, MultisigEmailTriggers.WalletCreated, model, cancellationToken);
+ }
+ }
+
+ private async Task PublishPendingTransactionEvent(PendingTransactionService.PendingTransactionEvent pendingEvent, CancellationToken cancellationToken)
+ {
+ if (pendingEvent.Type is not PendingTransactionService.PendingTransactionEvent.Created and
+ not PendingTransactionService.PendingTransactionEvent.SignatureCollected)
+ return;
+
+ await using var scope = serviceScopeFactory.CreateAsyncScope();
+ var storeRepository = scope.ServiceProvider.GetRequiredService<StoreRepository>();
+ var permissionService = scope.ServiceProvider.GetRequiredService<PermissionService>();
+ var handlers = scope.ServiceProvider.GetRequiredService<PaymentMethodHandlerDictionary>();
+
+ var pendingTransaction = pendingEvent.Data;
+ var store = await storeRepository.FindStore(pendingTransaction.StoreId);
+ if (store is null)
+ return;
+
+ var walletId = new WalletId(pendingTransaction.StoreId, pendingTransaction.CryptoCode);
+ var derivation = store.GetDerivationSchemeSettings(handlers, walletId.CryptoCode);
+ if (derivation is null || !IsServerMultisig(derivation))
+ return;
+
+ var recipients = pendingEvent.Type is PendingTransactionService.PendingTransactionEvent.Created
+ ? await GetWalletScopedRecipients(
+ storeRepository,
+ permissionService,
+ walletId.StoreId,
+ allPolicies: new[] { WalletPolicies.CanViewWallet },
+ anyPolicies: new[] { WalletPolicies.CanSignWalletTransactions, WalletPolicies.CanManageWalletTransactions })
+ : await GetWalletScopedRecipients(
+ storeRepository,
+ permissionService,
+ walletId.StoreId,
+ allPolicies: new[] { WalletPolicies.CanViewWallet },
+ anyPolicies: new[]
+ {
+ WalletPolicies.CanSignWalletTransactions,
+ WalletPolicies.CanCreateWalletTransactions,
+ WalletPolicies.CanManageWalletTransactions
+ },
+ excludeUserId: pendingEvent.SignerUserId);
+ if (recipients.Length == 0)
+ return;
+
+ var blob = pendingTransaction.GetBlob();
+ var signaturesCollected = blob?.SignaturesCollected ?? 0;
+ var signaturesNeeded = blob?.SignaturesNeeded ?? blob?.SignaturesTotal ?? 0;
+ var pendingTransactionLink = GetPendingTransactionLink(walletId, pendingTransaction);
+ var trigger = pendingEvent.Type is PendingTransactionService.PendingTransactionEvent.Created
+ ? MultisigEmailTriggers.PendingTransactionCreated
+ : MultisigEmailTriggers.PendingTransactionSignatureCollected;
+
+ foreach (var recipient in recipients)
+ {
+ var model = new JObject
+ {
+ ["CryptoCode"] = walletId.CryptoCode,
+ ["PendingTransaction"] = new JObject
+ {
+ ["Link"] = pendingTransactionLink,
+ ["SignaturesCollected"] = signaturesCollected,
+ ["SignaturesNeeded"] = signaturesNeeded,
+ ["SignaturesMissing"] = Math.Max(0, signaturesNeeded - signaturesCollected)
+ }
+ };
+ AddRecipient(model, recipient);
+ await PublishTrigger(walletId.StoreId, trigger, model, cancellationToken);
+ }
+ }
+
+ private Task PublishTrigger(string storeId, string trigger, JObject model, CancellationToken cancellationToken)
+ {
+ EventAggregator.Publish(new TriggerEvent(storeId, trigger, model, null));
+ return Task.CompletedTask;
+ }
+
+ private static bool IsServerMultisig(DerivationSchemeSettings derivation)
+ {
+ return derivation is { AccountKeySettings.Length: > 1, IsMultiSigOnServer: true };
+ }
+
+ private string? GetPendingTransactionLink(WalletId walletId, PendingTransaction pendingTransaction)
+ {
+ var blob = pendingTransaction.GetBlob();
+ if (blob?.RequestBaseUrl is not null && RequestBaseUrl.TryFromUrl(blob.RequestBaseUrl, out var requestBaseUrl))
+ return linkGenerator.WalletPendingTransactionLink(walletId, pendingTransaction.Id, requestBaseUrl);
+ return null;
+ }
+
+ record RecipientInfo(string Email, string Name);
+ private static async Task<RecipientInfo[]> GetWalletScopedRecipients(
+ StoreRepository storeRepository,
+ PermissionService permissionService,
+ string storeId,
+ IEnumerable<string>? allPolicies = null,
+ IEnumerable<string>? anyPolicies = null,
+ string? excludeUserId = null,
+ IEnumerable<string>? includeUserIds = null)
+ {
+ if (string.IsNullOrWhiteSpace(storeId))
+ return Array.Empty<RecipientInfo>();
+
+ var requiredAll = (allPolicies ?? Array.Empty<string>())
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ var requiredAny = (anyPolicies ?? Array.Empty<string>())
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ var included = includeUserIds?
+ .Where(id => !string.IsNullOrWhiteSpace(id))
+ .ToHashSet(StringComparer.Ordinal);
+ bool HasPolicy(PermissionSet permissionSet, string policy)
+ {
+ return Permission.TryCreatePermission(policy, storeId, out var permission) &&
+ permissionSet.HasPermission(permission, permissionService);
+ }
+
+ var storeUsers = await storeRepository.GetStoreUsers(storeId);
+ return storeUsers
+ .Where(u => !string.IsNullOrWhiteSpace(u.Id) &&
+ !string.IsNullOrWhiteSpace(u.Email))
+ .Where(u => included is null || included.Contains(u.Id))
+ .Where(u => excludeUserId is null || !string.Equals(u.Id, excludeUserId, StringComparison.Ordinal))
+ .Where(u =>
+ {
+ var permissionSet = u.StoreRole?.ToPermissionSet(storeId) ?? new PermissionSet();
+ if (!HasPolicy(permissionSet, WalletPolicies.CanViewWallet))
+ return false;
+ if (requiredAll.Any(policy => !HasPolicy(permissionSet, policy)))
+ return false;
+ return requiredAny.Length <= 0 || requiredAny.Any(policy => HasPolicy(permissionSet, policy));
+ })
+ .Select(u => new RecipientInfo(u.Email, u.UserBlob.Name))
+ .DistinctBy(u => u.Email, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Models/MultisigInProgressViewModel.cs b/BTCPayServer/Plugins/Multisig/Models/MultisigInProgressViewModel.cs
new file mode 100644
index 0000000..0d7950c
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Models/MultisigInProgressViewModel.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace BTCPayServer.Plugins.Multisig.Models;
+
+public class MultisigInProgressViewModel
+{
+ public string StoreId { get; set; }
+ public string CryptoCode { get; set; }
+ public string RequestId { get; set; }
+ public int RequiredSigners { get; set; }
+ public int TotalSigners { get; set; }
+ public int SubmittedSigners { get; set; }
+ public bool DidParticipate { get; set; }
+ public bool YourKeySubmitted { get; set; }
+ public DateTimeOffset ExpiresAt { get; set; }
+ public string SessionUrl { get; set; }
+ public bool CanCreateWallet { get; set; }
+ public MultisigInProgressParticipantViewModel[] Participants { get; set; } = [];
+
+ public int MissingSigners => Math.Max(0, TotalSigners - SubmittedSigners);
+ public bool CanSubmitSignerKey => DidParticipate && !YourKeySubmitted;
+ public bool ReadyToCreateWallet => MissingSigners == 0;
+}
+
+public class MultisigInProgressParticipantViewModel
+{
+ public string Email { get; set; }
+ public string Name { get; set; }
+ public bool Submitted { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Models/MultisigSetupData.cs b/BTCPayServer/Plugins/Multisig/Models/MultisigSetupData.cs
new file mode 100644
index 0000000..de4386e
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Models/MultisigSetupData.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using BTCPayServer.Abstractions;
+using BTCPayServer.JsonConverters;
+using NBitcoin;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Plugins.Multisig.Models;
+
+public class MultisigSetupData
+{
+ public string RequestId { get; set; }
+ public string StoreId { get; set; }
+ public string CryptoCode { get; set; }
+ public string RequestedByUserId { get; set; }
+ public string ScriptType { get; set; }
+ public int RequiredSigners { get; set; }
+ public int TotalSigners { get; set; }
+ public bool ReplacesExistingWallet { get; set; }
+ public DateTimeOffset ExpiresAt { get; set; }
+
+ [JsonIgnore]
+ public List<PendingMultisigSetupParticipantData> Participants { get; set; } = new();
+ [JsonConverter(typeof(RequestBaseUrlConverter))]
+ public RequestBaseUrl RequestBaseUrl { get; set; }
+ public bool IsPendingParticipant(string userId)
+ => !string.IsNullOrEmpty(userId) &&
+ Participants.Any(p => string.Equals(p.UserId, userId, StringComparison.Ordinal));
+
+ public DerivationSchemeSettings GetDiredivationSchemeSettings(BTCPayNetwork network)
+ {
+ var suffix = ScriptType.ToLowerInvariant() switch
+ {
+ "p2wsh" => string.Empty,
+ "p2sh-p2wsh" => "-[p2sh]",
+ "p2sh" => "-[legacy]",
+ _ => string.Empty
+ };
+
+ var multisigDerivation = $"{RequiredSigners}-of-{string.Join("-", Participants.Select(k => k.AccountKey.ToString()))}{suffix}";
+ var strategy = new DerivationSchemeSettings(new DerivationSchemeParser(network).Parse(multisigDerivation), network);
+ strategy.Source = "ManualDerivationScheme";
+ strategy.IsMultiSigOnServer = true;
+ strategy.DefaultIncludeNonWitnessUtxo = true;
+ for (int i = 0; i < Participants.Count; i++)
+ {
+ strategy.AccountKeySettings[i].SignerUserId = Participants[i].UserId;
+ strategy.AccountKeySettings[i].AccountKeyPath = Participants[i].AccountKeyPath.KeyPath;
+ strategy.AccountKeySettings[i].RootFingerprint = Participants[i].AccountKeyPath.MasterFingerprint;
+ }
+ return strategy;
+ }
+}
+
+public class PendingMultisigSetupParticipantData
+{
+ public string UserId { get; set; }
+ public string Email { get; set; }
+ public string Name { get; set; }
+ public string AccountKey { get; set; }
+ public RootedKeyPath AccountKeyPath { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Models/MultisigSetupViewModel.cs b/BTCPayServer/Plugins/Multisig/Models/MultisigSetupViewModel.cs
new file mode 100644
index 0000000..2f2e316
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Models/MultisigSetupViewModel.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using BTCPayServer.Models.StoreViewModels;
+
+namespace BTCPayServer.Plugins.Multisig.Models;
+
+public class MultisigSetupViewModel : DerivationSchemeViewModel
+{
+ public string StoreId { get; set; }
+ public int? MultisigRequiredSigners { get; set; }
+ public int? MultisigTotalSigners { get; set; }
+ public string MultisigScriptType { get; set; }
+ public string[] MultisigParticipantUserIds { get; set; }
+ public string MultisigRequestId { get; set; }
+ public List<MultisigStoreUserItem> MultisigStoreUsers { get; set; } = new();
+}
+
+public class MultisigStoreUserItem
+{
+ public string UserId { get; set; }
+ public string Email { get; set; }
+ public string Name { get; set; }
+ public bool Selected { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Models/MultisigSignerKeyViewModel.cs b/BTCPayServer/Plugins/Multisig/Models/MultisigSignerKeyViewModel.cs
new file mode 100644
index 0000000..0550d19
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Models/MultisigSignerKeyViewModel.cs
@@ -0,0 +1,13 @@
+namespace BTCPayServer.Plugins.Multisig.Models;
+
+public class MultisigSignerKeyViewModel
+{
+ public string CryptoCode { get; set; }
+ public string RequestId { get; set; }
+ public int RequiredSigners { get; set; }
+ public int TotalSigners { get; set; }
+ public string ScriptType { get; set; }
+ public string DisplayAccountKey { get; set; }
+ public string AccountKeyPath { get; set; }
+ public string InputMethod { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Multisig/MultisigEmailTriggers.cs b/BTCPayServer/Plugins/Multisig/MultisigEmailTriggers.cs
new file mode 100644
index 0000000..0a596a2
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/MultisigEmailTriggers.cs
@@ -0,0 +1,147 @@
+using System.Collections.Generic;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Plugins.Emails.Views;
+
+namespace BTCPayServer.Plugins.Multisig;
+
+public static class MultisigEmailTriggers
+{
+ public const string SignerKeyRequested = "MultisigSignerKeyRequested";
+ public const string SignerKeySubmitted = "MultisigSignerKeySubmitted";
+ public const string WalletCreated = "MultisigWalletCreated";
+ public const string PendingTransactionCreated = "MultisigPendingTransactionCreated";
+ public const string PendingTransactionSignatureCollected = "MultisigPendingTransactionSignatureCollected";
+
+ public static readonly string[] DefaultRuleTriggers =
+ [
+ SignerKeyRequested,
+ SignerKeySubmitted,
+ WalletCreated,
+ PendingTransactionCreated,
+ PendingTransactionSignatureCollected
+ ];
+
+ static List<EmailTriggerViewModel.PlaceHolder> AddSetupPlaceholders(this List<EmailTriggerViewModel.PlaceHolder> placeholders)
+ {
+ placeholders.Add(new("{CryptoCode}", "The wallet crypto code"));
+ placeholders.Add(new("{Setup.Id}", "The multisig setup request id"));
+ placeholders.Add(new("{Setup.Link}", "The multisig setup session link"));
+ return placeholders;
+ }
+ static List<EmailTriggerViewModel.PlaceHolder> AddSignersPlaceHolders(this List<EmailTriggerViewModel.PlaceHolder> placeholders)
+ {
+ placeholders.Add(new("{Signer.Email}", "The signer email address"));
+ placeholders.Add(new("{Signer.Name}", "The signer name"));
+ placeholders.Add(new("{Signer.MailboxAddress}", "The formatted mailbox address to use when sending an email to the signer. (eg. \"John Doe\" <john.doe@example.com>)"));
+ return placeholders;
+ }
+ static List<EmailTriggerViewModel.PlaceHolder> AddRecipientPlaceholders(this List<EmailTriggerViewModel.PlaceHolder> placeholders)
+ {
+ placeholders.Add(new("{Recipient.Email}", "The recipient email address"));
+ placeholders.Add(new("{Recipient.Name}", "The recipient name"));
+ placeholders.Add(new("{Recipient.MailboxAddress}", "The formatted mailbox address to use when sending an email to the recipient. (eg. \"John Doe\" <john.doe@example.com>)"));
+ return placeholders;
+ }
+
+ public static IEnumerable<EmailTriggerViewModel> GetViewModels()
+ {
+ var signerPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
+ .AddSetupPlaceholders()
+ .AddSignersPlaceHolders();
+
+ yield return new EmailTriggerViewModel
+ {
+ Trigger = SignerKeyRequested,
+ Description = "Multisig - Signer Key Requested",
+ DefaultEmail = new EmailTriggerViewModel.Default
+ {
+ To = ["{Signer.MailboxAddress}"],
+ Subject = "Multisig signer request for {CryptoCode}",
+ Body = EmailsPlugin.CreateEmail(
+ "You have been invited to participate in a multisig wallet setup for {CryptoCode}. Submit your signer key to continue the setup.",
+ "Submit signer key",
+ "{Setup.Link}")
+ },
+ PlaceHolders = signerPlaceholders
+ };
+
+ yield return new EmailTriggerViewModel
+ {
+ Trigger = SignerKeySubmitted,
+ Description = "Multisig - Signer Key Submitted",
+ DefaultEmail = new EmailTriggerViewModel.Default
+ {
+ To = ["{Recipient.MailboxAddress}"],
+ Subject = "Multisig signer submitted ({CryptoCode})",
+ Body = EmailsPlugin.CreateEmail(
+ "<b>{Signer.Name}</b> submitted a signer key for the multisig wallet setup.",
+ "Open request",
+ "{Setup.Link}")
+ },
+ PlaceHolders =
+ new List<EmailTriggerViewModel.PlaceHolder>()
+ .AddSetupPlaceholders()
+ .AddSignersPlaceHolders()
+ .AddRecipientPlaceholders()
+ };
+
+ yield return new EmailTriggerViewModel
+ {
+ Trigger = WalletCreated,
+ Description = "Multisig - Wallet Created",
+ DefaultEmail = new EmailTriggerViewModel.Default
+ {
+ To = ["{Recipient.MailboxAddress}"],
+ Subject = "Multisig wallet created for {CryptoCode}",
+ Body = EmailsPlugin.CreateEmail(
+ "The multisig wallet setup for {CryptoCode} is complete.",
+ "Open wallet",
+ "{Wallet.Link}")
+ },
+ PlaceHolders = new List<EmailTriggerViewModel.PlaceHolder>()
+ .AddSetupPlaceholders()
+ .AddRecipientPlaceholders()
+ };
+
+ var pendingPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>
+ {
+ new("{CryptoCode}", "The wallet crypto code"),
+ new("{PendingTransaction.Link}", "The pending transaction link"),
+ new("{PendingTransaction.SignaturesCollected}", "The number of collected signatures"),
+ new("{PendingTransaction.SignaturesNeeded}", "The number of required signatures"),
+ new("{PendingTransaction.SignaturesMissing}", "The number of missing signatures")
+ }.AddRecipientPlaceholders();
+
+ yield return new EmailTriggerViewModel
+ {
+ Trigger = PendingTransactionCreated,
+ Description = "Multisig - Pending Transaction Created",
+ DefaultEmail = new EmailTriggerViewModel.Default
+ {
+ To = ["{Recipient.MailboxAddress}"],
+ Subject = "Pending multisig transaction requires signatures ({CryptoCode})",
+ Body = EmailsPlugin.CreateEmail(
+ "A pending multisig transaction was created and needs signatures.",
+ "Open pending transaction",
+ "{PendingTransaction.Link}")
+ },
+ PlaceHolders = pendingPlaceholders
+ };
+
+ yield return new EmailTriggerViewModel
+ {
+ Trigger = PendingTransactionSignatureCollected,
+ Description = "Multisig - Pending Transaction Signature Collected",
+ DefaultEmail = new EmailTriggerViewModel.Default
+ {
+ To = ["{Recipient.MailboxAddress}"],
+ Subject = "Multisig signature collected ({CryptoCode})",
+ Body = EmailsPlugin.CreateEmail(
+ "A signer submitted a signature for the pending multisig transaction.<br/>Progress: <b>{PendingTransaction.SignaturesCollected}/{PendingTransaction.SignaturesNeeded}</b> collected, {PendingTransaction.SignaturesMissing} missing.",
+ "Open pending transaction",
+ "{PendingTransaction.Link}")
+ },
+ PlaceHolders = pendingPlaceholders
+ };
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/MultisigPlugin.cs b/BTCPayServer/Plugins/Multisig/MultisigPlugin.cs
new file mode 100644
index 0000000..62f1bd7
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/MultisigPlugin.cs
@@ -0,0 +1,37 @@
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Security;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.Multisig;
+
+public class MultisigPlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "Multisig";
+ public override string Identifier => "BTCPayServer.Plugins.Multisig";
+ public override string Name => "Multisig";
+ public override string Description => "Pluginized multisig setup and signer key collection flows.";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddSingleton(new BuiltInPermissionScopeProvider.RouteValueToStoreIdQuery(
+ "multisigSetupId",
+ """
+ SELECT "store_id"
+ FROM "multisig_setups"
+ WHERE "id" = @id
+ LIMIT 1
+ """));
+ services.AddSingleton<Services.MultisigService>();
+ services.AddSingleton<Services.MultisigNotificationService>();
+ services.AddHostedService<HostedServices.MultisigEmailTriggerHostedService>();
+ services.AddSearchResultItemProvider<MultisigSearchResultProvider>();
+ foreach (var trigger in MultisigEmailTriggers.GetViewModels())
+ {
+ services.AddSingleton(trigger);
+ }
+
+ services.AddUIExtension("store-onchain-wallet-setup", "/Plugins/Multisig/Views/SetupWalletCard.cshtml");
+ services.AddUIExtension("dashboard-setup-guide-wallet", "/Plugins/Multisig/Views/DashboardSetupGuideExtension.cshtml");
+ services.AddScheduledDbScript("Multisig Setup Cleanup", "DELETE FROM multisig_setups WHERE expires_at < NOW()");
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/MultisigSearchResultProvider.cs b/BTCPayServer/Plugins/Multisig/MultisigSearchResultProvider.cs
new file mode 100644
index 0000000..3576d39
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/MultisigSearchResultProvider.cs
@@ -0,0 +1,73 @@
+#nullable enable
+
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Payments;
+using BTCPayServer.Payments.Bitcoin;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using BTCPayServer.Plugins.Multisig.Controllers;
+using BTCPayServer.Plugins.Multisig.Services;
+using BTCPayServer.Plugins.Wallets;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.Plugins.Multisig;
+
+public class MultisigSearchResultProvider(
+ PaymentMethodHandlerDictionary handlers,
+ PrettyNameProvider prettyNameProvider,
+ MultisigService multisigService,
+ IStringLocalizer stringLocalizer) : ISearchResultItemProvider
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ public async Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken)
+ {
+ if (context.UserQuery is not null || context.Store is not { Id: var storeId })
+ return;
+
+ var pendingsByCryptoCode = (await multisigService.GetPendingMultisigSetup(storeId)).ToDictionary(o => o.CryptoCode);
+ foreach (var network in handlers.OfType<BitcoinLikePaymentHandler>().Select(h => h.Network))
+ {
+ var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
+ var translated = prettyNameProvider.PrettyName(paymentMethodId, false);
+ var untranslated = prettyNameProvider.PrettyName(paymentMethodId, true);
+ var category = StringLocalizer["On-chain wallets", network.CryptoCode].Value + " ❯ " + translated;
+ var hasWallet = context.Store.GetPaymentMethodConfig<DerivationSchemeSettings>(paymentMethodId, handlers) is not null;
+ if (pendingsByCryptoCode.TryGetValue(network.CryptoCode, out var pending) &&
+ (!hasWallet || pending.ReplacesExistingWallet))
+ {
+ var setupAccess = await context.AuthorizationService.GetSetupAccess(storeId, context.User, pending);
+ if (!setupAccess.CanViewStatus)
+ continue;
+
+ context.ItemResults.Add(new ResultItemViewModel
+ {
+ Title = StringLocalizer["Continue multisig setup in progress"].Value,
+ Url = context.Url.Action(nameof(UIMultisigSetupController.SetupMultisigStatus), "UIMultisigSetup", new { area = MultisigPlugin.Area, multisigSetupId = pending.RequestId }),
+ Category = category,
+ Keywords = ["Multisig", "Setup", "Wallet", network.CryptoCode, translated, untranslated],
+ Order = 1
+ });
+ }
+
+ if (!hasWallet)
+ {
+ context.ItemResults.Add(new ResultItemViewModel
+ {
+ RequiredPolicy = WalletPolicies.CanManageWalletSettings,
+ Title = StringLocalizer["Set up a multisig wallet"].Value,
+ Url = context.Url.Action(nameof(UIMultisigWalletsController.SetupMultisig), "UIMultisigWallets", new { area = MultisigPlugin.Area, storeId, cryptoCode = network.CryptoCode }),
+ Category = category,
+ Keywords = ["Multisig", "Setup", "Wallet", network.CryptoCode, translated, untranslated],
+ Order = 1
+ });
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Services/MultisigNotificationService.cs b/BTCPayServer/Plugins/Multisig/Services/MultisigNotificationService.cs
new file mode 100644
index 0000000..f187cfd
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Services/MultisigNotificationService.cs
@@ -0,0 +1,87 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Plugins.Multisig.Events;
+using BTCPayServer.Plugins.Multisig.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Plugins.Multisig.Services;
+
+public class MultisigNotificationService(
+ EventAggregator eventAggregator,
+ LinkGenerator linkGenerator,
+ ApplicationDbContextFactory dbContextFactory,
+ IEnumerable<EmailTriggerViewModel> emailTriggers)
+{
+ private readonly EmailTriggerViewModel[] _emailTriggers = emailTriggers.ToArray();
+
+ public async Task EnsureDefaultEmailRules(string storeId, CancellationToken cancellationToken = default)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var existingTriggers = await ctx.EmailRules
+ .Where(r => r.StoreId == storeId && MultisigEmailTriggers.DefaultRuleTriggers.Contains(r.Trigger))
+ .Select(r => r.Trigger)
+ .ToArrayAsync(cancellationToken);
+ var existing = existingTriggers.ToHashSet(StringComparer.Ordinal);
+ foreach (var triggerViewModel in _emailTriggers.Where(t => MultisigEmailTriggers.DefaultRuleTriggers.Contains(t.Trigger)))
+ {
+ if (triggerViewModel.DefaultEmail is null || existing.Contains(triggerViewModel.Trigger))
+ continue;
+
+ ctx.EmailRules.Add(new EmailRuleData
+ {
+ StoreId = storeId,
+ Trigger = triggerViewModel.Trigger,
+ To = triggerViewModel.DefaultEmail.To,
+ CC = triggerViewModel.DefaultEmail.CC,
+ BCC = triggerViewModel.DefaultEmail.BCC,
+ Subject = triggerViewModel.DefaultEmail.Subject,
+ Body = triggerViewModel.DefaultEmail.Body
+ });
+ }
+
+ await ctx.SaveChangesAsync(cancellationToken);
+ }
+
+ public Task PublishSignerKeyRequestedEvents(MultisigSetupData pending)
+ {
+ foreach (var participant in pending.Participants.Where(p => string.IsNullOrWhiteSpace(p.AccountKey)))
+ {
+ eventAggregator.Publish(new MultisigSignerKeyRequestedEvent(
+ pending,
+ new(participant.Email,
+ participant.Name)));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public void PublishSignerKeySubmittedEvent(MultisigSetupData pending, PendingMultisigSetupParticipantData participant)
+ {
+ eventAggregator.Publish(new MultisigSignerKeySubmittedEvent(
+ pending,
+ new(participant.Email,
+ participant.Name)));
+ }
+
+ public void PublishWalletCreatedEvent(MultisigSetupData pending)
+ {
+ var walletLink = linkGenerator.WalletTransactionsLink(new WalletId(pending.StoreId, pending.CryptoCode), pending.RequestBaseUrl);
+ var participantIds = pending.Participants
+ .Select(p => p.UserId)
+ .Where(id => !string.IsNullOrWhiteSpace(id))
+ .ToArray();
+ eventAggregator.Publish(new MultisigWalletCreatedEvent(
+ pending,
+ walletLink,
+ participantIds));
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Services/MultisigService.cs b/BTCPayServer/Plugins/Multisig/Services/MultisigService.cs
new file mode 100644
index 0000000..8fc157e
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Services/MultisigService.cs
@@ -0,0 +1,289 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.Multisig.Models;
+using BTCPayServer.Plugins.Wallets;
+using BTCPayServer.Payments;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Stores;
+using Dapper;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using Newtonsoft.Json;
+using NBitcoin;
+using Newtonsoft.Json.Linq;
+using StoreData = BTCPayServer.Data.StoreData;
+
+namespace BTCPayServer.Plugins.Multisig.Services;
+
+public class MultisigService(
+ StoreRepository storeRepository,
+ ApplicationDbContextFactory dbContextFactory,
+ PaymentMethodHandlerDictionary handlers,
+ LinkGenerator linkGenerator,
+ PermissionService permissionService)
+{
+ public async Task PopulateSetupViewModel(MultisigSetupViewModel vm)
+ {
+ vm.MultisigScriptType ??= "p2wsh";
+ vm.MultisigRequiredSigners ??= 2;
+ var safeCount = vm.MultisigTotalSigners is > 0 and <= 15
+ ? vm.MultisigTotalSigners.Value
+ : 3;
+ vm.MultisigTotalSigners = safeCount;
+ vm.MultisigStoreUsers = await GetStoreUsers(vm.StoreId, vm.MultisigParticipantUserIds);
+ }
+
+ public async Task<List<MultisigStoreUserItem>> GetStoreUsers(string storeId, IEnumerable<string>? selectedUserIds = null)
+ {
+ var users = await storeRepository.GetStoreUsers(storeId);
+ var selected = (selectedUserIds ?? Array.Empty<string>()).ToHashSet(StringComparer.Ordinal);
+ return users
+ .Where(user => CanParticipateInMultisigSetup(storeId, user))
+ .Select(user => new MultisigStoreUserItem
+ {
+ UserId = user.Id,
+ Email = user.Email,
+ Name = user.UserBlob?.Name,
+ Selected = selected.Contains(user.Id)
+ })
+ .OrderBy(user => string.IsNullOrWhiteSpace(user.Name) ? user.Email : user.Name, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(user => user.Email, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(user => user.UserId, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ private bool CanParticipateInMultisigSetup(string storeId, StoreRepository.StoreUser user)
+ {
+ var storeRole = user.StoreRole;
+ if (storeRole is null ||
+ !Permission.TryCreatePermission(WalletPolicies.CanSignWalletTransactions, storeId, out var requiredPermission))
+ {
+ return false;
+ }
+
+ return storeRole.ToPermissionSet(storeId).HasPermission(requiredPermission, permissionService);
+ }
+
+ public async Task<MultisigSetupData?> GetPendingMultisigSetupContext(string storeId, string? multisigSetupId)
+ {
+ if (string.IsNullOrWhiteSpace(multisigSetupId))
+ return null;
+
+ await using var ctx = dbContextFactory.CreateContext();
+ var row = await ctx.Database.GetDbConnection().QueryFirstOrDefaultAsync<string>(
+ """
+ SELECT data
+ FROM multisig_setups
+ WHERE store_id = @storeId AND id = @multisigSetupId AND expires_at > NOW()
+ """,
+ new
+ {
+ storeId,
+ multisigSetupId
+ });
+ if (row is null)
+ return null;
+ return (await ToSetupData([row], ctx))[0];
+ }
+
+ private async Task<MultisigSetupData[]> ToSetupData(string[] setupData, ApplicationDbContext ctx)
+ {
+ if (setupData.Length == 0)
+ return Array.Empty<MultisigSetupData>();
+ var setupDataObj = setupData
+ .Select(row => JsonConvert.DeserializeObject<MultisigSetupData>(row, storeRepository.SerializerSettings)!)
+ .ToArray();
+ var participants = await GetParticipants(ctx, setupDataObj.Select(s => s.RequestId).ToArray());
+ foreach (var (pending, participant) in setupDataObj.Zip(participants))
+ pending.Participants = participant;
+ return setupDataObj;
+ }
+
+ public async Task<MultisigSetupData[]> GetPendingMultisigSetup(string storeId)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var setups = await ctx.Database.GetDbConnection().QueryAsync<string>(
+ """
+ SELECT data
+ FROM multisig_setups
+ WHERE store_id = @storeId AND expires_at > NOW()
+ """,
+ new { storeId });
+ return await ToSetupData(setups.ToArray(), ctx);
+ }
+
+ public async Task SavePendingMultisigSetup(MultisigSetupData pending)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var connection = ctx.Database.GetDbConnection();
+ await connection.OpenAsync();
+ await using var transaction = await connection.BeginTransactionAsync();
+ await connection.ExecuteAsync(
+ """
+ DELETE FROM multisig_setups
+ WHERE store_id = @storeId AND crypto_code = @cryptoCode;
+ INSERT INTO multisig_setups (id, store_id, crypto_code, data, expires_at)
+ VALUES (@id, @storeId, @cryptoCode, @data::JSONB, @expiresAt);
+ """,
+ new
+ {
+ id = pending.RequestId,
+ storeId = pending.StoreId,
+ cryptoCode = pending.CryptoCode.ToUpperInvariant(),
+ data = JsonConvert.SerializeObject(pending, storeRepository.SerializerSettings),
+ expiresAt = pending.ExpiresAt
+ }, transaction);
+
+ await connection.ExecuteAsync(
+ """
+ INSERT INTO multisig_setups_participants (multisig_setup_id, user_id)
+ VALUES (@setupId, @userId)
+ """,
+ pending.Participants.Select(p => new
+ {
+ setupId = pending.RequestId,
+ userId = p.UserId
+ }).ToArray(), transaction);
+ await transaction.CommitAsync();
+ pending.Participants = (await GetParticipants(ctx, pending.RequestId))[0];
+ }
+
+ public async Task UpdateParticipant(string storeId, string setupId, string userId, PendingMultisigSetupParticipantData participant)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var connection = ctx.Database.GetDbConnection();
+ await connection.ExecuteAsync(
+ """
+ UPDATE multisig_setups_participants msp
+ SET account_key = @accountKey, account_key_path = @accountKeyPath
+ FROM multisig_setups ms
+ WHERE msp.multisig_setup_id = ms.id
+ AND msp.multisig_setup_id = @setupId
+ AND msp.user_id = @userId
+ AND ms.store_id = @storeId;
+ """,
+ new { setupId, userId, storeId, accountKey = participant.AccountKey, accountKeyPath = participant.AccountKeyPath.ToString() });
+ }
+
+ public async Task DeletePendingMultisigSetup(string storeId, string setupId)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ await ctx.Database.GetDbConnection().ExecuteAsync(
+ """
+ DELETE FROM multisig_setups
+ WHERE store_id = @storeId AND id = @setupId
+ """,
+ new { storeId, setupId });
+ }
+
+ private async Task<List<PendingMultisigSetupParticipantData>[]> GetParticipants(ApplicationDbContext ctx, params string[] setupIds)
+ {
+ var rows = await ctx.Database.GetDbConnection().QueryAsync<string>(
+ """
+ SELECT
+ COALESCE(
+ jsonb_agg(
+ jsonb_build_object(
+ 'userId', p.user_id,
+ 'email', u."Email",
+ 'name', COALESCE(u."Blob2"->>'Name', u."Blob2"->>'name'),
+ 'accountKey', p.account_key,
+ 'accountKeyPath', p.account_key_path
+ )
+ ORDER BY p.user_id
+ ) FILTER (WHERE p.user_id IS NOT NULL),
+ '[]'::jsonb
+ )::text AS participants
+ FROM unnest(@setupIds) WITH ORDINALITY AS s(id, ord)
+ LEFT JOIN multisig_setups_participants p ON p.multisig_setup_id = s.id
+ LEFT JOIN "AspNetUsers" u ON u."Id" = p.user_id
+ GROUP BY s.id, s.ord
+ ORDER BY s.ord;
+ """,
+ new { setupIds });
+ return rows
+ .Select(JArray.Parse)
+ .Select(arr => arr.Select(row => new PendingMultisigSetupParticipantData
+ {
+ UserId = row["userId"]!.Value<string>(),
+ Email = row["email"]!.Value<string>(),
+ Name = row["name"]!.Value<string>(),
+ AccountKey = row["accountKey"]!.Value<string?>(),
+ AccountKeyPath = row["accountKeyPath"]!.Value<string?>() is string path ? RootedKeyPath.Parse(path) : null
+ }).ToList()).ToArray();
+ }
+
+ public bool HasOnChainWallet(StoreData store, string cryptoCode)
+ {
+ var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode);
+ return handlers.Support(paymentMethodId) &&
+ store.GetPaymentMethodConfig<DerivationSchemeSettings>(paymentMethodId, handlers) is not null;
+ }
+
+ public async Task<IReadOnlyList<MultisigInProgressViewModel>> GetInProgressForStore(IAuthorizationService authorizationService, StoreData store, ClaimsPrincipal user)
+ {
+ var result = new List<MultisigInProgressViewModel>();
+ var setupAccess = await authorizationService.GetSetupAccess(store.Id, user, pending: null);
+ var userId = user.GetId();
+
+ var pendings = await GetPendingMultisigSetup(store.Id);
+ foreach (var pending in pendings)
+ {
+ if (!pending.ReplacesExistingWallet && HasOnChainWallet(store, pending.CryptoCode))
+ continue;
+
+ var pendingAccess = setupAccess with { IsParticipant = pending.IsPendingParticipant(userId) };
+ if (!pendingAccess.CanViewStatus)
+ continue;
+
+ result.Add(CreateInProgressViewModel(store.Id, user.GetId(), pending, pendingAccess.CanManageWalletSettings));
+ }
+
+ return result
+ .OrderBy(m => m.ReadyToCreateWallet ? 0 : 1)
+ .ThenBy(m => m.CanSubmitSignerKey ? 0 : 1)
+ .ThenBy(m => m.ExpiresAt)
+ .ToList();
+ }
+
+ public MultisigInProgressViewModel CreateInProgressViewModel(string storeId, string userId, MultisigSetupData pending, bool canCreateWallet)
+ {
+ var participant = pending.Participants.FirstOrDefault(p => string.Equals(p.UserId, userId, StringComparison.Ordinal));
+ var didParticipate = participant is not null;
+ var yourKeySubmitted = !string.IsNullOrWhiteSpace(participant?.AccountKey);
+ var submittedSigners = pending.Participants.Count(p => !string.IsNullOrWhiteSpace(p.AccountKey));
+ var sessionUrl = linkGenerator.MultisigSetupSessionLink(pending.RequestId, pending.RequestBaseUrl);
+
+ return new MultisigInProgressViewModel
+ {
+ StoreId = storeId,
+ CryptoCode = pending.CryptoCode,
+ RequestId = pending.RequestId,
+ RequiredSigners = pending.RequiredSigners,
+ TotalSigners = pending.TotalSigners,
+ SubmittedSigners = submittedSigners,
+ DidParticipate = didParticipate,
+ YourKeySubmitted = yourKeySubmitted,
+ ExpiresAt = pending.ExpiresAt,
+ SessionUrl = sessionUrl,
+ CanCreateWallet = canCreateWallet,
+ Participants = pending.Participants
+ .Select(p => new MultisigInProgressParticipantViewModel
+ {
+ Email = p.Email,
+ Name = p.Name,
+ Submitted = !string.IsNullOrWhiteSpace(p.AccountKey)
+ })
+ .ToArray()
+ };
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/UrlHelperExtensions.Multisig.cs b/BTCPayServer/Plugins/Multisig/UrlHelperExtensions.Multisig.cs
new file mode 100644
index 0000000..239354b
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/UrlHelperExtensions.Multisig.cs
@@ -0,0 +1,19 @@
+#nullable enable
+using BTCPayServer.Abstractions;
+using BTCPayServer.Controllers;
+using BTCPayServer.Plugins.Multisig.Controllers;
+using BTCPayServer.Plugins.Wallets;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+
+namespace BTCPayServer.Plugins.Multisig;
+
+public static class UrlHelperExtensions
+{
+ public static string MultisigSetupSessionLink(this LinkGenerator linkGenerator, string requestId, RequestBaseUrl requestBaseUrl)
+ => linkGenerator.GetUriByAction(
+ action: nameof(UIMultisigSetupController.SetupMultisigStatus),
+ controller: "UIMultisigSetup",
+ values: new { area = MultisigPlugin.Area, multisigSetupId = requestId },
+ requestBaseUrl: requestBaseUrl);
+}
diff --git a/BTCPayServer/Plugins/Multisig/VaultBridge/Elements/MultisigKeyPathHelper.cs b/BTCPayServer/Plugins/Multisig/VaultBridge/Elements/MultisigKeyPathHelper.cs
new file mode 100644
index 0000000..4b629a9
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/VaultBridge/Elements/MultisigKeyPathHelper.cs
@@ -0,0 +1,20 @@
+using System;
+using NBitcoin;
+
+namespace BTCPayServer.Plugins.Multisig.VaultBridge.Elements;
+
+public static class MultisigKeyPathHelper
+{
+ public static KeyPath BuildDefaultPath(KeyPath coinType, string scriptType, int accountNumber)
+ {
+ accountNumber = Math.Max(0, accountNumber);
+ var normalized = scriptType?.ToLowerInvariant();
+ return normalized switch
+ {
+ "p2sh-p2wsh" => new KeyPath("48'").Derive(coinType).Derive(accountNumber, true).Derive(1, true),
+ "p2sh" => new KeyPath("45'").Derive(coinType).Derive(accountNumber, true),
+ "p2wsh" => new KeyPath("48'").Derive(coinType).Derive(accountNumber, true).Derive(2, true),
+ _ => throw new ArgumentOutOfRangeException(nameof(scriptType), scriptType, "Unsupported multisig script type.")
+ };
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/VaultBridge/Elements/MultisigXPubSelect.razor b/BTCPayServer/Plugins/Multisig/VaultBridge/Elements/MultisigXPubSelect.razor
new file mode 100644
index 0000000..6671694
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/VaultBridge/Elements/MultisigXPubSelect.razor
@@ -0,0 +1,236 @@
+@using System
+@using BTCPayServer.Blazor.VaultBridge
+@using BTCPayServer.Blazor.VaultBridge.Elements
+@using Microsoft.AspNetCore.Components
+@using Microsoft.AspNetCore.Components.Web
+@using NBitcoin
+@namespace BTCPayServer.Plugins.Multisig.VaultBridge.Elements
+@inherits VaultElement
+
+<div id="vault-xpub" class="mt-4">
+ <div class="form-group">
+ <label class="form-label">@ui.StringLocalizer["Script type"]</label>
+ <input class="form-control" value="@ScriptTypeLabel" readonly style="max-width:24ch;" />
+ </div>
+
+ <div class="form-group mt-3">
+ <label for="pathMode" class="form-label">@ui.StringLocalizer["Path mode"]</label>
+ <select id="pathMode" @bind="PathMode" class="form-select w-auto">
+ <option value="standard">@ui.StringLocalizer["Standard (Recommended)"]</option>
+ <option value="custom">@ui.StringLocalizer["Custom (Advanced)"]</option>
+ </select>
+ <small class="text-secondary d-block mt-1">@ui.StringLocalizer["Custom mode overrides the standard path completely."]</small>
+ </div>
+
+ @if (!UseCustomPath)
+ {
+ <div class="form-group">
+ <label for="accountNumber" class="form-label">@ui.StringLocalizer["Account"]</label>
+ <input id="accountNumber"
+ @key="AccountInputRenderVersion"
+ class="form-control"
+ type="number"
+ min="0"
+ step="1"
+ inputmode="numeric"
+ pattern="[0-9]*"
+ style="max-width:12ch;"
+ value="@AccountInput"
+ @oninput="OnAccountNumberInput"
+ @onchange="OnAccountNumberInput" />
+ </div>
+ }
+
+ <div class="form-group">
+ <label for="keyPath" class="form-label">@ui.StringLocalizer["Key path"]</label>
+ <input id="keyPath"
+ class="form-control font-monospace"
+ type="text"
+ style="max-width:36ch;"
+ placeholder="m/48'/1'/0'/2'"
+ value="@DisplayedKeyPath"
+ @oninput="OnKeyPathInput"
+ readonly="@(!UseCustomPath)" />
+ @if (!string.IsNullOrEmpty(CustomPathValidationError))
+ {
+ <span class="text-danger">@CustomPathValidationError</span>
+ }
+ </div>
+</div>
+
+<button id="vault-confirm" class="btn btn-primary mt-4" type="button" @onclick="OnConfirm">@ui.StringLocalizer["Confirm"]</button>
+
+@code {
+ private readonly VaultBridgeUI ui;
+ private readonly KeyPath coinType;
+
+ public MultisigXPubSelect(VaultBridgeUI ui, string scriptType, KeyPath coinType)
+ {
+ this.ui = ui;
+ this.coinType = coinType;
+ ScriptType = scriptType?.ToLowerInvariant() switch
+ {
+ null or "" => "p2wsh",
+ "p2wsh" => "p2wsh",
+ "p2sh-p2wsh" => "p2sh-p2wsh",
+ "p2sh" => "p2sh",
+ _ => throw new ArgumentOutOfRangeException(nameof(scriptType), scriptType, "Unsupported multisig script type.")
+ };
+ }
+
+ public string ScriptType { get; }
+ public int AccountNumber { get; set; }
+ public string AccountInput { get; set; } = "0";
+ public int AccountInputRenderVersion { get; set; }
+ public bool UseCustomPath { get; private set; }
+
+ public string PathMode
+ {
+ get => UseCustomPath ? "custom" : "standard";
+ set
+ {
+ var useCustom = string.Equals(value, "custom", StringComparison.OrdinalIgnoreCase);
+ if (UseCustomPath == useCustom)
+ return;
+ UseCustomPath = useCustom;
+ if (useCustom)
+ CustomKeyPath = string.Empty;
+ ui.StateHasChanged();
+ }
+ }
+
+ public string CustomKeyPath { get; set; }
+ public KeyPath CustomParsedKeyPath { get; private set; }
+ public string CustomPathValidationError =>
+ UseCustomPath && !string.IsNullOrWhiteSpace(CustomKeyPath) && !TryGetCustomPath(out _, out var error) ? error : string.Empty;
+
+ public string DisplayedKeyPath =>
+ UseCustomPath
+ ? CustomKeyPath
+ : $"m/{MultisigKeyPathHelper.BuildDefaultPath(coinType, ScriptType, AccountNumber)}";
+
+ public string ScriptTypeLabel => ScriptType switch
+ {
+ "p2sh-p2wsh" => "P2SH-P2WSH",
+ "p2sh" => "P2SH",
+ _ => "P2WSH"
+ };
+
+ private TaskCompletionSource<MultisigXPubSelect> _tcs;
+ private Feedback _selectionFeedback;
+
+ public Task<MultisigXPubSelect> GetSelection()
+ {
+ _selectionFeedback = new Feedback(ui.StringLocalizer["Select account settings"], FeedbackType.Loading);
+ ui.ShowFeedback(_selectionFeedback);
+ ui.AddElement(this);
+ _tcs = new TaskCompletionSource<MultisigXPubSelect>(TaskCreationOptions.RunContinuationsAsynchronously);
+ return _tcs.Task;
+ }
+
+ public void OnConfirm()
+ {
+ if (UseCustomPath)
+ {
+ if (!TryGetCustomPath(out var parsed, out _))
+ {
+ ui.StateHasChanged();
+ return;
+ }
+
+ CustomParsedKeyPath = parsed;
+ }
+ else
+ {
+ SetAccountNumberFromRaw(AccountInput);
+ }
+
+ ui.Elements.Remove(this);
+ if (_selectionFeedback is not null)
+ ui.Elements.Remove(_selectionFeedback);
+ ui.StateHasChanged();
+ _tcs?.TrySetResult(this);
+ _tcs = null;
+ _selectionFeedback = null;
+ }
+
+ public void Dispose()
+ {
+ _tcs?.TrySetCanceled();
+ }
+
+ private void OnKeyPathInput(ChangeEventArgs e)
+ {
+ if (!UseCustomPath)
+ return;
+ CustomKeyPath = e.Value?.ToString() ?? string.Empty;
+ ui.StateHasChanged();
+ }
+
+ private void OnAccountNumberInput(ChangeEventArgs e)
+ {
+ if (UseCustomPath)
+ return;
+ var raw = e.Value?.ToString() ?? string.Empty;
+ SetAccountNumberFromRaw(raw);
+ ui.StateHasChanged();
+ }
+
+ private void SetAccountNumberFromRaw(string raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ AccountNumber = 0;
+ AccountInput = "0";
+ AccountInputRenderVersion++;
+ return;
+ }
+
+ var digitsOnly = true;
+ for (var i = 0; i < raw.Length; i++)
+ {
+ if (!char.IsDigit(raw[i]))
+ {
+ digitsOnly = false;
+ break;
+ }
+ }
+
+ if (digitsOnly && int.TryParse(raw, out var account))
+ {
+ AccountNumber = Math.Max(0, account);
+ AccountInput = AccountNumber.ToString();
+ }
+ else
+ {
+ AccountInput = AccountNumber.ToString();
+ AccountInputRenderVersion++;
+ }
+ }
+
+ private bool TryGetCustomPath(out KeyPath parsedPath, out string error)
+ {
+ parsedPath = null;
+ if (string.IsNullOrWhiteSpace(CustomKeyPath))
+ {
+ error = ui.StringLocalizer["Key path is required"].Value;
+ return false;
+ }
+
+ var normalizedPath = (CustomKeyPath ?? string.Empty).Trim()
+ .Replace("’", "'")
+ .Replace("`", "'")
+ .Replace("′", "'")
+ .Replace(" ", string.Empty);
+ normalizedPath = System.Text.RegularExpressions.Regex.Replace(normalizedPath, @"([0-9]+)[hH]", "$1'");
+ normalizedPath = normalizedPath.StartsWith("m/", StringComparison.OrdinalIgnoreCase) ? normalizedPath[2..] : normalizedPath;
+ if (!KeyPath.TryParse(normalizedPath, out parsedPath))
+ {
+ error = ui.StringLocalizer["Invalid keypath"].Value;
+ return false;
+ }
+
+ error = string.Empty;
+ return true;
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/VaultBridge/GetMultisigAccountKeyController.cs b/BTCPayServer/Plugins/Multisig/VaultBridge/GetMultisigAccountKeyController.cs
new file mode 100644
index 0000000..8ca3a05
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/VaultBridge/GetMultisigAccountKeyController.cs
@@ -0,0 +1,36 @@
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Blazor.VaultBridge;
+using BTCPayServer.Blazor.VaultBridge.Elements;
+using BTCPayServer.Hwi;
+using BTCPayServer.Plugins.Multisig.VaultBridge.Elements;
+using Microsoft.JSInterop;
+using NBitcoin;
+
+namespace BTCPayServer.Plugins.Multisig.VaultBridge;
+
+public class GetMultisigAccountKeyController : HWIController
+{
+ public string ScriptType { get; set; }
+
+ protected override async Task Run(VaultBridgeUI ui, HwiClient hwi, HwiDeviceClient device, HDFingerprint fingerprint, BTCPayNetwork network, CancellationToken cancellationToken)
+ {
+ var selector = new MultisigXPubSelect(ui, ScriptType, network.CoinType);
+ var selection = await selector.GetSelection();
+ ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Fetching public keys..."]);
+
+ var keyPath = selection.UseCustomPath
+ ? selection.CustomParsedKeyPath
+ : MultisigKeyPathHelper.BuildDefaultPath(network.CoinType, selection.ScriptType, selection.AccountNumber);
+
+ var xpub = await device.GetXPubAsync(keyPath, cancellationToken);
+
+ ui.ShowFeedback(FeedbackType.Success, ui.StringLocalizer["Public keys successfully fetched."]);
+ await ui.JSRuntime.InvokeVoidAsync("vault.setMultisigSignerKey", cancellationToken, new JsonObject
+ {
+ ["accountKey"] = xpub.ToString(),
+ ["accountKeyPath"] = new RootedKeyPath(fingerprint, keyPath).ToString()
+ });
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Views/DashboardSetupGuideExtension.cshtml b/BTCPayServer/Plugins/Multisig/Views/DashboardSetupGuideExtension.cshtml
new file mode 100644
index 0000000..437cf3b
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/DashboardSetupGuideExtension.cshtml
@@ -0,0 +1,3 @@
+@model StoreDashboardViewModel
+
+<vc:multisig-dashboard-setup-guide model="Model" />
diff --git a/BTCPayServer/Plugins/Multisig/Views/MultisigConfirm.cshtml b/BTCPayServer/Plugins/Multisig/Views/MultisigConfirm.cshtml
new file mode 100644
index 0000000..a93a731
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/MultisigConfirm.cshtml
@@ -0,0 +1,45 @@
+@model MultisigSetupViewModel
+@{
+ ViewData.SetTitle(StringLocalizer["Confirm addresses"]);
+}
+
+@section Navbar {
+ <a asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigSetup" asp-action="SetupMultisigStatus" asp-route-multisigSetupId="@Model.MultisigRequestId">
+ <vc:icon symbol="back" />
+ </a>
+}
+
+<header class="text-center">
+ <h1>@ViewData["Title"]</h1>
+ <p class="lead text-secondary mt-3" text-translate="true">Please check that your wallet is generating the same addresses as below.</p>
+</header>
+
+<form method="post" asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigWallets" asp-action="FinalizeMultisigSetup" asp-route-multisigSetupId="@Model.MultisigRequestId">
+ <input asp-for="Confirmation" type="hidden"/>
+ <input asp-for="MultisigRequestId" type="hidden" />
+ <div asp-validation-summary="All"></div>
+
+ <div class="table-responsive-sm">
+ <table class="table table-hover w-auto mx-auto">
+ <thead>
+ <tr>
+ <th text-translate="true">Key path</th>
+ <th text-translate="true">Address</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var sample in Model.AddressSamples ?? [])
+ {
+ <tr>
+ <td>@sample.KeyPath</td>
+ <td><code>@sample.Address</code></td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+
+ <div class="text-center">
+ <button type="submit" class="btn btn-primary" id="Confirm" text-translate="true">Confirm</button>
+ </div>
+</form>
diff --git a/BTCPayServer/Plugins/Multisig/Views/SetupMultisig.cshtml b/BTCPayServer/Plugins/Multisig/Views/SetupMultisig.cshtml
new file mode 100644
index 0000000..2028a09
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/SetupMultisig.cshtml
@@ -0,0 +1,107 @@
+@model MultisigSetupViewModel
+@{
+ ViewData.SetTitle(StringLocalizer["Multisig setup"]);
+}
+
+@section Navbar {
+ <a asp-area="@WalletsPlugin.Area" asp-controller="UIStoreOnChainWallets" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode">
+ <vc:icon symbol="back" />
+ </a>
+}
+
+<header class="text-center">
+ <h1>@ViewData["Title"]</h1>
+ <p class="lead text-secondary mt-3" text-translate="true">Build your multisig derivation using guided inputs, then import it in one step.</p>
+</header>
+
+<form asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigWallets" asp-action="SetupMultisig" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" method="post" class="my-5">
+ <div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
+
+ <div class="row g-3">
+ <div class="col-md-4">
+ <label asp-for="MultisigRequiredSigners" class="form-label" text-translate="true">Required signatures (M)</label>
+ <input asp-for="MultisigRequiredSigners" class="form-control" type="number" min="1" max="15" />
+ </div>
+ <div class="col-md-4">
+ <label asp-for="MultisigTotalSigners" class="form-label" text-translate="true">Total signers (N)</label>
+ <input asp-for="MultisigTotalSigners" class="form-control" type="number" min="1" max="15" id="MultisigTotalSigners" />
+ </div>
+ <div class="col-md-4">
+ <label asp-for="MultisigScriptType" class="form-label" text-translate="true">Script type</label>
+ <select asp-for="MultisigScriptType" class="form-select" id="MultisigScriptType">
+ <option value="p2wsh">P2WSH</option>
+ <option value="p2sh-p2wsh">P2SH-P2WSH</option>
+ <option value="p2sh">P2SH</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="mt-4 border rounded p-3">
+ <h4 class="mb-2" text-translate="true">Request signer keys from store users</h4>
+ <p class="text-secondary mb-3" text-translate="true">Select the store users who should provide signer keys for this multisig wallet.</p>
+ <div class="d-flex align-items-center justify-content-between mb-3">
+ <span class="small text-secondary">
+ <span id="SelectedSignerCount">0</span> @StringLocalizer["selected"] ·
+ <span id="SelectedSignerSummary">0/@(Model.MultisigTotalSigners ?? 0)</span> @StringLocalizer["total signers"]
+ </span>
+ </div>
+ <div id="SignerList" class="d-flex flex-column gap-2 overflow-auto" style="max-height: 360px;">
+ @foreach (var user in Model.MultisigStoreUsers)
+ {
+ var displayName = !string.IsNullOrWhiteSpace(user.Name) ? user.Name : user.Email;
+ var showEmail = !string.IsNullOrWhiteSpace(user.Email) &&
+ !string.Equals(displayName, user.Email, StringComparison.OrdinalIgnoreCase);
+ <label class="border rounded p-3 d-flex align-items-center gap-3 multisig-signer-item">
+ <input class="form-check-input m-0 fs-5 flex-shrink-0 multisig-signer-checkbox" type="checkbox" name="MultisigParticipantUserIds" value="@user.UserId" @(user.Selected ? "checked" : "") />
+ <span class="d-flex flex-column flex-grow-1">
+ <span class="fw-semibold">@displayName</span>
+ @if (showEmail)
+ {
+ <span class="text-secondary small">@user.Email</span>
+ }
+ </span>
+ </label>
+ }
+ </div>
+ <div class="mt-3 d-flex flex-wrap align-items-center justify-content-end gap-2">
+ <button id="CreateSignerRequest" type="submit" class="btn btn-primary text-nowrap" text-translate="true">Send requests</button>
+ </div>
+ </div>
+
+ <div class="d-flex gap-3 mt-4">
+ <a asp-area="@WalletsPlugin.Area" asp-controller="UIStoreOnChainWallets" asp-action="ImportWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" asp-route-method="xpub" class="btn btn-secondary" text-translate="true">Use manual xpub entry</a>
+ </div>
+</form>
+
+<script>
+ (function () {
+ const maxSigners = 15;
+ const totalInput = document.getElementById("MultisigTotalSigners");
+ const createRequestButton = document.getElementById("CreateSignerRequest");
+ const selectedSignerCount = document.getElementById("SelectedSignerCount");
+ const selectedSignerSummary = document.getElementById("SelectedSignerSummary");
+ const signerCheckboxes = Array.from(document.querySelectorAll(".multisig-signer-checkbox"));
+
+ function parseCount(value, fallback) {
+ const parsed = parseInt(value, 10);
+ if (Number.isNaN(parsed) || parsed < 1) return fallback;
+ return Math.min(parsed, maxSigners);
+ }
+
+ function refreshSelectedSigners() {
+ const selected = signerCheckboxes.filter(c => c.checked).length;
+ const currentTotal = parseCount(totalInput.value, 1);
+ if (selected > currentTotal) {
+ totalInput.value = selected.toString();
+ }
+
+ selectedSignerCount.textContent = selected.toString();
+ selectedSignerSummary.textContent = `${selected}/${parseCount(totalInput.value, 1)}`;
+ createRequestButton.disabled = selected === 0;
+ }
+
+ totalInput.addEventListener("input", refreshSelectedSigners);
+ signerCheckboxes.forEach(c => c.addEventListener("change", refreshSelectedSigners));
+ refreshSelectedSigners();
+ })();
+</script>
diff --git a/BTCPayServer/Plugins/Multisig/Views/SetupMultisigStatus.cshtml b/BTCPayServer/Plugins/Multisig/Views/SetupMultisigStatus.cshtml
new file mode 100644
index 0000000..12b036a
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/SetupMultisigStatus.cshtml
@@ -0,0 +1,102 @@
+@model BTCPayServer.Plugins.Multisig.Models.MultisigInProgressViewModel
+@{
+ ViewData.SetTitle(StringLocalizer["Multisig setup"]);
+ var remainingSigners = Math.Max(0, Model.TotalSigners - Model.SubmittedSigners);
+ var progressStatus = remainingSigners == 0
+ ? StringLocalizer["All signer keys submitted"].Value
+ : remainingSigners == 1
+ ? StringLocalizer["Waiting for 1 signer"].Value
+ : StringLocalizer["Waiting for {0} signers", remainingSigners].Value;
+}
+
+<header class="text-center mb-4">
+ <h1>@ViewData["Title"]</h1>
+ <p class="lead text-secondary mt-3">
+ @StringLocalizer["{0}-of-{1} {2} · {3}/{4} submitted · {5}", Model.RequiredSigners, Model.TotalSigners, Model.CryptoCode, Model.SubmittedSigners, Model.TotalSigners, progressStatus]
+ </p>
+</header>
+
+<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
+
+<div class="border rounded p-3">
+ <h2 class="h4 mb-2" text-translate="true">Request signer keys from store users</h2>
+ <p class="text-secondary mb-4" text-translate="true">Track signer key collection and finalize setup once all keys are submitted.</p>
+
+ <div class="border rounded p-3 mb-4">
+ <div class="row g-4 align-items-center">
+ <div class="col-12 col-lg">
+ <h3 class="h5 mb-2" text-translate="true">Signer request link</h3>
+ <p class="text-secondary mb-1" text-translate="true">Request id:</p>
+ <p class="text-secondary text-break mb-3">@Model.RequestId</p>
+ <p class="text-secondary mb-3" text-translate="true">BTCPay Server sends email requests if email is enabled. You can also share this link or QR code with the selected signers.</p>
+ <div class="input-group">
+ <input id="MultisigSessionLink" class="form-control" value="@Model.SessionUrl" readonly />
+ <button type="button"
+ class="btn btn-secondary clipboard-button"
+ data-clipboard-target="#MultisigSessionLink"
+ title="@StringLocalizer["Copy session link"]"
+ aria-label="@StringLocalizer["Copy session link"]">
+ <vc:icon symbol="actions-copy" />
+ </button>
+ </div>
+ </div>
+ <div class="col-12 col-lg-auto text-center">
+ <vc:qr-code data="@Model.SessionUrl" size="240" />
+ </div>
+ </div>
+ </div>
+
+ <p class="text-secondary mb-3">
+ @StringLocalizer["{0}/{1} signer keys submitted", Model.SubmittedSigners, Model.TotalSigners]
+ </p>
+ <div class="table-responsive-md">
+ <table class="table table-hover align-middle mb-0">
+ <thead>
+ <tr>
+ <th text-translate="true">Signer</th>
+ <th class="text-end" text-translate="true">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var participant in Model.Participants)
+ {
+ var displayName = !string.IsNullOrWhiteSpace(participant.Name) ? participant.Name : participant.Email;
+ var showEmail = !string.IsNullOrWhiteSpace(participant.Email) &&
+ !string.Equals(displayName, participant.Email, StringComparison.OrdinalIgnoreCase);
+ <tr>
+ <td class="text-break">
+ <div>@displayName</div>
+ @if (showEmail)
+ {
+ <div class="text-secondary small">@participant.Email</div>
+ }
+ </td>
+ <td class="text-end">
+ @if (participant.Submitted)
+ {
+ <span class="badge badge-translucent rounded-pill text-bg-success" text-translate="true">Submitted</span>
+ }
+ else
+ {
+ <span class="badge rounded-pill text-bg-warning" text-translate="true">Pending</span>
+ }
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+
+ <div class="d-flex justify-content-end gap-3 mt-4">
+ @if (Model.CanSubmitSignerKey)
+ {
+ <a id="SubmitSignerKeyCta" asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigSetup" asp-action="SetupMultisigSubmitKey" asp-route-multisigSetupId="@Model.RequestId" class="btn btn-primary" text-translate="true">Submit signer key</a>
+ }
+ @if (Model.ReadyToCreateWallet && Model.CanCreateWallet)
+ {
+ <form method="post" asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigWallets" asp-action="FinalizeMultisigSetup" asp-route-multisigSetupId="@Model.RequestId">
+ <button id="FinalizeMultisig" type="submit" class="btn btn-primary" text-translate="true">Create multisig</button>
+ </form>
+ }
+ </div>
+</div>
diff --git a/BTCPayServer/Plugins/Multisig/Views/SetupMultisigSubmitKey.cshtml b/BTCPayServer/Plugins/Multisig/Views/SetupMultisigSubmitKey.cshtml
new file mode 100644
index 0000000..1d4e019
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/SetupMultisigSubmitKey.cshtml
@@ -0,0 +1,208 @@
+@model MultisigSignerKeyViewModel
+@{
+ Layout = "~/Views/Shared/_LayoutSimple.cshtml";
+ ViewData["Title"] = StringLocalizer["Submit multisig signer key"];
+ var inputMethod = Model.InputMethod?.ToLowerInvariant();
+ var useHardware = inputMethod == "hardware";
+ var useManual = inputMethod == "manual";
+ var showMethodSelection = !useHardware && !useManual;
+ ViewData.SetBlazorAllowed(useHardware);
+}
+
+<main class="py-4 py-lg-5">
+ <div class="container">
+ <div class="row justify-content-center">
+ <div class="col-12 col-lg-8">
+ <div class="bg-tile m-0 p-3 p-sm-5 rounded">
+ <header class="mb-4">
+ <h1 class="h3 mb-3">@ViewData["Title"]</h1>
+ <p class="text-secondary mb-0">
+ @StringLocalizer["Request {0} - {1}/{2} ({3}).", Model.RequestId, Model.RequiredSigners, Model.TotalSigners, Model.ScriptType.ToUpperInvariant()]
+ </p>
+ </header>
+
+ @if (showMethodSelection)
+ {
+ <header class="border-top pt-4 mt-4">
+ <h2 class="h5 mb-2" text-translate="true">Choose input method</h2>
+ <p class="text-secondary mb-0" text-translate="true">Select how you want to provide your multisig signer key.</p>
+ </header>
+
+ <div class="list-group mt-3">
+ <a asp-action="SetupMultisigSubmitKey"
+ asp-route-multisigSetupId="@Model.RequestId"
+ asp-route-method="hardware"
+ id="SubmitSignerKeyHardware"
+ class="list-group-item list-group-item-action only-for-js d-flex align-items-center gap-3 p-4">
+ <div class="flex-shrink-0 text-primary">
+ <vc:icon symbol="wallet-hardware" />
+ </div>
+ <div class="flex-grow-1 d-flex flex-column flex-lg-row align-items-lg-center justify-content-lg-between gap-2 me-2">
+ <div>
+ <h3 class="h5 mb-1" text-translate="true">Hardware wallet</h3>
+ <p class="mb-0 text-secondary" text-translate="true">Fetch your account key, fingerprint, and key path using BTCPay Server Vault</p>
+ </div>
+ <small class="d-block text-primary mt-2 mt-lg-0" text-translate="true">Recommended</small>
+ </div>
+ <vc:icon symbol="caret-right" />
+ </a>
+ <noscript>
+ <div class="list-group-item disabled d-flex align-items-center gap-3 p-4">
+ <div class="flex-shrink-0 text-primary">
+ <vc:icon symbol="wallet-hardware" />
+ </div>
+ <div class="flex-grow-1">
+ <h3 class="h5 mb-1" text-translate="true">Hardware wallet</h3>
+ <p class="mb-0" text-translate="true">Please enable JavaScript for this option to be available</p>
+ </div>
+ </div>
+ </noscript>
+ </div>
+
+ <div class="list-group mt-4">
+ <a asp-action="SetupMultisigSubmitKey"
+ asp-route-multisigSetupId="@Model.RequestId"
+ asp-route-method="manual"
+ id="SubmitSignerKeyManual"
+ class="list-group-item list-group-item-action d-flex align-items-center gap-3 p-4">
+ <div class="flex-shrink-0 text-primary">
+ <vc:icon symbol="wallet-xpub" />
+ </div>
+ <div class="flex-grow-1 me-2">
+ <h3 class="h5 mb-1" text-translate="true">Manual entry</h3>
+ <p class="mb-0 text-secondary" text-translate="true">Paste your account key and optional origin information manually</p>
+ </div>
+ <vc:icon symbol="caret-right" />
+ </a>
+ </div>
+
+ <div class="mt-4">
+ <a asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigSetup" asp-action="SetupMultisigStatus" asp-route-multisigSetupId="@Model.RequestId" class="btn btn-secondary" text-translate="true">Back to setup</a>
+ </div>
+ }
+ else
+ {
+ @if (useHardware)
+ {
+ <div id="HardwareFlowSection" class="mb-4">
+ <h2 class="h5 mb-2" text-translate="true">Extract from BTCPay Server Vault</h2>
+ <p class="text-secondary mb-3" text-translate="true">Use your hardware wallet to auto-fill account key, fingerprint, and key path.</p>
+ <component type="typeof(BTCPayServer.Blazor.VaultBridge.VaultBridgeUI)"
+ param-Controller="@(new GetMultisigAccountKeyController
+ {
+ CryptoCode = Model.CryptoCode,
+ ScriptType = Model.ScriptType
+ })"
+ render-mode="Server" />
+ <p id="HardwareFetchHint" class="text-secondary mt-3 mb-0" text-translate="true">Fetch signer data from device to continue.</p>
+ </div>
+ }
+ else
+ {
+ <header class="mb-4">
+ <h2 class="h5 mb-2" text-translate="true">Enter signer key manually</h2>
+ <p class="text-secondary mb-0" text-translate="true">Paste your account key and optional origin information.</p>
+ </header>
+ }
+
+ <form method="post" id="SubmitSignerKeyForm">
+ <input type="hidden" name="InputMethod" value="@inputMethod" />
+ <div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
+ <div id="SignerKeyFields" class="@(useHardware ? "d-none" : string.Empty)">
+ <div class="mb-3">
+ <label asp-for="DisplayAccountKey" class="form-label" text-translate="true">Account key (xpub/tpub)</label>
+ <input asp-for="DisplayAccountKey" class="form-control font-monospace" />
+ <span asp-validation-for="DisplayAccountKey" class="text-danger"></span>
+ </div>
+ <div class="row g-3">
+ <div class="col-md-8">
+ <label asp-for="AccountKeyPath" class="form-label" text-translate="true">Account key path</label>
+ <input asp-for="AccountKeyPath" class="form-control font-monospace" placeholder="@StringLocalizer["d90c6a4f/48'/1'/0'/2'"]" />
+ <span asp-validation-for="AccountKeyPath" class="text-danger"></span>
+ </div>
+ </div>
+ </div>
+ <div class="d-flex flex-wrap gap-3 mt-4">
+ <button type="submit" id="SubmitSignerKeyButton" class="btn btn-primary" disabled="@useHardware" text-translate="true">Submit signer key</button>
+ <a asp-action="SetupMultisigSubmitKey"
+ asp-route-multisigSetupId="@Model.RequestId"
+ id="BackToSignerKeyInputMethods"
+ class="btn btn-secondary"
+ text-translate="true">Choose another method</a>
+ <a asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigSetup" asp-action="SetupMultisigStatus" asp-route-multisigSetupId="@Model.RequestId" class="btn btn-secondary" text-translate="true">Back to setup</a>
+ </div>
+ </form>
+ }
+ </div>
+ </div>
+ </div>
+ </div>
+</main>
+
+@section PageFootContent
+{
+ @if (!showMethodSelection)
+ {
+ @if (useHardware)
+ {
+ <script src="~/js/vaultbridge.js" asp-append-version="true"></script>
+ }
+ <script>
+ (() => {
+ const useHardware = @(useHardware ? "true" : "false");
+ const hardwareHint = document.getElementById("HardwareFetchHint");
+ const signerFields = document.getElementById("SignerKeyFields");
+ const submitButton = document.getElementById("SubmitSignerKeyButton");
+ const accountKey = document.getElementById("DisplayAccountKey");
+ const accountKeyPath = document.getElementById("AccountKeyPath");
+
+ if (!signerFields || !submitButton || !accountKey || !accountKeyPath) {
+ return;
+ }
+
+ function hasRequiredSignerValues() {
+ return !!(accountKey.value && accountKey.value.trim());
+ }
+
+ function setReadonly(readonly) {
+ accountKey.readOnly = readonly;
+ accountKeyPath.readOnly = readonly;
+ }
+
+ function refreshHardwareState() {
+ const ready = hasRequiredSignerValues();
+ signerFields.classList.toggle("d-none", !ready);
+ if (hardwareHint) {
+ hardwareHint.classList.toggle("d-none", ready);
+ }
+ submitButton.disabled = !ready;
+ }
+ setReadonly(useHardware);
+ if (useHardware) {
+ refreshHardwareState();
+ } else {
+ submitButton.disabled = false;
+ }
+
+ accountKey.addEventListener("input", () => {
+ if (useHardware) {
+ refreshHardwareState();
+ }
+ });
+ accountKey.addEventListener("change", () => {
+ if (useHardware) {
+ refreshHardwareState();
+ }
+ });
+
+ if (useHardware) {
+ vault.setMultisigSignerKey = function (data) {
+ accountKey.value = data.accountKey || "";
+ accountKeyPath.value = data.accountKeyPath || "";
+ refreshHardwareState();
+ };
+ }
+ })();
+ </script>
+ }
+}
diff --git a/BTCPayServer/Plugins/Multisig/Views/SetupWalletCard.cshtml b/BTCPayServer/Plugins/Multisig/Views/SetupWalletCard.cshtml
new file mode 100644
index 0000000..8b166b2
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/SetupWalletCard.cshtml
@@ -0,0 +1,20 @@
+@model WalletSetupViewModel
+
+@if (Model.CryptoCode == "BTC")
+{
+ <br>
+ <div class="mt-5">
+ <div class="list-group">
+ <a asp-area="@MultisigPlugin.Area" asp-controller="UIMultisigWallets" asp-action="SetupMultisig" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" id="ImportMultisigWizardLink" class="list-group-item list-group-item-action">
+ <div class="image">
+ <vc:icon symbol="wallet-xpub"/>
+ </div>
+ <div class="content">
+ <h4 text-translate="true">Multisig setup</h4>
+ <p class="mb-0 text-secondary" text-translate="true">Request signer keys, collect account keys, and create the multisig wallet</p>
+ </div>
+ <vc:icon symbol="caret-right" />
+ </a>
+ </div>
+ </div>
+}
diff --git a/BTCPayServer/Plugins/Multisig/Views/Shared/Components/MultisigDashboardSetupGuide/Default.cshtml b/BTCPayServer/Plugins/Multisig/Views/Shared/Components/MultisigDashboardSetupGuide/Default.cshtml
new file mode 100644
index 0000000..347146b
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/Shared/Components/MultisigDashboardSetupGuide/Default.cshtml
@@ -0,0 +1,33 @@
+@model IReadOnlyList<BTCPayServer.Plugins.Multisig.Models.MultisigInProgressViewModel>
+
+@foreach (var multisig in Model)
+{
+ var setupState = multisig.ReadyToCreateWallet
+ ? StringLocalizer["Ready to create wallet"]
+ : multisig.CanSubmitSignerKey
+ ? StringLocalizer["Awaiting your key"]
+ : multisig.MissingSigners == 1
+ ? StringLocalizer["Waiting for 1 signer"]
+ : StringLocalizer["Waiting for {0} signers", multisig.MissingSigners];
+ var participationState = multisig.DidParticipate
+ ? (multisig.YourKeySubmitted ? StringLocalizer["You participated"] : StringLocalizer["You are invited"])
+ : StringLocalizer["You are not a signer"];
+ var canCreateWallet = multisig.ReadyToCreateWallet && multisig.CanCreateWallet;
+ var actionText = multisig.CanSubmitSignerKey
+ ? StringLocalizer["Submit key"]
+ : canCreateWallet
+ ? StringLocalizer["Create wallet"]
+ : StringLocalizer["View status"];
+ var actionUrl = multisig.SessionUrl;
+ <a href="@actionUrl"
+ id="SetupGuide-Multisig-@multisig.RequestId"
+ class="list-group-item list-group-item-action d-flex align-items-center order-1">
+ <vc:icon symbol="wallet-new" />
+ <div class="content">
+ <h5 class="mb-0" text-translate="true">Multisig wallet setup</h5>
+ <div class="text-secondary small">@StringLocalizer["{0}-of-{1} {2} · {3}/{4} submitted · {5} · {6}", multisig.RequiredSigners, multisig.TotalSigners, multisig.CryptoCode, multisig.SubmittedSigners, multisig.TotalSigners, setupState, participationState]</div>
+ </div>
+ <span class="text-primary text-nowrap me-2">@actionText</span>
+ <vc:icon symbol="caret-right" />
+ </a>
+}
diff --git a/BTCPayServer/Plugins/Multisig/Views/_ViewImports.cshtml b/BTCPayServer/Plugins/Multisig/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..af3c236
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/_ViewImports.cshtml
@@ -0,0 +1,7 @@
+@using BTCPayServer.Models.StoreViewModels
+@using BTCPayServer.Plugins.Multisig
+@using BTCPayServer.Plugins.Multisig.Models
+@using BTCPayServer.Plugins.Multisig.VaultBridge
+@using BTCPayServer.Plugins.Wallets
+@using BTCPayServer.Plugins.Wallets.Views.ViewModels
+@namespace BTCPayServer.Plugins.Multisig.Views
diff --git a/BTCPayServer/Plugins/Multisig/Views/_ViewStart.cshtml b/BTCPayServer/Plugins/Multisig/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..0034f46
--- /dev/null
+++ b/BTCPayServer/Plugins/Multisig/Views/_ViewStart.cshtml
@@ -0,0 +1,8 @@
+@using BTCPayServer.Abstractions.Extensions
+@using BTCPayServer.Views
+@using BTCPayServer.Views.Stores
+
+@{
+ Layout = "~/Plugins/Wallets/Views/UIStoreOnChainWallets/_LayoutWalletSetup.cshtml";
+ ViewData.SetActiveCategory(typeof(StoreNavPages));
+}
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs
index d92a72b..560a421 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
@@ -22,6 +23,7 @@ using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Localization;
@@ -48,6 +50,7 @@ public class UIStoreOnChainWalletsController(
EventAggregator eventAggregator,
IHtmlHelper html,
IStringLocalizer stringLocalizer,
+ UserManager<ApplicationUser> userManager,
HotwalletSafe hotwalletSafe)
: Controller
{
@@ -459,6 +462,16 @@ public class UIStoreOnChainWalletsController(
var handler = paymentMethodHandlerDictionary.GetBitcoinHandler(cryptoCode);
var hw = await hotwalletSafe.TryUnlock(User, new WalletId(storeId, cryptoCode));
+
+ var userById = new Dictionary<string, ApplicationUser>();
+ foreach (var participant in derivation.AccountKeySettings.Where(p => p.SignerUserId != null))
+ {
+ var user = await userManager.FindByIdAsync(participant.SignerUserId);
+ if (user == null)
+ continue;
+ userById.TryAdd(user.Id, user);
+ }
+
var vm = new WalletSettingsViewModel
{
StoreId = storeId,
@@ -480,7 +493,8 @@ public class UIStoreOnChainWalletsController(
{
AccountKey = e.AccountKey.ToString(),
MasterFingerprint = e.RootFingerprint is { } fp ? fp.ToString() : null,
- AccountKeyPath = e.AccountKeyPath == null ? "" : $"m/{e.AccountKeyPath}"
+ AccountKeyPath = e.AccountKeyPath == null ? "" : $"m/{e.AccountKeyPath}",
+ SignerEmail = userById.TryGetValue(e.SignerUserId ?? "", out var u) ? u.GetMailboxAddress().ToString() : null,
}).ToList(),
Config = _dataProtector.ProtectString(JToken.FromObject(derivation, handler.Serializer).ToString()),
PayJoinEnabled = storeBlob.PayJoinEnabled,
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
index 1b38fe3..e76a44b 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
@@ -130,8 +130,16 @@ namespace BTCPayServer.Controllers
return NotFound();
var pendingTransaction =
await pendingTransactionService.GetPendingTransaction(GetPendingTxId(walletId, pendingTransactionId));
- if (pendingTransaction is null || network is null)
+ if (pendingTransaction is null)
return NotFound();
+ if (pendingTransaction.State is not (PendingTransactionState.Pending or PendingTransactionState.Signed))
+ {
+ return RedirectToAction(nameof(WalletTransactions), new
+ {
+ walletId = walletId.ToString(),
+ searchText = pendingTransaction.TransactionId
+ });
+ }
var canSign = (await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded;
var canBroadcastSigned = pendingTransaction.State == PendingTransactionState.Signed &&
(await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanBroadcastWalletTransactions)).Succeeded;
@@ -1644,7 +1652,8 @@ namespace BTCPayServer.Controllers
var pendingTransaction = await pendingTransactionService.CollectSignature(
GetPendingTxId(walletId, vm.SigningContext.PendingTransactionId),
psbt,
- CancellationToken.None);
+ CancellationToken.None,
+ GetUserId());
if (pendingTransaction != null)
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
diff --git a/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs b/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs
index fa8a518..efe5664 100644
--- a/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs
+++ b/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs
@@ -1,6 +1,8 @@
#nullable enable
+using BTCPayServer.Abstractions;
using BTCPayServer.Controllers;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Plugins.Wallets;
@@ -13,4 +15,11 @@ public static class UrlHelperExtensions
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), "UIWallets", new { area = WalletsPlugin.Area, walletId });
+ public static string WalletPendingTransactionLink(this LinkGenerator urlHelper, WalletId walletId, string pendingTransactionId, RequestBaseUrl baseUrl)
+ => urlHelper.GetUriByAction(
+ action: nameof(UIWalletsController.ViewPendingTransaction),
+ controller: "UIWallets",
+ values: new { area = WalletsPlugin.Area, walletId = walletId.ToString(), pendingTransactionId },
+ baseUrl
+ );
}
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/SetupWallet.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/SetupWallet.cshtml
index ca8fa16..de519ff 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/SetupWallet.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/SetupWallet.cshtml
@@ -41,3 +41,5 @@
</div>
</div>
}
+
+<vc:ui-extension-point location="store-onchain-wallet-setup" model="@Model" />
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml
index cc41805..682759a 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml
@@ -151,6 +151,13 @@
@for (var i = 0; i < Model.AccountKeys.Count; i++)
{
<h4 class="mt-5 mb-3">@StringLocalizer["Account Key"] @i</h4>
+ @if (!string.IsNullOrWhiteSpace(Model.AccountKeys[i].SignerEmail))
+ {
+ <div class="text-secondary mb-2">
+ <span class="fw-semibold">@StringLocalizer["Signer"]:</span>
+ <span>@Model.AccountKeys[i].SignerEmail</span>
+ </div>
+ }
<div class="form-group">
<div class="d-flex align-items-center justify-content-between">
<label asp-for="@Model.AccountKeys[i].AccountKey" class="form-label"></label>
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/_LayoutWalletSetup.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/_LayoutWalletSetup.cshtml
index ec2ee9c..3faabf8 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/_LayoutWalletSetup.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/_LayoutWalletSetup.cshtml
@@ -1,5 +1,6 @@
@{
Layout = "_LayoutWizard";
+ var storeId = Context.GetStoreDataOrNull()?.Id ?? Context.GetRouteValue("storeId")?.ToString();
}
@section PageHeadContent {
@@ -13,7 +14,7 @@
@section Navbar {
@await RenderSectionAsync("Navbar", false)
- <a asp-area="" asp-controller="UIStores" asp-action="Index" asp-route-storeId="@Context.GetRouteValue("storeId")" id="CancelWizard" class="cancel">
+ <a asp-area="" asp-controller="UIStores" asp-action="Index" asp-route-storeId="@storeId" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
}
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/ListWallets.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/ListWallets.cshtml
index cb5727b..1bee7a6 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/ListWallets.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/ListWallets.cshtml
@@ -6,6 +6,8 @@
<partial name="_StatusMessage" />
+<vc:ui-extension-point location="wallets-top" model="@Model" />
+
<div class="d-sm-flex justify-content-between mb-2">
<h2 class="mb-0">
@ViewData["Title"]
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
index 4eaf303..04a2dce 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
@@ -668,7 +668,7 @@
var ptblob = @pendingTransaction.GetBlob();
var isMultisigPending = (ptblob?.SignaturesNeeded ?? 0) > 0 && (ptblob?.SignaturesTotal ?? 0) > 1;
<tr>
- <td>@pendingTransaction.TransactionId</td>
+ <td>@(pendingTransaction.TransactionId?.ToString() ?? "—")</td>
<td>@pendingTransaction.State</td>
@if (showMultisigColumns)
{
diff --git a/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs b/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
index 03b21c8..3a74433 100644
--- a/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
+++ b/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
@@ -1,5 +1,3 @@
-using BTCPayServer;
-using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Services;
diff --git a/BTCPayServer/Plugins/Webhooks/TriggerProviders/PendingTransactionTriggerProvider.cs b/BTCPayServer/Plugins/Webhooks/TriggerProviders/PendingTransactionTriggerProvider.cs
index 2d8028a..a286581 100644
--- a/BTCPayServer/Plugins/Webhooks/TriggerProviders/PendingTransactionTriggerProvider.cs
+++ b/BTCPayServer/Plugins/Webhooks/TriggerProviders/PendingTransactionTriggerProvider.cs
@@ -4,6 +4,7 @@ using BTCPayServer.Abstractions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
+using BTCPayServer.Plugins.Wallets;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json;
@@ -39,7 +40,10 @@ public class PendingTransactionTriggerProvider
model["PendingTransaction"] = o;
if (blob.RequestBaseUrl is not null && RequestBaseUrl.TryFromUrl(blob.RequestBaseUrl, out var v))
{
- o["Link"] = linkGenerator.WalletTransactionsLink(new(webhookTriggerContext.Store.Id, evt.Data.CryptoCode), v);
+ var walletId = new WalletId(webhookTriggerContext.Store.Id, evt.Data.CryptoCode);
+ o["Link"] = evt.Type is PendingTransactionService.PendingTransactionEvent.Created or PendingTransactionService.PendingTransactionEvent.SignatureCollected
+ ? linkGenerator.WalletPendingTransactionLink(walletId, evt.Data.Id, v)
+ : linkGenerator.WalletTransactionsLink(walletId, v);
}
return model;
@@ -60,7 +64,9 @@ public class PendingTransactionTriggerProvider
_ => null
};
if (webhook is not null)
+ {
webhook.PendingTransactionId = evt.Data.TransactionId;
+ }
return webhook;
}
diff --git a/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs b/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
index 1b2ad08..17b2588 100644
--- a/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
+++ b/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
@@ -78,13 +78,13 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
services.AddWebhookTriggerProvider<PendingTransactionTriggerProvider>();
var pendingTransactionsPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
{
- new("{PendingTransaction.Id}", "The id of the pending transaction"),
- new("{PendingTransaction.TrimmedId}", "The trimmed id of the pending transaction"),
+ new("{PendingTransaction.Id}", "The id of the pending transaction (if available)"),
+ new("{PendingTransaction.TrimmedId}", "The trimmed id of the pending transaction (if available)"),
new("{PendingTransaction.StoreId}", "The store id of the pending transaction"),
new("{PendingTransaction.SignaturesCollected}", "The number of signatures collected"),
new("{PendingTransaction.SignaturesNeeded}", "The number of signatures needed"),
new("{PendingTransaction.SignaturesTotal}", "The total number of signatures"),
- new("{PendingTransaction.Link}", "The link to the wallet transaction list")
+ new("{PendingTransaction.Link}", "The link to the pending transaction or wallet transaction list")
};
var pendingTransactionTriggers = new List<EmailTriggerViewModel>()
diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs
index ca07e23..1cd01b0 100644
--- a/BTCPayServer/Services/Stores/StoreRepository.cs
+++ b/BTCPayServer/Services/Stores/StoreRepository.cs
@@ -1,7 +1,6 @@
#nullable enable
using System;
using System.Collections.Generic;
-using System.Data;
using System.Linq;
using System.Security.Principal;
using System.Threading.Tasks;
diff --git a/BTCPayServer/Views/UIStores/Dashboard.cshtml b/BTCPayServer/Views/UIStores/Dashboard.cshtml
index d74f717..6afe631 100644
--- a/BTCPayServer/Views/UIStores/Dashboard.cshtml
+++ b/BTCPayServer/Views/UIStores/Dashboard.cshtml
@@ -71,6 +71,7 @@
</div>
<vc:icon symbol="caret-right" />
</a>
+ <vc:ui-extension-point location="dashboard-setup-guide-wallet" model="@Model" />
<vc:ui-extension-point location="dashboard-setup-guide-payment" model="@Model" />
</div>
</div>
@@ -126,6 +127,7 @@ else
</div>
</div>
}
+ <vc:ui-extension-point location="dashboard-setup-guide-wallet" model="@Model" />
}
@if (Model.LightningSupported)
{
Why this scored 15/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.