Compare commits

..

19 Commits

Author SHA1 Message Date
65bceb3ffb Updating state of pending transaction when broadcasted 2024-11-01 18:00:41 -05:00
154d9fc9a3 Redirecting to wallet after signature by pending transaction 2024-11-01 18:00:41 -05:00
151a5638f4 Adding non witness utxo needed for signing 2024-11-01 18:00:41 -05:00
81500b68c3 Passing the signing check if there are emultiple root fingerprints setup 2024-11-01 18:00:41 -05:00
4b75bc9bbd Reformatting code 2024-11-01 18:00:41 -05:00
861a8a0450 Code cleanup 2024-11-01 18:00:41 -05:00
b2b2300419 Debugging signing of multisig PSBT 2024-11-01 18:00:41 -05:00
ebca562a7d Allowing deletion of keypaths 2024-11-01 18:00:41 -05:00
beb3faa461 Switching to mainnet on local for debugging 2024-11-01 18:00:41 -05:00
e177c8454e Tweaking flow and display of transaction 2024-11-01 18:00:41 -05:00
07a91c3ef1 Generating migration for PendingTransaction database table 2024-11-01 18:00:41 -05:00
b0b88c98da More refactoring to reset database model 2024-11-01 18:00:41 -05:00
fca4832628 Reversing snapshot changes to properly generate migration 2024-11-01 18:00:41 -05:00
cccdc6f4fa Multisig 2024-11-01 18:00:41 -05:00
693eceb80f Reolve pull payment timezone (#6348) 2024-11-01 08:28:43 +09:00
7d8fc14159 fix: save proof blob if payout is in progress (#6343)
the payout cant be tracked later otherwise and will be marked as
cancelled
2024-11-01 08:24:21 +09:00
4687bb95cb Fix: Incorrect percentage accounting of raised money in crowdfunding (#6347) 2024-11-01 08:23:10 +09:00
e3ec07da76 Fix: Crowdfund page was crashing from 2.0.0 (#6342) (#6346) 2024-10-31 23:42:18 +09:00
910801d305 Replace font-awesome icon on Policies page 2024-10-31 12:23:30 +01:00
25 changed files with 2343 additions and 131 deletions

View File

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
@ -61,6 +63,7 @@ namespace BTCPayServer.Data
public DbSet<LightningAddressData> LightningAddresses { get; set; }
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
public DbSet<FormData> Forms { get; set; }
public DbSet<PendingTransaction> PendingTransactions { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
@ -106,7 +109,7 @@ namespace BTCPayServer.Data
WebhookData.OnModelCreating(builder, Database);
FormData.OnModelCreating(builder, Database);
StoreRole.OnModelCreating(builder, Database);
PendingTransaction.OnModelCreating(builder, Database);
}
}
}

View File

@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data;
public class PendingTransaction: IHasBlob<PendingTransactionBlob>
{
public string TransactionId { get; set; }
public string CryptoCode { get; set; }
public string StoreId { get; set; }
public StoreData Store { get; set; }
public DateTimeOffset? Expiry { get; set; }
public PendingTransactionState State { get; set; }
public string[] OutpointsUsed { get; set; }
[NotMapped][Obsolete("Use Blob2 instead")]
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<PendingTransaction>()
.HasOne(o => o.Store)
.WithMany(i => i.PendingTransactions)
.HasForeignKey(i => i.StoreId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<PendingTransaction>().HasKey(transaction => new {transaction.CryptoCode, transaction.TransactionId});
if (databaseFacade.IsNpgsql())
{
builder.Entity<PendingTransaction>()
.Property(o => o.Blob2)
.HasColumnType("JSONB");
builder.Entity<PendingTransaction>()
.Property(o => o.OutpointsUsed)
.HasColumnType("text[]");
}
else
{
builder.Entity<PendingTransaction>()
.Property(e => e.OutpointsUsed)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries));
}
}
}
public enum PendingTransactionState
{
Pending,
Cancelled,
Expired,
Invalidated,
Signed,
Broadcast
}
public class PendingTransactionBlob
{
public string PSBT { get; set; }
public List<CollectedSignature> CollectedSignatures { get; set; } = new();
}
public class CollectedSignature
{
public DateTimeOffset Timestamp { get; set; }
public string ReceivedPSBT { get; set; }
}

View File

@ -49,6 +49,7 @@ namespace BTCPayServer.Data
public IEnumerable<FormData> Forms { get; set; }
public IEnumerable<StoreRole> StoreRoles { get; set; }
public bool Archived { get; set; }
public IEnumerable<PendingTransaction> PendingTransactions { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
/// <inheritdoc />
public partial class AddingPendingTransactionsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PendingTransactions",
columns: table => new
{
TransactionId = table.Column<string>(type: "text", nullable: false),
CryptoCode = table.Column<string>(type: "text", nullable: false),
StoreId = table.Column<string>(type: "text", nullable: true),
Expiry = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
State = table.Column<int>(type: "integer", nullable: false),
OutpointsUsed = table.Column<string[]>(type: "text[]", nullable: true),
Blob2 = table.Column<string>(type: "JSONB", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PendingTransactions", x => new { x.CryptoCode, x.TransactionId });
table.ForeignKey(
name: "FK_PendingTransactions_Stores_StoreId",
column: x => x.StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PendingTransactions_StoreId",
table: "PendingTransactions",
column: "StoreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PendingTransactions");
}
}
}

