Selenium tests for Multisig on server (#6487)

* Adding MultisigTests

* Adding fetching of receive address and creating pending transaction

* Completing multisig test flow

* Reverting Selenium ChromeDriver version

* Adding generation of PSBTs

* Removing unnecessary lines

* PSBT test signing now working with multisig dervation scheme

* Updating SignTestPSBT test

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

* Bugfixing PSBT problem

* Ensuring that PSBT signing also works for pending transactions

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

* Resolving bug in PendingTransactionService where Combine was modifying object

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

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

* Small nit, waiting loaded element

* Nit: Use AssetElementNotFound

* Fix warning

* Remove code dups

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
rockstardev 2025-02-25 00:39:57 -05:00 committed by GitHub
parent 9d5baabc2c
commit 8b5c5895f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 296 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -203,6 +203,7 @@ else
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" enctype="multipart/form-data" class="mb-2"> <form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" enctype="multipart/form-data" class="mb-2">
<input type="hidden" asp-for="ReturnUrl" /> <input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="BackUrl" /> <input type="hidden" asp-for="BackUrl" />
<partial name="SigningContext" for="SigningContext" />
<div class="form-group"> <div class="form-group">
<label for="ImportedPSBT" class="form-label" text-translate="true">PSBT content</label> <label for="ImportedPSBT" class="form-label" text-translate="true">PSBT content</label>
<textarea id="ImportedPSBT" name="PSBT" class="form-control" rows="5"></textarea> <textarea id="ImportedPSBT" name="PSBT" class="form-control" rows="5"></textarea>

View file

@ -174,38 +174,24 @@
<div class="table-responsive-md"> <div class="table-responsive-md">
<table class="table table-hover "> <table class="table table-hover ">
<thead> <thead>
<th> <th>Id</th>
Id <th>State</th>
</th> <th>Signature count</th>
<th> <th>Actions</th>
State
</th>
<th>
Signature count
</th>
<th>
Actions
</th>
</thead> </thead>
@foreach (var pendingTransaction in Model.PendingTransactions) @foreach (var pendingTransaction in Model.PendingTransactions)
{ {
<tr> <tr>
<td>@pendingTransaction.TransactionId</td>
<td>@pendingTransaction.State</td>
<td>@pendingTransaction.GetBlob().CollectedSignatures.Count</td>
<td> <td>
@pendingTransaction.TransactionId <a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId"
>@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")</a>
-
<a asp-action="CancelPendingTransaction"
asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">Abort</a>
</td> </td>
<td>
@pendingTransaction.State
</td>
<td>
@pendingTransaction.GetBlob().CollectedSignatures.Count
</td>
<td>
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">
@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")
</a>-
<a asp-action="CancelPendingTransaction"
asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">Abort</a>
</tr> </tr>
} }
</table> </table>