Compare commits
19 Commits
v2.0.0
...
v2.0.0-roc
Author | SHA1 | Date | |
---|---|---|---|
65bceb3ffb | |||
154d9fc9a3 | |||
151a5638f4 | |||
81500b68c3 | |||
4b75bc9bbd | |||
861a8a0450 | |||
b2b2300419 | |||
ebca562a7d | |||
beb3faa461 | |||
e177c8454e | |||
07a91c3ef1 | |||
b0b88c98da | |||
fca4832628 | |||
cccdc6f4fa | |||
693eceb80f | |||
7d8fc14159 | |||
4687bb95cb | |||
e3ec07da76 | |||
910801d305 |
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
74
BTCPayServer.Data/Data/PendingTransaction.cs
Normal file
74
BTCPayServer.Data/Data/PendingTransaction.cs
Normal 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; }
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
1663
BTCPayServer.Data/Migrations/20241029163147_AddingPendingTransactionsTable.Designer.cs
generated
Normal file
1663
BTCPayServer.Data/Migrations/20241029163147_AddingPendingTransactionsTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
@ -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"
|
||||
|
@ -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));
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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":
|
||||
|
@ -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(),
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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>());
|
||||
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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."
|
||||
};
|
||||
|
@ -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)
|
||||
});
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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/"
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
}
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user