View File

@ -637,6 +637,36 @@ namespace BTCPayServer.Migrations
b.ToTable("PayoutProcessors");
});
modelBuilder.Entity("BTCPayServer.Data.PendingTransaction", b =>
{
b.Property<string>("CryptoCode")
.HasColumnType("text");
b.Property<string>("TransactionId")
.HasColumnType("text");
b.Property<string>("Blob2")
.HasColumnType("JSONB");
b.Property<DateTimeOffset?>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<string[]>("OutpointsUsed")
.HasColumnType("text[]");
b.Property<int>("State")
.HasColumnType("integer");
b.Property<string>("StoreId")
.HasColumnType("text");
b.HasKey("CryptoCode", "TransactionId");
b.HasIndex("StoreId");
b.ToTable("PendingTransactions");
});
modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b =>
{
b.Property<string>("Id")
@ -1324,6 +1354,16 @@ namespace BTCPayServer.Migrations
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.PendingTransaction", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany("PendingTransactions")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
@ -1582,6 +1622,8 @@ namespace BTCPayServer.Migrations
b.Navigation("Payouts");
b.Navigation("PendingTransactions");
b.Navigation("PullPayments");
b.Navigation("Settings");

View File

@ -102,7 +102,7 @@ services:
expose:
- "32838"
environment:
NBXPLORER_NETWORK: regtest
NBXPLORER_NETWORK: mainnet
NBXPLORER_CHAINS: "btc"
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
@ -122,7 +122,7 @@ services:
restart: unless-stopped
image: btcpayserver/bitcoin:26.0
environment:
BITCOIN_NETWORK: regtest
BITCOIN_NETWORK: mainnet
BITCOIN_WALLETDIR: "/data/wallets"
BITCOIN_EXTRA_ARGS: |-
rpcuser=ceiwHEbqWI83
@ -136,6 +136,7 @@ services:
zmqpubrawtx=tcp://0.0.0.0:28333
deprecatedrpc=signrawtransaction
fallbackfee=0.0002
prune=50000
ports:
- "43782:43782"
- "39388:39388"

View File

@ -494,16 +494,14 @@ public partial class UIStoresController
for (int i = 0; i < derivation.AccountKeySettings.Length; i++)
{
KeyPath accountKeyPath;
HDFingerprint? rootFingerprint;
try
{
accountKeyPath = string.IsNullOrWhiteSpace(vm.AccountKeys[i].AccountKeyPath)
? null
: new KeyPath(vm.AccountKeys[i].AccountKeyPath);
var strKeyPath = vm.AccountKeys[i].AccountKeyPath;
var accountKeyPath = string.IsNullOrWhiteSpace(strKeyPath) ? null : new KeyPath(strKeyPath);
if (accountKeyPath != null && derivation.AccountKeySettings[i].AccountKeyPath != accountKeyPath)
bool pathsDiffer = accountKeyPath != derivation.AccountKeySettings[i].AccountKeyPath;
if (pathsDiffer)
{
needUpdate = true;
derivation.AccountKeySettings[i].AccountKeyPath = accountKeyPath;
@ -516,7 +514,7 @@ public partial class UIStoresController
try
{
rootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint)
HDFingerprint? rootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint)
? null
: new HDFingerprint(Encoders.Hex.DecodeData(vm.AccountKeys[i].MasterFingerprint));

View File

@ -12,12 +12,14 @@ using BTCPayServer.ModelBinders;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using Newtonsoft.Json.Linq;
using Org.BouncyCastle.Asn1;
namespace BTCPayServer.Controllers
{
@ -27,11 +29,14 @@ namespace BTCPayServer.Controllers
{
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly IAuthorizationService _authorizationService;
private readonly BTCPayWalletProvider _btcPayWalletProvider;
public UIVaultController(PaymentMethodHandlerDictionary handlers, IAuthorizationService authorizationService)
public UIVaultController(PaymentMethodHandlerDictionary handlers, IAuthorizationService authorizationService,
BTCPayWalletProvider btcPayWalletProvider)
{
_handlers = handlers;
_authorizationService = authorizationService;
_btcPayWalletProvider = btcPayWalletProvider;
}
[Route("{cryptoCode}/xpub")]
@ -142,12 +147,19 @@ namespace BTCPayServer.Controllers
var psbt = PSBT.Parse(o["psbt"].Value<string>(), network.NBitcoinNetwork);
var derivationSettings = GetDerivationSchemeSettings(walletId);
derivationSettings.RebaseKeyPaths(psbt);
var signing = derivationSettings.GetSigningAccountKeySettings();
if (signing.GetRootedKeyPath()?.MasterFingerprint != fingerprint)
// if we only have one root fingerprint setup, then check if it matches device
if (derivationSettings.AccountKeySettings.Count(a => a.RootFingerprint != null) <= 1)
{
await websocketHelper.Send("{ \"error\": \"wrong-wallet\"}", cancellationToken);
continue;
var signing = derivationSettings.GetSigningAccountKeySettings();
if (signing.GetRootedKeyPath()?.MasterFingerprint != fingerprint)
{
await websocketHelper.Send("{ \"error\": \"wrong-wallet\"}", cancellationToken);
continue;
}
}
// otherwise, let the device check if it can sign anything
var signableInputs = psbt.Inputs
.SelectMany(i => i.HDKeyPaths)
.Where(i => i.Value.MasterFingerprint == fingerprint)
@ -161,11 +173,21 @@ namespace BTCPayServer.Controllers
continue;
}
}
// we're adding all coins to the PSBT, in case non witness UTXOs are needed
var wallet = _btcPayWalletProvider.GetWallet(cryptoCode);
foreach (var input in psbt.Inputs)
{
var txid = input.PrevOut.Hash;
var tx = await wallet.GetTransactionAsync(txid, false, cancellationToken);
input.NonWitnessUtxo = tx?.Transaction;
}
try
{
psbt = await device.SignPSBTAsync(psbt, cancellationToken);
}
catch (Hwi.HwiException)
catch (Hwi.HwiException hwiex)
{
await websocketHelper.Send("{ \"error\": \"user-reject\"}", cancellationToken);
continue;

View File

@ -251,6 +251,9 @@ namespace BTCPayServer.Controllers
}
switch (command)
{
case "createpending":
var pt = await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
case "sign":
return await WalletSign(walletId, vm);
case "decode":
@ -289,7 +292,7 @@ namespace BTCPayServer.Controllers
});
case "broadcast":
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
return await RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
{
SigningContext = new SigningContextModel(psbt),
ReturnUrl = vm.ReturnUrl,
@ -605,6 +608,12 @@ namespace BTCPayServer.Controllers
{
return LocalRedirect(vm.ReturnUrl);
}
if (vm.SigningContext.PendingTransactionId is not null)
{
await _pendingTransactionService.Broadcasted(walletId.CryptoCode, walletId.StoreId,
vm.SigningContext.PendingTransactionId);
}
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
case "analyze-psbt":

View File

@ -33,7 +33,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using NBitcoin;
@ -77,9 +79,12 @@ namespace BTCPayServer.Controllers
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly WalletHistogramService _walletHistogramService;
private readonly PendingTransactionService _pendingTransactionService;
readonly CurrencyNameTable _currencyTable;
public UIWalletsController(StoreRepository repo,
public UIWalletsController(
PendingTransactionService pendingTransactionService,
StoreRepository repo,
WalletRepository walletRepository,
CurrencyNameTable currencyTable,
BTCPayNetworkProvider networkProvider,
@ -104,6 +109,7 @@ namespace BTCPayServer.Controllers
IStringLocalizer stringLocalizer,
TransactionLinkProviders transactionLinkProviders)
{
_pendingTransactionService = pendingTransactionService;
_currencyTable = currencyTable;
_labelService = labelService;
_defaultRules = defaultRules;
@ -130,6 +136,67 @@ namespace BTCPayServer.Controllers
StringLocalizer = stringLocalizer;
}
[HttpGet("{walletId}/pending/{transactionId}/cancel")]
public IActionResult CancelPendingTransaction(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string transactionId)
{
return View("Confirm", new ConfirmModel("Abort Pending Transaction",
"Proceeding with this action will invalidate Pending Transaction and all accepted signatures.",
"Confirm Abort"));
}
[HttpPost("{walletId}/pending/{transactionId}/cancel")]
public async Task<IActionResult> CancelPendingTransactionConfirmed(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string transactionId)
{
await _pendingTransactionService.CancelPendingTransaction(walletId.CryptoCode, walletId.StoreId, transactionId);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = $"Aborted Pending Transaction {transactionId}"
});
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
[HttpGet("{walletId}/pending/{transactionId}")]
public async Task<IActionResult> ViewPendingTransaction(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string transactionId)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var pendingTransaction =
await _pendingTransactionService.GetPendingTransaction(walletId.CryptoCode, walletId.StoreId,
transactionId);
if (pendingTransaction is null)
return NotFound();
var blob = pendingTransaction.GetBlob();
var currentPsbt = PSBT.Parse(blob.PSBT, network.NBitcoinNetwork);
foreach (CollectedSignature collectedSignature in blob.CollectedSignatures)
{
var psbt = PSBT.Parse(collectedSignature.ReceivedPSBT, network.NBitcoinNetwork);
currentPsbt = currentPsbt.Combine(psbt);
}
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
var vm = new WalletPSBTViewModel()
{
CryptoCode = network.CryptoCode,
SigningContext = new SigningContextModel(currentPsbt)
{
PendingTransactionId = transactionId, PSBT = currentPsbt.ToBase64(),
},
};
await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network);
await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
return View("WalletPSBTDecoded", vm);
}
[HttpPost]
[Route("{walletId}")]
public async Task<IActionResult> ModifyTransaction(
@ -950,10 +1017,10 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{walletId}/vault")]
public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
public async Task<IActionResult> WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
WalletSendVaultModel model)
{
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
return await RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
{
SigningContext = model.SigningContext,
ReturnUrl = model.ReturnUrl,
@ -961,8 +1028,18 @@ namespace BTCPayServer.Controllers
});
}
private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm)
private async Task<IActionResult> RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm)
{
if (vm.SigningContext.PendingTransactionId is not null)
{
var walletId = WalletId.Parse(this.RouteData.Values["walletId"].ToString());
var psbt = PSBT.Parse(vm.SigningContext.PSBT, NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode).NBitcoinNetwork);
var pendingTransaction = await _pendingTransactionService.CollectSignature(walletId.CryptoCode, psbt, false, CancellationToken.None);
if (pendingTransaction != null)
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
var redirectVm = new PostRedirectViewModel
{
AspController = "UIWallets",
@ -1004,6 +1081,7 @@ namespace BTCPayServer.Controllers
redirectVm.FormParameters.Add("SigningContext.EnforceLowR",
signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture));
redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress);
redirectVm.FormParameters.Add("SigningContext.PendingTransactionId", signingContext.PendingTransactionId);
}
private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm)
@ -1120,7 +1198,7 @@ namespace BTCPayServer.Controllers
ModelState.Remove(nameof(viewModel.SigningContext.PSBT));
viewModel.SigningContext ??= new();
viewModel.SigningContext.PSBT = psbt?.ToBase64();
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
return await RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
{
SigningKey = signingKey.GetWif(network.NBitcoinNetwork).ToString(),
SigningKeyPath = rootedKeyPath?.ToString(),

View File

@ -33,7 +33,6 @@ namespace BTCPayServer
public DerivationSchemeSettings()
{
}
public DerivationSchemeSettings(DerivationStrategyBase derivationStrategy, BTCPayNetwork network)
@ -48,16 +47,16 @@ namespace BTCPayServer
}
BitcoinExtPubKey _SigningKey;
private BitcoinExtPubKey _signingKey;
public BitcoinExtPubKey SigningKey
{
get
{
return _SigningKey ?? AccountKeySettings?.Select(k => k.AccountKey).FirstOrDefault();
return _signingKey ?? AccountKeySettings?.Select(k => k.AccountKey).FirstOrDefault();
}
set
{
_SigningKey = value;
_signingKey = value;
}
}
public string Source { get; set; }
@ -84,11 +83,7 @@ namespace BTCPayServer
return AccountKeySettings.Single(a => a.AccountKey == SigningKey);
}
public AccountKeySettings[] AccountKeySettings
{
get;
set;
}
public AccountKeySettings[] AccountKeySettings { get; set; }
public IEnumerable<NBXplorer.Models.PSBTRebaseKeyRules> GetPSBTRebaseKeyRules()
{
@ -114,7 +109,7 @@ namespace BTCPayServer
public string ToPrettyString()
{
return !string.IsNullOrEmpty(Label) ? Label :
!String.IsNullOrEmpty(AccountOriginal) ? AccountOriginal :
!string.IsNullOrEmpty(AccountOriginal) ? AccountOriginal :
ToString();
}

View File

@ -351,6 +351,8 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<StoreRepository>();
services.TryAddSingleton<PaymentRequestRepository>();
services.TryAddSingleton<BTCPayWalletProvider>();
services.AddSingleton<PendingTransactionService>();
services.AddSingleton<IHostedService>(provider => provider.GetService<PendingTransactionService>());
services.TryAddSingleton<WalletReceiveService>();
services.AddSingleton<IHostedService>(provider => provider.GetService<WalletReceiveService>());

View File

@ -17,5 +17,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string PayJoinBIP21 { get; set; }
public bool? EnforceLowR { get; set; }
public string ChangeAddress { get; set; }
public string PendingTransactionId { get; set; }
}
}

View File

@ -311,11 +311,13 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
}
else
{
// Payment will be saved as pending, the LightningPendingPayoutListener will handle settling/cancelling
payoutData.State = PayoutState.InProgress;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Unknown,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = "The payment has been initiated but is still in-flight."
};

View File

@ -228,8 +228,7 @@ namespace BTCPayServer.Plugins.Crowdfund
ProgressPercentage = (currentPayments.TotalCurrency / settings.TargetAmount) * 100,
PendingProgressPercentage = (pendingPayments.TotalCurrency / settings.TargetAmount) * 100,
LastUpdated = DateTime.UtcNow,
PaymentStats = GetPaymentStats(currentPayments),
PendingPaymentStats = GetPaymentStats(pendingPayments),
PaymentStats = GetPaymentStats(currentPayments, pendingPayments),
LastResetDate = lastResetDate,
NextResetDate = nextResetDate,
CurrentPendingAmount = pendingPayments.TotalCurrency,
@ -244,17 +243,21 @@ namespace BTCPayServer.Plugins.Crowdfund
return vm;
}
private Dictionary<string, PaymentStat> GetPaymentStats(InvoiceStatistics stats)
private Dictionary<string, PaymentStat> GetPaymentStats(InvoiceStatistics stats, InvoiceStatistics pendingSats)
{
var r = new Dictionary<string, PaymentStat>();
var total = stats.Select(s => s.Value.CurrencyValue).Sum();
foreach (var kv in stats)
var allStats = stats.Concat(pendingSats);
var total = allStats
.Select(s => s.Value.CurrencyValue).Sum();
foreach (var kv in allStats
.GroupBy(k => k.Key, k => k.Value)
.Select(g => (g.Key, CurrencyValue: g.Sum(s => s.CurrencyValue))))
{
var pmi = PaymentMethodId.Parse(kv.Key);
r.TryAdd(kv.Key, new PaymentStat()
{
Label = _prettyNameProvider.PrettyName(pmi),
Percent = (kv.Value.CurrencyValue / total) * 100.0m,
Percent = (kv.CurrencyValue / total) * 100.0m,
// Note that the LNURL will have the same LN
IsLightning = pmi == PaymentTypes.LN.GetPaymentMethodId(kv.Key)
});

View File

@ -56,7 +56,6 @@ namespace BTCPayServer.Plugins.Crowdfund.Models
public decimal? PendingProgressPercentage { get; set; }
public DateTime LastUpdated { get; set; }
public Dictionary<string, PaymentStat> PaymentStats { get; set; }
public Dictionary<string, PaymentStat> PendingPaymentStats { get; set; }
public DateTime? LastResetDate { get; set; }
public DateTime? NextResetDate { get; set; }
}

View File

@ -37,8 +37,8 @@
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_NETWORK": "mainnet",
"BTCPAY_LAUNCHSETTINGS": "false",
"BTCPAY_PORT": "14142",
"BTCPAY_HttpsUseDefaultCertificate": "true",
"BTCPAY_VERBOSE": "true",
@ -65,7 +65,7 @@
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "",
"BTCPAY_CHEATMODE": "true",
"BTCPAY_CHEATMODE": "false",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer"
},
"applicationUrl": "https://localhost:14142/"

View File

@ -1,9 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBitcoin;
namespace BTCPayServer.Services.Wallets
{
@ -64,4 +72,224 @@ namespace BTCPayServer.Services.Wallets
yield return w.Value;
}
}
public class PendingTransactionService:EventHostedServiceBase
{
private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly ExplorerClientProvider _explorerClientProvider;
public PendingTransactionService(
DelayedTransactionBroadcaster broadcaster,
BTCPayNetworkProvider networkProvider,
ApplicationDbContextFactory dbContextFactory,
EventAggregator eventAggregator,
ILogger<PendingTransactionService> logger,
ExplorerClientProvider explorerClientProvider ) : base(eventAggregator, logger)
{
_broadcaster = broadcaster;
_networkProvider = networkProvider;
_dbContextFactory = dbContextFactory;
_explorerClientProvider = explorerClientProvider;
}
protected override void SubscribeToEvents()
{
Subscribe<NewOnChainTransactionEvent>();
base.SubscribeToEvents();
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
await base.StartAsync(cancellationToken);
_ = CheckForExpiry(CancellationToken);
}
private async Task CheckForExpiry(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await using var ctx = _dbContextFactory.CreateContext();
var pendingTransactions = await ctx.PendingTransactions
.Where(p => p.Expiry <= DateTimeOffset.UtcNow && p.State == PendingTransactionState.Pending)
.ToArrayAsync(cancellationToken: cancellationToken);
foreach (var pendingTransaction in pendingTransactions)
{
pendingTransaction.State = PendingTransactionState.Expired;
}
await ctx.SaveChangesAsync(cancellationToken);
await Task.Delay(TimeSpan.FromMinutes(10), cancellationToken);
}
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is NewOnChainTransactionEvent newTransactionEvent)
{
await using var ctx = _dbContextFactory.CreateContext();
var txInputs = newTransactionEvent.NewTransactionEvent.TransactionData.Transaction.Inputs.Select(i => i.PrevOut.ToString()).ToArray();
var txHash = newTransactionEvent.NewTransactionEvent.TransactionData.TransactionHash.ToString();
var pendingTransactions = await ctx.PendingTransactions.Where(p => p.TransactionId == txHash || p.OutpointsUsed.Any(o => txInputs.Contains(o))).ToArrayAsync(cancellationToken: cancellationToken);
if (!pendingTransactions.Any())
{
return;
}
foreach (var pendingTransaction in pendingTransactions)
{
if(pendingTransaction.TransactionId == txHash)
{
pendingTransaction.State = PendingTransactionState.Broadcast;
continue;
}
if(pendingTransaction.OutpointsUsed.Any(o => txInputs.Contains(o)))
{
pendingTransaction.State = PendingTransactionState.Invalidated;
}
}
await ctx.SaveChangesAsync(cancellationToken);
}
await base.ProcessEvent(evt, cancellationToken);
}
public async Task<PendingTransaction> CreatePendingTransaction(string storeId, string cryptoCode, PSBT psbt, DateTimeOffset? expiry = null, CancellationToken cancellationToken = default)
{
var network = _networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network is null)
{
throw new NotSupportedException("CryptoCode not supported");
}
var txId = psbt.GetGlobalTransaction().GetHash();
await using var ctx = _dbContextFactory.CreateContext();
var pendingTransaction = new PendingTransaction
{
CryptoCode = cryptoCode,
TransactionId = txId.ToString(),
State = PendingTransactionState.Pending,
OutpointsUsed = psbt.Inputs.Select(i => i.PrevOut.ToString()).ToArray(),
Expiry = expiry,
StoreId = storeId,
};
pendingTransaction.SetBlob(new PendingTransactionBlob
{
PSBT = psbt.ToBase64()
});
ctx.PendingTransactions.Add(pendingTransaction);
await ctx.SaveChangesAsync(cancellationToken);
return pendingTransaction;
}
public async Task<PendingTransaction?> CollectSignature(string cryptoCode, PSBT psbt, bool broadcastIfComplete, CancellationToken cancellationToken)
{
var network = _networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network is null)
{
return null;
}
var txId = psbt.GetGlobalTransaction().GetHash();
await using var ctx = _dbContextFactory.CreateContext();
var pendingTransaction = await ctx.PendingTransactions.FindAsync( new object[]{ cryptoCode, txId.ToString()}, cancellationToken);
if (pendingTransaction is null)
{
return null;
}
if(pendingTransaction.State != PendingTransactionState.Pending)
{
return null;
}
var blob = pendingTransaction.GetBlob();
var originalPsbtWorkingCopy = PSBT.Parse(blob.PSBT, network.NBitcoinNetwork);
foreach (var collectedSignature in blob.CollectedSignatures)
{
var collectedPsbt = PSBT.Parse(collectedSignature.ReceivedPSBT, network.NBitcoinNetwork);
originalPsbtWorkingCopy = originalPsbtWorkingCopy.Combine(collectedPsbt);
}
var originalPsbtWorkingCopyWithNewPsbt = originalPsbtWorkingCopy.Combine(psbt);
//check if we have more signatures than before
if (originalPsbtWorkingCopyWithNewPsbt.Inputs.All(i => i.PartialSigs.Count >= originalPsbtWorkingCopy.Inputs[(int) i.Index].PartialSigs.Count))
{
blob.CollectedSignatures.Add(new CollectedSignature
{
ReceivedPSBT = psbt.ToBase64(),
Timestamp = DateTimeOffset.UtcNow
});
pendingTransaction.SetBlob(blob);
}
if (originalPsbtWorkingCopyWithNewPsbt.TryFinalize(out _))
{
pendingTransaction.State = PendingTransactionState.Signed;
}
await ctx.SaveChangesAsync(cancellationToken);
if (broadcastIfComplete && pendingTransaction.State == PendingTransactionState.Signed)
{
var explorerClient = _explorerClientProvider.GetExplorerClient(network);
var tx = originalPsbtWorkingCopyWithNewPsbt.ExtractTransaction();
var result = await explorerClient.BroadcastAsync(tx, cancellationToken);
if(result.Success)
{
pendingTransaction.State = PendingTransactionState.Broadcast;
await ctx.SaveChangesAsync(cancellationToken);
}
else
{
await _broadcaster.Schedule(DateTimeOffset.Now,tx, network);
}
}
return pendingTransaction;
}
public async Task<PendingTransaction?> GetPendingTransaction(string cryptoCode, string storeId, string txId)
{
await using var ctx = _dbContextFactory.CreateContext();
return await ctx.PendingTransactions.FirstOrDefaultAsync(p => p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == txId);
}
public async Task<PendingTransaction[]> GetPendingTransactions(string cryptoCode, string storeId)
{
await using var ctx = _dbContextFactory.CreateContext();
return await ctx.PendingTransactions.Where(p => p.CryptoCode == cryptoCode && p.StoreId == storeId && (p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed)).ToArrayAsync();
}
public async Task CancelPendingTransaction(string cryptoCode, string storeId, string transactionId)
{
await using var ctx = _dbContextFactory.CreateContext();
var pt = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == transactionId &&
(p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed));
if (pt is null)
return;
pt.State = PendingTransactionState.Cancelled;
await ctx.SaveChangesAsync();
}
public async Task Broadcasted(string cryptoCode, string storeId, string transactionId)
{
await using var ctx = _dbContextFactory.CreateContext();
var pt = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == transactionId &&
(p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed));
if (pt is null)
return;
pt.State = PendingTransactionState.Broadcast;
await ctx.SaveChangesAsync();
}
}
}

View File

@ -97,11 +97,11 @@
}
<div class="d-flex align-items-center gap-2">
<span class="text-muted text-nowrap" text-translate="true">Start Date</span>
<span class="text-nowrap">@Model.StartDate.ToString("g")</span>
<span class="text-nowrap">@Model.StartDate.ToBrowserDate()</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted text-nowrap" text-translate="true">Last Updated</span>
<span class="text-nowrap">@Model.LastRefreshed.ToString("g")</span>
<span class="text-nowrap">@Model.LastRefreshed.ToBrowserDate()</span>
</div>
<div class="d-flex align-items-center only-for-js gap-3 my-3">
<button type="button" class="btn btn-link fw-semibold d-print-none p-0" id="copyLink" text-translate="true">

View File

@ -224,7 +224,7 @@
<div class="d-flex align-items-center justify-content-between">
<label asp-for="DomainToAppMapping[index].Domain" class="form-label"></label>
<button type="submit" title="@StringLocalizer["Remove domain mapping"]" name="command" value="@($"remove-domain:{index}")" class="d-inline-block ms-2 btn text-danger btn-link p-0 mb-2">
<span class="fa fa-times"></span>
<vc:icon symbol="cross"/>
<span text-translate="true">Remove Mapping</span>
</button>
</div>

View File

@ -7,4 +7,5 @@
<input type="hidden" asp-for="PayJoinBIP21" value="@Model.PayJoinBIP21"/>
<input type="hidden" asp-for="EnforceLowR" value="@Model.EnforceLowR" />
<input type="hidden" asp-for="ChangeAddress" value="@Model.ChangeAddress" />
<input type="hidden" asp-for="PendingTransactionId" value="@Model.PendingTransactionId" />
}

View File

@ -2,20 +2,20 @@
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model WalletPSBTViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
var isReady = !Model.HasErrors;
var isSignable = !isReady;
var needsExport = !isSignable && !isReady;
Layout = "_LayoutWizard";
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new {walletId});
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
var isReady = !Model.HasErrors;
var isSignable = !isReady;
var needsExport = !isSignable && !isReady;
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.PSBT, isReady ? StringLocalizer["Confirm broadcasting this transaction"] : StringLocalizer["Transaction Details"], walletId);
Csp.UnsafeEval();
Csp.UnsafeEval();
}
@section PageHeadContent {
<link rel="stylesheet" href="~/vendor/highlightjs/default.min.css" asp-append-version="true">
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true" />
<style>
.nav-pills .nav-link.active {
color: var(--btcpay-secondary-text-active);
@ -80,11 +80,11 @@
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="close" />
</a>
}
@ -98,17 +98,29 @@
@if (isSignable)
{
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="my-5">
<input type="hidden" asp-for="CryptoCode"/>
<input type="hidden" asp-for="NBXSeedAvailable"/>
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="FileName"/>
<input type="hidden" asp-for="CryptoCode" />
<input type="hidden" asp-for="NBXSeedAvailable" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="FileName" />
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="BackUrl" />
<div class="d-flex flex-column flex-sm-row flex-wrap justify-content-center align-items-sm-center">
<partial name="SigningContext" for="SigningContext" />
<div class="d-flex flex-column flex-sm-row flex-wrap justify-content-center align-items-sm-center gap-2">
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary" text-translate="true">Sign transaction</button>
@if (Model.SigningContext.PendingTransactionId is null && !Model.NBXSeedAvailable)
{
<button type="submit" id="CreatePendingTransaction" name="command" value="createpending"
class="btn btn-primary">Create pending transaction</button>
}
else if (Model.SigningContext.PendingTransactionId is not null)
{
<a asp-action="CancelPendingTransaction" asp-route-walletId="@walletId"
asp-route-transactionId="@Model.SigningContext.PendingTransactionId" class="btn btn-danger">Cancel</a>
}
</div>
</form>
}
}
else if (isReady)
{
<form method="post" asp-action="WalletPSBTReady" asp-route-walletId="@walletId" class="my-5">
@ -141,14 +153,14 @@ else
<h2 class="accordion-header" id="PSBTOptionsExportHeader">
<button type="button" class="accordion-button @(needsExport ? "" : "collapsed")" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsExportContent" aria-controls="PSBTOptionsExportContent" aria-expanded="@(needsExport ? "true" : "false")">
<span class="h5">Export PSBT @(isReady ? "" : "for signing")</span>
<vc:icon symbol="caret-down"/>
<vc:icon symbol="caret-down" />
</button>
</h2>
<div id="PSBTOptionsExportContent" class="accordion-collapse collapse @(needsExport ? "show" : "")" aria-labelledby="PSBTOptionsExportHeader" data-bs-parent="#PSBTOptions">
<div class="accordion-body">
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="mb-2">
<input type="hidden" asp-for="CryptoCode"/>
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="CryptoCode" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="BackUrl" />
<div class="d-flex flex-column flex-sm-row flex-wrap align-items-sm-center">
@ -192,7 +204,7 @@ else
<h2 class="accordion-header" id="PSBTOptionsImportHeader">
<button type="button" class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsImportContent" aria-controls="PSBTOptionsImportContent" aria-expanded="false">
<span class="h5" text-translate="true">Provide updated PSBT</span>
<vc:icon symbol="caret-down"/>
<vc:icon symbol="caret-down" />
</button>
</h2>
<div id="PSBTOptionsImportContent" class="accordion-collapse collapse" aria-labelledby="PSBTOptionsImportHeader" data-bs-parent="#PSBTOptions">
@ -221,13 +233,13 @@ else
<h2 class="accordion-header" id="PSBTOptionsAdvancedHeader">
<button type="button" class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsAdvancedContent" aria-controls="PSBTOptionsAdvancedContent" aria-expanded="false">
<span class="h5">Add metadata to PSBT (advanced)</span>
<vc:icon symbol="caret-down"/>
<vc:icon symbol="caret-down" />
</button>
</h2>
<div id="PSBTOptionsAdvancedContent" class="accordion-collapse collapse" aria-labelledby="PSBTOptionsAdvancedHeader" data-bs-parent="#PSBTOptions">
<div class="accordion-body">
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="mb-2">
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="BackUrl" />
<p class="mb-2">For exporting the signed PSBT and transaction information to a wallet, update the PSBT.</p>
@ -240,5 +252,5 @@ else
</div>
</div>
<partial name="ShowQR"/>
<partial name="CameraScanner"/>
<partial name="ShowQR" />
<partial name="CameraScanner" />

View File

@ -4,6 +4,9 @@
@using BTCPayServer.Components.WalletNav
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Html
@using BTCPayServer.Services.Wallets
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model ListTransactionsViewModel
@ -74,7 +77,7 @@
const count = @Safe.Json(Model.Count);
const skipInitial = @Safe.Json(Model.Skip);
const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new { walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true }));
const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true}));
// The next time we load transactions, skip will become 0
let skip = @Safe.Json(Model.Skip) - count;
@ -173,57 +176,106 @@
</div>
<div style="clear:both"></div>
@inject PendingTransactionService PendingTransactionService
@{
var wId = WalletId.Parse(walletId);
var pendingTransactions = await PendingTransactionService.GetPendingTransactions(wId.CryptoCode, wId.StoreId);
}
@if (pendingTransactions.Any())
{
<div class="table-responsive-md">
<table class="table table-hover ">
<thead>
<th>
Id
</th>
<th>
State
</th>
<th>
Signature count
</th>
<th>
Actions
</th>
</thead>
@foreach (var pendingTransaction in pendingTransactions)
{
<tr>
<td>
@pendingTransaction.TransactionId
</td>
<td>
@pendingTransaction.State
</td>
<td>
@pendingTransaction.GetBlob().CollectedSignatures.Count
</td>
<td>
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">
@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")
</a>-
<a asp-action="CancelPendingTransaction"
asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">Abort</a>
</tr>
}
</table>
</div>
}
<div id="WalletTransactions" class="table-responsive-md">
<table class="table table-hover mass-action">
<thead class="mass-action-head">
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th class="date-col">
<div class="d-flex align-items-center gap-1">
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th class="date-col">
<div class="d-flex align-items-center gap-1">
<span text-translate="true">Date</span>
<button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
<vc:icon symbol="time" />
</button>
</div>
</th>
</div>
</th>
<th text-translate="true" class="text-start">Label</th>
<th text-translate="true">Transaction</th>
<th text-translate="true" class="amount-col">Amount</th>
<th></th>
</tr>
<th></th>
</tr>
</thead>
<thead class="mass-action-actions">
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th colspan="5">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<strong class="mass-action-selected-count">0</strong>
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th colspan="5">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<strong class="mass-action-selected-count">0</strong>
<span text-translate="true">selected</span>
</div>
<form id="WalletActions" method="post" asp-action="WalletActions" asp-route-walletId="@walletId" permission="@Policies.CanModifyStoreSettings" class="d-inline-flex align-items-center gap-3">
<button id="BumpFee" name="command" type="submit" value="cpfp" class="btn btn-link">
</div>
<form id="WalletActions" method="post" asp-action="WalletActions" asp-route-walletId="@walletId" permission="@Policies.CanModifyStoreSettings" class="d-inline-flex align-items-center gap-3">
<button id="BumpFee" name="command" type="submit" value="cpfp" class="btn btn-link">
<vc:icon symbol="actions-send" />
<span text-translate="true">Bump fee</span>
</button>
</form>
</div>
</th>
</tr>
</button>
</form>
</div>
</th>
</tr>
</thead>
<tbody id="WalletTransactionsList">
<partial name="_WalletTransactionsList" model="Model" />
<partial name="_WalletTransactionsList" model="Model" />
</tbody>
</table>
</div>
<noscript>
<vc:pager view-model="Model"/>
<vc:pager view-model="Model" />
</noscript>
<div class="text-center only-for-js d-none" id="LoadingIndicator">
<div class="spinner-border spinner-border-sm text-secondary ms-2" role="status">
<span class="visually-hidden" text-translate="true">Loading...</span>

View File

@ -111,37 +111,12 @@ app = new Vue({
return this.srvModel.targetCurrency.toUpperCase();
},
paymentStats: function(){
var result= [];
var combinedStats = {};
var keys = Object.keys(this.srvModel.info.paymentStats);
var result = [];
for (var i = 0; i < keys.length; i++) {
if(combinedStats[keys[i]]){
combinedStats[keys[i]] +=this.srvModel.info.paymentStats[keys[i]];
}else{
combinedStats[keys[i]] =this.srvModel.info.paymentStats[keys[i]];
}
}
keys = Object.keys(this.srvModel.info.pendingPaymentStats);
for (var i = 0; i < keys.length; i++) {
if(combinedStats[keys[i]]){
combinedStats[keys[i]] +=this.srvModel.info.pendingPaymentStats[keys[i]];
}else{
combinedStats[keys[i]] =this.srvModel.info.pendingPaymentStats[keys[i]];
}
}
keys = Object.keys(combinedStats);
for (var i = 0; i < keys.length; i++) {
if(!combinedStats[keys[i]]){
continue;
}
var value = combinedStats[keys[i]].percent.toFixed(2) + '%';
var newItem = {key:keys[i], value: value, label: combinedStats[keys[i]].label};
newItem.lightning = combinedStats[keys[i]].isLightning;
var value = this.srvModel.info.paymentStats[keys[i]].percent.toFixed(2) + '%';
var newItem = { key: keys[i], value: value, label: this.srvModel.info.paymentStats[keys[i]].label};
newItem.lightning = this.srvModel.info.paymentStats[keys[i]].isLightning;
result.push(newItem);
}