diff --git a/.vscode/launch.json b/.vscode/launch.json index 58374344..1c0e8945 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,7 +21,7 @@ }, "env": { "ASPNETCORE_ENVIRONMENT": "Development", - "POSTGRES_CONNECTIONSTRING": "Host=127.0.0.1;Port=5432;Database=nodeguard;Username=rw_dev;Password=rw_dev", + "POSTGRES_CONNECTIONSTRING": "Host=127.0.0.1;Port=25432;Database=nodeguard;Username=rw_dev;Password=rw_dev", "BITCOIN_NETWORK": "REGTEST", "MAXIMUM_WITHDRAWAL_BTC_AMOUNT": "21000000", "NBXPLORER_ENABLE_CUSTOM_BACKEND": "true", diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5bb4af40..da2acdfe 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.4' +version: "3.4" name: nodeguard services: @@ -22,7 +22,9 @@ services: platform: linux/amd64 hostname: nbxplorer ports: - - "32838:32838" + - "32838:32838" + depends_on: + - nbxplorer_postgres environment: NBXPLORER_NETWORK: regtest NBXPLORER_BIND: 0.0.0.0:32838 @@ -36,7 +38,7 @@ services: NBXPLORER_BTCNODEENDPOINT: host.docker.internal:19444 command: ["--noauth"] volumes: - - "bitcoin_datadir:/root/.bitcoin" + - "bitcoin_datadir:/root/.bitcoin" nbxplorer_postgres: container_name: nbxplorer_postgres @@ -51,12 +53,99 @@ services: - nbxplorer_postgres_data:/var/lib/postgresql/data ports: - 35432:5432 + healthcheck: + test: ["CMD", "pg_isready", "-U", "rw_dev"] + interval: 10s + timeout: 5s + retries: 5 + mempool-frontend-btc: + profiles: ["mempool"] + environment: + FRONTEND_HTTP_PORT: "8080" + BACKEND_MAINNET_HTTP_HOST: "mempool-backend-btc" + LIQUID_ENABLED: false + LIQUID_TESTNET_ENABLED: false + image: mempool/frontend:latest + container_name: 40swap_mempool_frontend_btc + user: "1000:1000" + restart: always + command: "./wait-for mempool-db-btc:3306 --timeout=720 -- nginx -g 'daemon off;'" + ports: + - 7084:8080 + + mempool-backend-btc: + profiles: ["mempool"] + environment: + MEMPOOL_BACKEND: "electrum" + CORE_RPC_HOST: "host.docker.internal" + CORE_RPC_PORT: "18443" + CORE_RPC_USERNAME: "polaruser" + CORE_RPC_PASSWORD: "polarpass" + DATABASE_ENABLED: "true" + DATABASE_HOST: "mempool-db-btc" + DATABASE_DATABASE: "mempool_btc" + DATABASE_USERNAME: "mempool" + DATABASE_PASSWORD: "mempool" + STATISTICS_ENABLED: "true" + ELECTRUM_HOST: "electrumx" + ELECTRUM_PORT: "50001" + ELECTRUM_TLS_ENABLED: "false" + image: mempool/backend:latest + container_name: 40swap_mempool_backend_btc + user: "1000:1000" + restart: always + command: "./wait-for-it.sh mempool-db-btc:3306 --timeout=720 --strict -- ./start.sh" + depends_on: + - mempool-db-btc + volumes: + - mempool-backend-btc-data:/backend/cache + mempool-db-btc: + profiles: ["mempool"] + environment: + MYSQL_DATABASE: "mempool_btc" + MYSQL_USER: "mempool" + MYSQL_PASSWORD: "mempool" + MYSQL_ROOT_PASSWORD: "admin" + image: mariadb:10.5.8 + container_name: 40swap_mempool_db_btc + restart: always + volumes: + - mempool-db-btc-data:/var/lib/mysql + + electrumx: + profiles: ["mempool"] + image: andgohq/electrumx:1.8.7 + container_name: 40swap_electrumx + command: ["wait-for-it.sh", "host.docker.internal:18443", "--", "init"] + ports: + - "51002:50002" + - "51001:50001" + expose: + - "50001" + - "50002" + volumes: + - electrumx-data:/data + environment: + # bitcoind is valid + - DAEMON_URL=http://polaruser:polarpass@host.docker.internal:18443 + - COIN=BitcoinSegwit + - NET=regtest + # 127.0.0.1 or electrumx is valid for RPC_HOST + - RPC_HOST=electrumx + - RPC_PORT=18443 + - HOST=electrumx + - TCP_PORT=50001 + - SSL_PORT=50002 + restart: always volumes: - nodeguard_postgres_data: - bitcoin_datadir: - nbxplorer_datadir: - nbxplorer_postgres_data: - nodeguard_data_keys_dir: + nodeguard_postgres_data: + bitcoin_datadir: + nbxplorer_datadir: + nbxplorer_postgres_data: + nodeguard_data_keys_dir: + mempool-backend-btc-data: + mempool-db-btc-data: + electrumx-data: diff --git a/src/Data/Models/WalletWithdrawalRequest.cs b/src/Data/Models/WalletWithdrawalRequest.cs index cc085511..0a72993c 100644 --- a/src/Data/Models/WalletWithdrawalRequest.cs +++ b/src/Data/Models/WalletWithdrawalRequest.cs @@ -63,7 +63,12 @@ public enum WalletWithdrawalRequestStatus /// /// The PSBT is being signed by NodeGuard after all human required signatures have been collected /// - FinalizingPSBT = 7 + FinalizingPSBT = 7, + + /// + /// The tx was bumped by Replace by Fee (RBF) method + /// + Bumped = 8 } /// @@ -189,6 +194,10 @@ public bool Equals(WalletWithdrawalRequest? other) public Wallet Wallet { get; set; } + public int? BumpingWalletWithdrawalRequestId { get; set; } + + public WalletWithdrawalRequest? BumpingWalletWithdrawalRequest { get; set; } + public List WalletWithdrawalRequestPSBTs { get; set; } public List UTXOs { get; set; } diff --git a/src/Data/Repositories/FUTXORepository.cs b/src/Data/Repositories/FUTXORepository.cs index 616c9a35..2220e56c 100644 --- a/src/Data/Repositories/FUTXORepository.cs +++ b/src/Data/Repositories/FUTXORepository.cs @@ -91,6 +91,21 @@ public async Task> GetAll() return _repository.Update(type, applicationDbContext); } + public async Task> GetLockedUTXOsByWithdrawalId(int walletWithdrawalRequestId) + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + var result = new List(); + result = await applicationDbContext.WalletWithdrawalRequests + .Include(x => x.UTXOs) + .Where(x => x.Id == walletWithdrawalRequestId) + .SelectMany(x => x.UTXOs) + .Include(x => x.WalletWithdrawalRequests) + .ToListAsync(); + + return result; + } + public async Task> GetLockedUTXOs(int? ignoredWalletWithdrawalRequestId = null, int? ignoredChannelOperationRequestId = null) { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/src/Data/Repositories/Interfaces/IFMUTXORepository.cs b/src/Data/Repositories/Interfaces/IFMUTXORepository.cs index bc2e45b3..40529b0b 100644 --- a/src/Data/Repositories/Interfaces/IFMUTXORepository.cs +++ b/src/Data/Repositories/Interfaces/IFMUTXORepository.cs @@ -43,4 +43,9 @@ public interface IFMUTXORepository /// Task> GetLockedUTXOs(int? ignoredWalletWithdrawalRequestId = null, int? ignoredChannelOperationRequestId = null); Task> GetLockedUTXOsByWalletId(int walletId); + /// + /// Gets the current list of UTXOs locked on WalletWithdrawalRequest by passing its id + /// + /// + Task> GetLockedUTXOsByWithdrawalId(int walletWithdrawalRequestId); } \ No newline at end of file diff --git a/src/Data/Repositories/WalletWithdrawalRequestRepository.cs b/src/Data/Repositories/WalletWithdrawalRequestRepository.cs index 56c83bb4..46fd702f 100644 --- a/src/Data/Repositories/WalletWithdrawalRequestRepository.cs +++ b/src/Data/Repositories/WalletWithdrawalRequestRepository.cs @@ -65,6 +65,7 @@ INBXplorerService nBXplorerService .Include(x => x.UserRequestor) .Include(x => x.WalletWithdrawalRequestPSBTs) .Include(x => x.WalletWithdrawalRequestDestinations) + .Include(x => x.UTXOs) .SingleOrDefaultAsync(x => x.Id == id); return request; @@ -94,6 +95,7 @@ public async Task> GetAll() .Include(x => x.UserRequestor) .Include(x => x.WalletWithdrawalRequestPSBTs) .Include(x => x.WalletWithdrawalRequestDestinations) + .Include(x => x.UTXOs) .AsSplitQuery() .ToListAsync(); } diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 05f6f2f4..0499b707 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -18,6 +18,7 @@ */ using System.Globalization; using System.Reflection; +using NBitcoin; using NodeGuard.Helpers; public class Constants @@ -75,6 +76,7 @@ public class Constants public static readonly long MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS = 25_000_000; //25M sats public static readonly string DEFAULT_DERIVATION_PATH = "48'/1'"; public static readonly int SESSION_TIMEOUT_MILLISECONDS = 3_600_000; + public static readonly Money BITCOIN_DUST = new Money(0.00000546m, MoneyUnit.BTC); // 546 satoshi in BTC //Sat/vb ratio public static decimal MIN_SAT_PER_VB_RATIO = 0.9m; @@ -100,7 +102,7 @@ public class Constants public static readonly string ALICE_PUBKEY = "02dc2ae598a02fc1e9709a23b68cd51d7fa14b1132295a4d75aa4f5acd23ee9527"; public static readonly string ALICE_HOST = "host.docker.internal:10001"; public static readonly string ALICE_MACAROON = "0201036c6e6402f801030a108cdfeb2614b8335c11aebb358f888d6d1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620c999e1a30842cbae3f79bd633b19d5ec0d2b6ebdc4880f6f5d5c230ce38f26ab"; - public static readonly string BOB_PUBKEY = "038644c6b13cdfc59bc97c2cc2b1418ced78f6d01da94f3bfd5fdf8b197335ea84"; + public static readonly string BOB_PUBKEY = "038644c6b13cdfc59bc97c2cc2b1418ced78f6d01da94f3bfd5fdf8b197335ea84"; public static readonly string BOB_HOST = "host.docker.internal:10002"; public static readonly string BOB_MACAROON = "0201036c6e6402f801030a10e0e89a68f9e2398228a995890637d2531201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620b85ae6b693338987cd65eda60a24573e962301b2a91d8f7c5625650d6368751f"; public static readonly string CAROL_PUBKEY = "03650f49929d84d9a6d9b5a66235c603a1a0597dd609f7cd3b15052382cf9bb1b4"; diff --git a/src/Jobs/PerformWithdrawalJob.cs b/src/Jobs/PerformWithdrawalJob.cs index 74c68839..ba016d3c 100644 --- a/src/Jobs/PerformWithdrawalJob.cs +++ b/src/Jobs/PerformWithdrawalJob.cs @@ -58,6 +58,29 @@ public async Task Execute(IJobExecutionContext context) catch (Exception e) { var request = await _walletWithdrawalRequestRepository.GetById(withdrawalRequestId); + + // If an error occurs and the wd was bumping another one, we need to update the status of the withdrawal request to previous status + if (request != null && request!.BumpingWalletWithdrawalRequestId.HasValue) + { + var bumped = await _walletWithdrawalRequestRepository.GetById(request.BumpingWalletWithdrawalRequestId.Value); + if (bumped == null) + { + _logger.LogError("Failed to find withdrawal request with ID {WithdrawalRequestId}", withdrawalRequestId); + return; + } + + bumped.Status = WalletWithdrawalRequestStatus.OnChainConfirmationPending; + var (ok, _) = _walletWithdrawalRequestRepository.Update(bumped); + if (!ok) + { + _logger.LogError("Failed to update withdrawal request status to OnChainConfirmationPending for ID {WithdrawalRequestId}", withdrawalRequestId); + } + else + { + _logger.LogInformation("Updated withdrawal request status to OnChainConfirmationPending for ID {WithdrawalRequestId}", withdrawalRequestId); + } + } + request!.Status = WalletWithdrawalRequestStatus.Failed; var (updated, _) = _walletWithdrawalRequestRepository.Update(request); if (!updated) diff --git a/src/Migrations/20230309121807_UpdateSourceDestChannelNodeIdFromRequests.cs b/src/Migrations/20230309121807_UpdateSourceDestChannelNodeIdFromRequests.cs index 3dedb3bd..2f84b515 100644 --- a/src/Migrations/20230309121807_UpdateSourceDestChannelNodeIdFromRequests.cs +++ b/src/Migrations/20230309121807_UpdateSourceDestChannelNodeIdFromRequests.cs @@ -36,7 +36,6 @@ protected override void Down(MigrationBuilder migrationBuilder) UPDATE public.""Channels"" SET ""SourceNodeId"" = 1, ""DestinationNodeId"" = 1; COMMIT;"; migrationBuilder.Sql(query); - } } } diff --git a/src/Migrations/20250611142943_MigrateLegacyWithdrawalData.cs b/src/Migrations/20250611142943_MigrateLegacyWithdrawalData.cs index 9346fd96..d23851f7 100644 --- a/src/Migrations/20250611142943_MigrateLegacyWithdrawalData.cs +++ b/src/Migrations/20250611142943_MigrateLegacyWithdrawalData.cs @@ -10,10 +10,8 @@ public partial class MigrateLegacyWithdrawalData : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - // Execute data migration in an explicit transaction + // Execute data migration migrationBuilder.Sql(@" - BEGIN; - -- Step 1: Migrate existing data from Amount and DestinationAddress to WalletWithdrawalRequestDestinations INSERT INTO ""WalletWithdrawalRequestDestinations"" (""Amount"", ""Address"", ""WalletWithdrawalRequestId"", ""CreationDatetime"", ""UpdateDatetime"") SELECT @@ -24,7 +22,7 @@ protected override void Up(MigrationBuilder migrationBuilder) ""UpdateDatetime"" FROM ""WalletWithdrawalRequests"" WHERE ""Amount"" > 0 AND ""DestinationAddress"" IS NOT NULL AND ""DestinationAddress"" != ''; - + -- Step 2: Verify migration was successful - if any records failed to migrate, rollback DO $$ DECLARE @@ -50,8 +48,6 @@ SELECT COUNT(*) INTO migrated_count -- Log success RAISE NOTICE 'Successfully migrated % legacy withdrawal records to destinations table', original_count; END $$; - - COMMIT; "); } diff --git a/src/Migrations/20250729130729_AddBumpingRequest.Designer.cs b/src/Migrations/20250729130729_AddBumpingRequest.Designer.cs new file mode 100644 index 00000000..f0262e6a --- /dev/null +++ b/src/Migrations/20250729130729_AddBumpingRequest.Designer.cs @@ -0,0 +1,1346 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodeGuard.Data; +using NodeGuard.Helpers; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250729130729_AddBumpingRequest")] + partial class AddBumpingRequest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator("Discriminator").HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("FeeRate") + .HasColumnType("numeric"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReturningFundsWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.HasIndex("ReturningFundsWalletId"); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "ReturningFundsWallet") + .WithMany() + .HasForeignKey("ReturningFundsWalletId"); + + b.Navigation("ReturningFundsWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20250729130729_AddBumpingRequest.cs b/src/Migrations/20250729130729_AddBumpingRequest.cs new file mode 100644 index 00000000..de78161e --- /dev/null +++ b/src/Migrations/20250729130729_AddBumpingRequest.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddBumpingRequest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BumpingWalletWithdrawalRequestId", + table: "WalletWithdrawalRequests", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_WalletWithdrawalRequests_BumpingWalletWithdrawalRequestId", + table: "WalletWithdrawalRequests", + column: "BumpingWalletWithdrawalRequestId"); + + migrationBuilder.AddForeignKey( + name: "FK_WalletWithdrawalRequests_WalletWithdrawalRequests_BumpingWa~", + table: "WalletWithdrawalRequests", + column: "BumpingWalletWithdrawalRequestId", + principalTable: "WalletWithdrawalRequests", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_WalletWithdrawalRequests_WalletWithdrawalRequests_BumpingWa~", + table: "WalletWithdrawalRequests"); + + migrationBuilder.DropIndex( + name: "IX_WalletWithdrawalRequests_BumpingWalletWithdrawalRequestId", + table: "WalletWithdrawalRequests"); + + migrationBuilder.DropColumn( + name: "BumpingWalletWithdrawalRequestId", + table: "WalletWithdrawalRequests"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index c2628e18..91e8fa82 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -36,7 +36,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UsersId"); - b.ToTable("ApplicationUserNode", (string)null); + b.ToTable("ApplicationUserNode"); }); modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => @@ -51,7 +51,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UtxosId"); - b.ToTable("ChannelOperationRequestFMUTXO", (string)null); + b.ToTable("ChannelOperationRequestFMUTXO"); }); modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => @@ -66,7 +66,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("WalletWithdrawalRequestsId"); - b.ToTable("FMUTXOWalletWithdrawalRequest", (string)null); + b.ToTable("FMUTXOWalletWithdrawalRequest"); }); modelBuilder.Entity("KeyWallet", b => @@ -81,7 +81,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("WalletsId"); - b.ToTable("KeyWallet", (string)null); + b.ToTable("KeyWallet"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => @@ -329,7 +329,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CreatorId"); - b.ToTable("ApiTokens", (string)null); + b.ToTable("ApiTokens"); }); modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => @@ -386,7 +386,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SourceNodeId"); - b.ToTable("Channels", (string)null); + b.ToTable("Channels"); }); modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => @@ -466,7 +466,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("WalletId"); - b.ToTable("ChannelOperationRequests", (string)null); + b.ToTable("ChannelOperationRequests"); }); modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => @@ -508,7 +508,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserSignerId"); - b.ToTable("ChannelOperationRequestPSBTs", (string)null); + b.ToTable("ChannelOperationRequestPSBTs"); }); modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => @@ -537,7 +537,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("FMUTXOs", (string)null); + b.ToTable("FMUTXOs"); }); modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => @@ -569,7 +569,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("InternalWallets", (string)null); + b.ToTable("InternalWallets"); }); modelBuilder.Entity("NodeGuard.Data.Models.Key", b => @@ -624,7 +624,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId"); - b.ToTable("Keys", (string)null); + b.ToTable("Keys"); }); modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => @@ -679,7 +679,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SwapWalletId"); - b.ToTable("LiquidityRules", (string)null); + b.ToTable("LiquidityRules"); }); modelBuilder.Entity("NodeGuard.Data.Models.Node", b => @@ -729,7 +729,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ReturningFundsWalletId"); - b.ToTable("Nodes", (string)null); + b.ToTable("Nodes"); }); modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => @@ -763,7 +763,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Key", "Outpoint") .IsUnique(); - b.ToTable("UTXOTags", (string)null); + b.ToTable("UTXOTags"); }); modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => @@ -836,7 +836,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") .IsUnique(); - b.ToTable("Wallets", (string)null); + b.ToTable("Wallets"); }); modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => @@ -847,8 +847,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("Amount") - .HasColumnType("numeric"); + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); b.Property("Changeless") .HasColumnType("boolean"); @@ -863,10 +863,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.Property("DestinationAddress") - .IsRequired() - .HasColumnType("text"); - b.Property("MempoolRecommendedFeesType") .HasColumnType("integer"); @@ -899,11 +895,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("BumpingWalletWithdrawalRequestId"); + b.HasIndex("UserRequestorId"); b.HasIndex("WalletId"); - b.ToTable("WalletWithdrawalRequests", (string)null); + b.ToTable("WalletWithdrawalRequests"); }); modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => @@ -934,7 +932,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("WalletWithdrawalRequestId"); - b.ToTable("WalletWithdrawalRequestDestinations", (string)null); + b.ToTable("WalletWithdrawalRequestDestinations"); }); modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => @@ -976,7 +974,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("WalletWithdrawalRequestId"); - b.ToTable("WalletWithdrawalRequestPSBTs", (string)null); + b.ToTable("WalletWithdrawalRequestPSBTs"); }); modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => @@ -1247,6 +1245,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") .WithMany("WalletWithdrawalRequests") .HasForeignKey("UserRequestorId"); @@ -1257,6 +1259,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("BumpingWalletWithdrawalRequest"); + b.Navigation("UserRequestor"); b.Navigation("Wallet"); diff --git a/src/Pages/ChannelRequests.razor b/src/Pages/ChannelRequests.razor index 46c60a88..624b7aeb 100644 --- a/src/Pages/ChannelRequests.razor +++ b/src/Pages/ChannelRequests.razor @@ -182,7 +182,7 @@ - @(context.MempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? $"{context.FeeRate} sats/vb" : _selectedMempoolRecommendedFeesType.ToString().Humanize()) + @(context.MempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? $"{context.FeeRate} sat/vb" : _selectedMempoolRecommendedFeesType.ToString().Humanize())
@@ -195,7 +195,7 @@ Custom - + @@ -318,7 +318,7 @@ - @(context.FeeRate != null ? $"{context.FeeRate} sats/vbyte" : _selectedMempoolRecommendedFeesType.ToString().Humanize()) + @(context.FeeRate != null ? $"{context.FeeRate} sat/vbyte" : _selectedMempoolRecommendedFeesType.ToString().Humanize()) diff --git a/src/Pages/Withdrawals.razor b/src/Pages/Withdrawals.razor index 8e01a68d..d9c4ca0d 100644 --- a/src/Pages/Withdrawals.razor +++ b/src/Pages/Withdrawals.razor @@ -201,7 +201,7 @@ - @(context.MempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? $"{context.CustomFeeRate} sats/vb" : context.MempoolRecommendedFeesType.Humanize()) + @(context.MempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? $"Custom fee ({context.CustomFeeRate} sat/vb)" : $"{context.MempoolRecommendedFeesType.Humanize()} ({context.CustomFeeRate} sat/vb)")
@@ -214,7 +214,7 @@ Custom - + @@ -346,7 +346,7 @@ - @(context.MempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? $"{context.CustomFeeRate} sats/vb" : context.MempoolRecommendedFeesType.Humanize()) + @(context.MempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? $"Custom fee ({context.CustomFeeRate} sat/vb)" : $"{context.MempoolRecommendedFeesType.Humanize()} ({context.CustomFeeRate} sat/vb)") @@ -386,6 +386,21 @@ } + + + @if (_isFinanceManager && context.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending) + { + + + + + Bump fee + + + } + + + @@ -416,6 +431,11 @@ TemplatePsbtString="@_templatePsbtString" ApproveRequestDelegate="async () => await ApproveRequestDelegate()"/> + + _selectedUTXOs = new(); private WalletWithdrawalRequest? _selectedRequest; private PSBTSign? _psbtSignRef; + private BumpfeeModal? _bumpfeeRef; private string _signedPSBT; private string? _templatePsbtString; private int? _selectedWalletId; @@ -513,6 +536,7 @@ public static readonly ColumnDefault CreationDate = new("Creation Date"); public static readonly ColumnDefault UpdateDate = new("Update Date"); public static readonly ColumnDefault Links = new("Links"); + public static readonly ColumnDefault Actions = new("Actions"); } protected override async Task OnInitializedAsync() @@ -756,6 +780,11 @@ //PSBT Generation try { + if (_selectedRequest.BumpingWalletWithdrawalRequestId != null) + { + await CopyBumpedRequestData(_selectedRequest); + } + var templatePSBT = await BitcoinService.GenerateTemplatePSBT(walletWithdrawalRequest); //TODO Save template PSBT (?) @@ -772,6 +801,50 @@ } } + private async Task ShowBumpfeeModal(WalletWithdrawalRequest walletWithdrawalRequest) + { + ulong confirmations = 0; + if (!string.IsNullOrEmpty(walletWithdrawalRequest.TxId)) + { + try + { + var nbxplorerStatus = await NBXplorerService.GetTransactionAsync(uint256.Parse(walletWithdrawalRequest.TxId)); + confirmations = (ulong)(nbxplorerStatus?.Confirmations ?? throw new Exception("Failed to get confirmations")); + } + catch (Exception) + { + ToastService.ShowError("Error getting confirmations"); + return; + } + } + + if (confirmations > 0) + { + ToastService.ShowError("Bumpfee can only be used for transactions with no confirmations. " + + "This transaction has already been mined."); + return; + } + + + // NOTE: Revisit this when full RBF is implemented because we could add a UTXO + if (walletWithdrawalRequest.Changeless) + { + ToastService.ShowError("Fee bumping is not supported for changeless transactions. Please create a new withdrawal with higher fees instead."); + return; + } + + try + { + _selectedRequest = walletWithdrawalRequest; + + if (_bumpfeeRef != null) await _bumpfeeRef.ShowModal(); + } + catch (Exception) + { + ToastService.ShowError("Error while creating bumpfee modal"); + } + } + private async Task Approve() { if (_selectedRequest == null || string.IsNullOrEmpty(_psbtSignRef?.SignedPSBT) || LoggedUser == null) @@ -843,6 +916,122 @@ } } + private async Task CreateNewWithdrawal(WalletWithdrawalRequest newRequest) + { + _selectedMempoolRecommendedFeesType = newRequest.MempoolRecommendedFeesType; + _customSatPerVbAmount = (long)newRequest.CustomFeeRate; + if (newRequest.Wallet.IsHotWallet) + { + await GetData(); + _selectedRequest = newRequest; + await _confirmationModal.ShowModal(); + return; + } + + newRequest.Wallet = null; + var addResult = await WalletWithdrawalRequestRepository.AddAsync(newRequest); + + + if (addResult.Item1) + { + ToastService.ShowSuccess("Success"); + await GetData(); + + if (_selectedUTXOs.Count > 0) + { + await CoinSelectionService.LockUTXOs(_selectedUTXOs, newRequest, BitcoinRequestType.WalletWithdrawal); + } + + _utxoSelectorModalRef.ClearModal(); + } + else + { + ToastService.ShowError("Something went wrong"); + _userPendingRequests.Remove(newRequest); + } + } + + private async Task Bumpfee(WalletWithdrawalRequest request) + { + try + { + await CreateNewWithdrawal(request); + } + catch + { + ToastService.ShowError("Could not bump the withdrawal"); + } + } + + private async void SubmitBumpfeeModal(WalletWithdrawalRequest newRequest) + { + if (newRequest != null && _bumpfeeRef != null) + { + newRequest.WalletId = _selectedRequest.WalletId; + newRequest.Wallet = await WalletRepository.GetById(newRequest.WalletId); + + // Use either the custom fee rate or the recommended fee rate based on mempool fee type + decimal newFeeRate; + if (newRequest.MempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee && newRequest.CustomFeeRate.HasValue) + { + newFeeRate = (decimal)newRequest.CustomFeeRate.Value; + } + else + { + // Use the recommended fee rate from NBXplorer service based on the selected mempool fee type + var recommendedFeeRate = await GetRecommendedFeeRate(newRequest.MempoolRecommendedFeesType); + newFeeRate = (decimal)recommendedFeeRate; + } + + // If the new fee plus the amount exceeds the utxo sum value, show an error + // NOTE: revisit this when full rbf because we could add a UTXO + var txInfo = await NBXplorerService.GetTransactionAsync(uint256.Parse(_selectedRequest.TxId)); + var tx = txInfo.Transaction; + long vSize = tx.GetVirtualSize(); + decimal newFee = (vSize * newFeeRate) / 100_000_000m; + + // Retrieve from database to get the UTXOs + _selectedRequest = await WalletWithdrawalRequestRepository.GetById(_selectedRequest.Id); + + if (_selectedRequest == null) + { + ToastService.ShowError("Withdrawal request not found"); + return; + } + + if (_selectedRequest.UTXOs != null && _selectedRequest.UTXOs.Count > 0) + { + var inputAmount = Money.Satoshis(_selectedRequest.UTXOs.Sum(x => x.SatsAmount)).ToDecimal(MoneyUnit.BTC); // Convert to BTC + if (inputAmount <= newFee + _selectedRequest.TotalAmount + Constants.BITCOIN_DUST.ToDecimal(MoneyUnit.BTC)) + { + ToastService.ShowError("The new fee plus the amount exceeds the sum of the selected UTXOs or returns dust. Please lower the fee rate."); + return; + } + } + + // If the request is changeless, show an error + // NOTE: Revisit this when full RBF is implemented because we could add a UTXO + if (newRequest.Changeless) + { + ToastService.ShowError("Fee bumping is not supported for changeless transactions. Please create a new withdrawal with higher fees instead."); + return; + } + + await Bumpfee(newRequest); + await _bumpfeeRef.HideModal(); + } + else + { + ToastService.ShowError("Something went wrong"); + } + } + + private async Task GetRecommendedFeeRate(MempoolRecommendedFeesType feeType) + { + return (long?)await NBXplorerService.GetFeesByType(feeType) ?? 1; + } + + private async Task ApproveRequestDelegate() { if (_selectedRequest != null) @@ -867,7 +1056,6 @@ ToastService.ShowError("Something went wrong"); } - await HideRejectCancelModal(); } } @@ -950,18 +1138,25 @@ if (_selectedRequest != null) { try - { + { _selectedRequest.Wallet = null; - _selectedRequest.Changeless = _selectedUTXOs.Count > 0; _selectedRequest.WithdrawAllFunds = _isCheckedAllFunds || _amount == _selectedRequestWalletBalance; _selectedRequest.MempoolRecommendedFeesType = _selectedMempoolRecommendedFeesType; _selectedRequest.CustomFeeRate = _customSatPerVbAmount; + var createWithdrawalResult = await WalletWithdrawalRequestRepository.AddAsync(_selectedRequest); if (!createWithdrawalResult.Item1) { throw new ShowToUserException(createWithdrawalResult.Item2); } + if (_selectedRequest.BumpingWalletWithdrawalRequestId != null) + { + await CopyBumpedRequestData(_selectedRequest); + } + + _selectedRequest.Changeless = _selectedUTXOs.Count > 0; + ToastService.ShowSuccess("Withdrawal request created!"); if (_selectedUTXOs.Count > 0) @@ -1007,14 +1202,36 @@ catch (NoUTXOsAvailableException e) { CleanUp("No UTXOs available for withdrawals were found for this wallet"); + await ResetStatusBumpedIfError(_selectedRequest); } catch (ShowToUserException e) { CleanUp(e.Message); + await ResetStatusBumpedIfError(_selectedRequest); } catch { CleanUp("Something went wrong"); + await ResetStatusBumpedIfError(_selectedRequest); + } + } + } + + private async Task ResetStatusBumpedIfError(WalletWithdrawalRequest request) + { + if (request.BumpingWalletWithdrawalRequestId != null) + { + var bumpedRequest = await WalletWithdrawalRequestRepository.GetById(request.BumpingWalletWithdrawalRequestId.Value); + + if (bumpedRequest != null) + { + bumpedRequest.Status = WalletWithdrawalRequestStatus.OnChainConfirmationPending; + var updateResult = WalletWithdrawalRequestRepository.Update(bumpedRequest); + if (!updateResult.Item1) + { + ToastService.ShowError("Error while updating the bumped request, please contact a superadmin for troubleshooting"); + return; + } } } } @@ -1048,6 +1265,27 @@ } } + private async Task ShowActionDropdown(WalletWithdrawalRequest walletWithdrawalRequest) + { + ulong confirmations = 0; + if (!string.IsNullOrEmpty(walletWithdrawalRequest.TxId)) + { + try + { + var nbxplorerStatus = await NBXplorerService.GetTransactionAsync(uint256.Parse(walletWithdrawalRequest.TxId)); + confirmations = (ulong)(nbxplorerStatus?.Confirmations ?? throw new Exception("Failed to get confirmations")); + return walletWithdrawalRequest.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending && confirmations.Equals((ulong) 0); + } + catch (Exception) + { + ToastService.ShowError("Error while showing bumpfee action"); + return false; + } + } + + return false; + } + private void OnColumnLayoutUpdate() { StateHasChanged(); @@ -1095,4 +1333,55 @@ return string.Empty; } + private async Task CopyBumpedRequestData(WalletWithdrawalRequest request) + { + WalletWithdrawalRequest originalRequest = await WalletWithdrawalRequestRepository.GetById((int)_selectedRequest.BumpingWalletWithdrawalRequestId); + if (originalRequest == null) + { + throw new ShowToUserException("Original withdrawal request not found"); + } + + // Get destinations from the original request + List originalDestinations = await WalletWithdrawalRequestDestinationRepository.GetByWalletWithdrawalRequestId(originalRequest.Id); + if (originalDestinations == null || originalDestinations.Count == 0) + { + throw new ShowToUserException("Original withdrawal request destinations not found"); + } + + _selectedRequest.WalletWithdrawalRequestDestinations = originalDestinations.Select(d => new WalletWithdrawalRequestDestination + { + Address = d.Address, + Amount = d.Amount, + WalletWithdrawalRequestId = _selectedRequest.Id + }).ToList(); + + // Get utxos from the original request + List mUTXOs = await FMUTXORepository.GetLockedUTXOsByWithdrawalId(originalRequest.Id); + + _selectedRequest.UTXOs = mUTXOs; + List outpoints = new List(); + mUTXOs.ForEach(u => { + OutPoint o; + if (OutPoint.TryParse($"{u.TxId}:{u.OutputIndex}", out o)) + outpoints.Add(o); + else + throw new FormatException("Failed to parse OutPoint from TxId and OutputIndex"); + }); + _selectedUTXOs = await CoinSelectionService.GetUTXOsByOutpointAsync(originalRequest.Wallet.GetDerivationStrategy(), outpoints); + originalRequest.Status = WalletWithdrawalRequestStatus.Bumped; + + var updateResult = WalletWithdrawalRequestRepository.Update(_selectedRequest); + if (!updateResult.Item1) + { + ToastService.ShowError("Something went wrong"); + return; + } + + updateResult = WalletWithdrawalRequestRepository.Update(originalRequest); + if (!updateResult.Item1) + { + ToastService.ShowError("Something went wrong"); + return; + } + } } diff --git a/src/Program.cs b/src/Program.cs index 5fb16f7b..051e1edc 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -110,6 +110,7 @@ public static void Main(string[] args) builder.Services .AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index a511f37e..1e7dd44c 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -385,6 +385,7 @@ enum WITHDRAWAL_REQUEST_STATUS { WITHDRAWAL_REJECTED = 3; WITHDRAWAL_PENDING_CONFIRMATION = 4; WITHDRAWAL_FAILED = 5; + WITHDRAWAL_BUMPED = 6; } message GetWithdrawalsRequestStatusRequest { diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index 0d5d8b6b..d554e8cd 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -1060,6 +1060,7 @@ private WITHDRAWAL_REQUEST_STATUS GetStatus(WalletWithdrawalRequestStatus status WalletWithdrawalRequestStatus.Pending => WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingApproval, WalletWithdrawalRequestStatus.PSBTSignaturesPending => WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingApproval, WalletWithdrawalRequestStatus.Rejected => WITHDRAWAL_REQUEST_STATUS.WithdrawalRejected, + WalletWithdrawalRequestStatus.Bumped => WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown status") }; } diff --git a/src/Services/BitcoinService.cs b/src/Services/BitcoinService.cs index 77cf0865..b94fb6fb 100644 --- a/src/Services/BitcoinService.cs +++ b/src/Services/BitcoinService.cs @@ -233,7 +233,8 @@ await _coinSelectionService.GetTxInputCoins(availableUTXOs, walletWithdrawalRequ builder.SetSigningOptions(SigHash.All) .SetChange(changeAddress.Address) - .SendEstimatedFees(feeRateResult); + .SendEstimatedFees(feeRateResult) + .SetOptInRBF(true); // We preserve the output order when testing so the psbt doesn't change var command = Assembly.GetEntryAssembly()?.GetName().Name?.ToLowerInvariant(); diff --git a/src/Shared/BumpfeeModal.razor b/src/Shared/BumpfeeModal.razor new file mode 100644 index 00000000..0e6dbcb5 --- /dev/null +++ b/src/Shared/BumpfeeModal.razor @@ -0,0 +1,176 @@ +@using NBitcoin +@using JSException = Microsoft.JSInterop.JSException + + @if (WithdrawalRequest != null) + { + + + Request fee bump + + + + + + New fee rate +
+ + + +
+ @(_selectedMempoolRecommendedFeesType != MempoolRecommendedFeesType.CustomFee ? "Fees may change by the time the request is first signed" : "") + Last fee used +
+ + + +
+ @(_selectedMempoolRecommendedFeesType != MempoolRecommendedFeesType.CustomFee ? "Fees must be greater than the current one" : "") + @if (_txVSize != 0) + { + @(_txVSize * _customSatPerVbAmount) sats will be approximately used as fees in the transaction. + And that's @(100 * (_txVSize * _customSatPerVbAmount) / _utxosInputSatsAmount)% of the input UTXOs + } + +
+
+
+ + + + +
+ } +
+ +@inject IBitcoinService BitcoinService +@inject IPriceConversionService PriceConversionService +@inject INBXplorerService NBXplorerService +@inject IWalletWithdrawalRequestRepository WalletWithdrawalRequestRepository +@inject IWalletRepository WalletRepository +@inject IFMUTXORepository FMUTXORepository +@code { + [Inject] + public IToastService ToastService { get; set; } + + [Parameter] + public ChannelOperationRequest? ChannelRequest { get; set; } + [Parameter] + public WalletWithdrawalRequest? WithdrawalRequest { get; set; } + + [Parameter] + public Action SubmitBumpfeeModal { get; set; } + + private decimal? _selectedWalletBalance; + private decimal _btcPrice; + + private MempoolRecommendedFeesType _selectedMempoolRecommendedFeesType; + private long _customSatPerVbAmount = 1; + + private Modal? _modalRef; + + private long _txVSize; + private long _utxosInputSatsAmount; + + protected override async Task OnParametersSetAsync() + { + if (WithdrawalRequest != null) + { + if (WithdrawalRequest.TxId != null) + { + var txInfo = await NBXplorerService.GetTransactionAsync(uint256.Parse(WithdrawalRequest.TxId)); + if (txInfo != null) + { + var tx = txInfo.Transaction; + _txVSize = tx.GetVirtualSize(); + } + } + + if (WithdrawalRequest.UTXOs != null) + _utxosInputSatsAmount = WithdrawalRequest.UTXOs.Sum(x => x.SatsAmount); + } + } + + private void HandleOnClick() + { + var oldFee = WithdrawalRequest?.CustomFeeRate ?? 0; + if (_customSatPerVbAmount <= oldFee) + { + ToastService.ShowError($"Fee must be greater than the current one ({oldFee} sat/vb)"); + } + else + { + var newRequest = new WalletWithdrawalRequest(); + newRequest.Description = WithdrawalRequest.Description; + newRequest.Changeless = WithdrawalRequest.Changeless; + newRequest.WithdrawAllFunds = WithdrawalRequest.WithdrawAllFunds; + newRequest.MempoolRecommendedFeesType = _selectedMempoolRecommendedFeesType; + newRequest.CustomFeeRate = _customSatPerVbAmount; + newRequest.UserRequestorId = WithdrawalRequest.UserRequestorId; + newRequest.BumpingWalletWithdrawalRequestId = WithdrawalRequest.Id; + SubmitBumpfeeModal?.Invoke(newRequest); + } + } + + public async Task ShowModal() + { + var walletId = WithdrawalRequest?.WalletId ?? -1; + if (walletId != -1) + { + var wallet = await WalletRepository.GetById(walletId); + var (balance, _) = await BitcoinService.GetWalletConfirmedBalance(wallet); + _selectedWalletBalance = balance; + } + _btcPrice = await PriceConversionService.GetBtcToUsdPrice(); + if (_modalRef != null) + await _modalRef.Show(); + } + + public async Task HideModal() + { + if (_modalRef != null) + await _modalRef.Close(CloseReason.UserClosing); + } + + private async Task OnMempoolFeeRateChange(MempoolRecommendedFeesType value) + { + _selectedMempoolRecommendedFeesType = value; + _customSatPerVbAmount = (long?)await NBXplorerService.GetFeesByType(_selectedMempoolRecommendedFeesType) ?? 1; + } +} diff --git a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs index 9a51af49..5e5ebebc 100644 --- a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs +++ b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs @@ -201,7 +201,7 @@ async Task GenerateTemplatePSBT_LegacyMultiSigSucceeds() var result = await bitcoinService.GenerateTemplatePSBT(withdrawalRequest); // Assert - var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiAYUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwMvESQsgAAAAfw77kI6AYzrbSJqBmMojtD7XuD6nXkKs3DQMOBHMObIA4COLhzUgr3QcZaUPFqBM9Fpr4YCK2uwOBdxZE7AdETXEB/M5N4wAACAAQAAgAEAAIBPAQQ1h88DVqwD9IAAAAH5CK5KZrD/oasUtVrwzkjypwIly5AQkC1pAa+QuT6PgQJRrxXgW7i36sGJWz9fR//v7NgyGgLvIimPidCiA33wYBBg86CzMAAAgAEAAIABAACATwEENYfPA325Ro2AAAAB9SJwx2h6Ovs1HvTxuaMMEPO205IXBoOuqUiME5oRyZgDIiOFIzjqZ/v9jcNSqyYl55ondkYhI2vxwCEwkNNInp8Q7QIQyDAAAIABAACAAQAAgAABASuAlpgAAAAAACIAILNTGKQyViCBs/y3kcG+Q/3NcIIypkqLb3/EMmN57BDEAQVpUiEC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEhAwJn/wsRl0hvcYj5Y3Bv3uQlxZ57pBZ9KSeuEPVNmjS/IQMaU3fyWsF+N0FpN8hSusDj6bESvd9YR509kdgWMLKLj1OuIgYC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEYH8zk3jAAAIABAACAAQAAgAAAAAAAAAAAIgYDAmf/CxGXSG9xiPljcG/e5CXFnnukFn0pJ64Q9U2aNL8YYPOgszAAAIABAACAAQAAgAAAAAAAAAAAIgYDGlN38lrBfjdBaTfIUrrA4+mxEr3fWEedPZHYFjCyi48Y7QIQyDAAAIABAACAAQAAgAAAAAAAAAAAAAAA", Network.RegTest); + var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD9////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiAYUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwMvESQsgAAAAfw77kI6AYzrbSJqBmMojtD7XuD6nXkKs3DQMOBHMObIA4COLhzUgr3QcZaUPFqBM9Fpr4YCK2uwOBdxZE7AdETXEB/M5N4wAACAAQAAgAEAAIBPAQQ1h88DVqwD9IAAAAH5CK5KZrD/oasUtVrwzkjypwIly5AQkC1pAa+QuT6PgQJRrxXgW7i36sGJWz9fR//v7NgyGgLvIimPidCiA33wYBBg86CzMAAAgAEAAIABAACATwEENYfPA325Ro2AAAAB9SJwx2h6Ovs1HvTxuaMMEPO205IXBoOuqUiME5oRyZgDIiOFIzjqZ/v9jcNSqyYl55ondkYhI2vxwCEwkNNInp8Q7QIQyDAAAIABAACAAQAAgAABASuAlpgAAAAAACIAILNTGKQyViCBs/y3kcG+Q/3NcIIypkqLb3/EMmN57BDEAQVpUiEC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEhAwJn/wsRl0hvcYj5Y3Bv3uQlxZ57pBZ9KSeuEPVNmjS/IQMaU3fyWsF+N0FpN8hSusDj6bESvd9YR509kdgWMLKLj1OuIgYC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEYH8zk3jAAAIABAACAAQAAgAAAAAAAAAAAIgYDAmf/CxGXSG9xiPljcG/e5CXFnnukFn0pJ64Q9U2aNL8YYPOgszAAAIABAACAAQAAgAAAAAAAAAAAIgYDGlN38lrBfjdBaTfIUrrA4+mxEr3fWEedPZHYFjCyi48Y7QIQyDAAAIABAACAAQAAgAAAAAAAAAAAAAAA", Network.RegTest); result.Should().BeEquivalentTo(psbt); } @@ -266,7 +266,7 @@ async Task GenerateTemplatePSBT_MultiSigSucceeds() } }); fmutxoRepository - .Setup(x => x.GetLockedUTXOs(null , null)) + .Setup(x => x.GetLockedUTXOs(null, null)) .ReturnsAsync(new List()); utxoTagRepository .Setup(x => x.GetByKeyValue(It.IsAny(), It.IsAny())) @@ -280,7 +280,7 @@ async Task GenerateTemplatePSBT_MultiSigSucceeds() var result = await bitcoinService.GenerateTemplatePSBT(withdrawalRequest); // Assert - var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiAYUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwMvESQsgAAAAfw77kI6AYzrbSJqBmMojtD7XuD6nXkKs3DQMOBHMObIA4COLhzUgr3QcZaUPFqBM9Fpr4YCK2uwOBdxZE7AdETXEB/M5N4wAACAAQAAgAEAAIBPAQQ1h88DVqwD9IAAAAH5CK5KZrD/oasUtVrwzkjypwIly5AQkC1pAa+QuT6PgQJRrxXgW7i36sGJWz9fR//v7NgyGgLvIimPidCiA33wYBBg86CzMAAAgAEAAIABAACATwEENYfPA325Ro0AAAAAgN63GqLxTu1/NyL0SV4a0Hn1n8Dzg+Wye9nbb16ZISADr+s+pcKnDcSqKHKWSl4v8Rcq80ZqG/7QObYmZUl/xUYQ7QIQyDAAAIABAACAAAAAAAABASuAlpgAAAAAACIAINCp0IUCw4KZ8J/JokbAV1TBQtK4m6WLzUomP5VBhszOAQVpUiEC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEhAwJn/wsRl0hvcYj5Y3Bv3uQlxZ57pBZ9KSeuEPVNmjS/IQNvzitZiz5ksZFSQuRibjPP4pwo+OWOqZLBL2x5ZrFVqVOuIgYC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEYH8zk3jAAAIABAACAAQAAgAAAAAAAAAAAIgYDAmf/CxGXSG9xiPljcG/e5CXFnnukFn0pJ64Q9U2aNL8YYPOgszAAAIABAACAAQAAgAAAAAAAAAAAIgYDb84rWYs+ZLGRUkLkYm4zz+KcKPjljqmSwS9seWaxVakY7QIQyDAAAIABAACAAAAAAAAAAAAAAAAAAAAA", Network.RegTest); + var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD9////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiAYUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwMvESQsgAAAAfw77kI6AYzrbSJqBmMojtD7XuD6nXkKs3DQMOBHMObIA4COLhzUgr3QcZaUPFqBM9Fpr4YCK2uwOBdxZE7AdETXEB/M5N4wAACAAQAAgAEAAIBPAQQ1h88DVqwD9IAAAAH5CK5KZrD/oasUtVrwzkjypwIly5AQkC1pAa+QuT6PgQJRrxXgW7i36sGJWz9fR//v7NgyGgLvIimPidCiA33wYBBg86CzMAAAgAEAAIABAACATwEENYfPA325Ro0AAAAAgN63GqLxTu1/NyL0SV4a0Hn1n8Dzg+Wye9nbb16ZISADr+s+pcKnDcSqKHKWSl4v8Rcq80ZqG/7QObYmZUl/xUYQ7QIQyDAAAIABAACAAAAAAAABASuAlpgAAAAAACIAINCp0IUCw4KZ8J/JokbAV1TBQtK4m6WLzUomP5VBhszOAQVpUiEC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEhAwJn/wsRl0hvcYj5Y3Bv3uQlxZ57pBZ9KSeuEPVNmjS/IQNvzitZiz5ksZFSQuRibjPP4pwo+OWOqZLBL2x5ZrFVqVOuIgYC2FTFYM/mwE4L60Q0G2p5QElV7YlMD7fcgoJEH79pLLEYH8zk3jAAAIABAACAAQAAgAAAAAAAAAAAIgYDAmf/CxGXSG9xiPljcG/e5CXFnnukFn0pJ64Q9U2aNL8YYPOgszAAAIABAACAAQAAgAAAAAAAAAAAIgYDb84rWYs+ZLGRUkLkYm4zz+KcKPjljqmSwS9seWaxVakY7QIQyDAAAIABAACAAAAAAAAAAAAAAAAAAAAA", Network.RegTest); result.Should().BeEquivalentTo(psbt); } @@ -358,10 +358,10 @@ async Task GenerateTemplatePSBT_SingleSigSucceeds() var result = await bitcoinService.GenerateTemplatePSBT(withdrawalRequest); // Assert - var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiCsUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwN9uUaNAAAAAYPR/OiA1LbTzxbLPvbXvtAwckIG3g+0T1zblR/ZodaiA5zBFsigPpL8htN/KJ/Ph8SPvQA/K+mSNXTSA0hgvPNuEO0CEMgwAACAAQAAgAEAAAAAAQEfgJaYAAAAAAAWABTpOvUBMqNMfl7P81etji6x4fXrMyIGA3uD9HVjgF5E+eQhHp+Na6femVYpc4bCA4DmimehAdWcGO0CEMgwAACAAQAAgAEAAAAAAAAAAAAAAAAAAA==", Network.RegTest); + var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD9////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiCsUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwN9uUaNAAAAAYPR/OiA1LbTzxbLPvbXvtAwckIG3g+0T1zblR/ZodaiA5zBFsigPpL8htN/KJ/Ph8SPvQA/K+mSNXTSA0hgvPNuEO0CEMgwAACAAQAAgAEAAAAAAQEfgJaYAAAAAAAWABTpOvUBMqNMfl7P81etji6x4fXrMyIGA3uD9HVjgF5E+eQhHp+Na6femVYpc4bCA4DmimehAdWcGO0CEMgwAACAAQAAgAEAAAAAAAAAAAAAAAAAAA==", Network.RegTest); result.Should().BeEquivalentTo(psbt); } - + [Fact] async Task GenerateTemplatePSBT_SingleSigFailsFrozenUTXO() { @@ -546,10 +546,10 @@ async Task GenerateTemplatePSBT_SingleSigSuccessManuallyUnfrozenUTXO() var result = await bitcoinService.GenerateTemplatePSBT(withdrawalRequest); // Assert - var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAdIEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAD/////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiCsUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwN9uUaNAAAAAYPR/OiA1LbTzxbLPvbXvtAwckIG3g+0T1zblR/ZodaiA5zBFsigPpL8htN/KJ/Ph8SPvQA/K+mSNXTSA0hgvPNuEO0CEMgwAACAAQAAgAEAAAAAAQEfgJaYAAAAAAAWABTpOvUBMqNMfl7P81etji6x4fXrMyIGA3uD9HVjgF5E+eQhHp+Na6femVYpc4bCA4DmimehAdWcGO0CEMgwAACAAQAAgAEAAAAAAAAAAAAAAAAAAA==", Network.RegTest); + var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAdIEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAD9////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiCsUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwN9uUaNAAAAAYPR/OiA1LbTzxbLPvbXvtAwckIG3g+0T1zblR/ZodaiA5zBFsigPpL8htN/KJ/Ph8SPvQA/K+mSNXTSA0hgvPNuEO0CEMgwAACAAQAAgAEAAAAAAQEfgJaYAAAAAAAWABTpOvUBMqNMfl7P81etji6x4fXrMyIGA3uD9HVjgF5E+eQhHp+Na6femVYpc4bCA4DmimehAdWcGO0CEMgwAACAAQAAgAEAAAAAAAAAAAAAAAAAAA==", Network.RegTest); result.Should().BeEquivalentTo(psbt); } - + [Fact] async Task GenerateTemplatePSBT_SingleSigFailsManuallyFrozenUTXO() { @@ -647,7 +647,7 @@ await act .ThrowAsync() .WithMessage("Exception of type 'NodeGuard.Helpers.NoUTXOsAvailableException' was thrown."); } - + [Fact] async Task GenerateTemplatePSBT_Changeless_SingleSigSucceeds() { @@ -716,7 +716,7 @@ async Task GenerateTemplatePSBT_Changeless_SingleSigSucceeds() } }); - var fmUtxos = utxos.Select(x => new FMUTXO() { TxId = x.Outpoint.Hash.ToString(), OutputIndex = 1}).ToList(); + var fmUtxos = utxos.Select(x => new FMUTXO() { TxId = x.Outpoint.Hash.ToString(), OutputIndex = 1 }).ToList(); fmutxoRepository .Setup(x => x.GetLockedUTXOs(null, null)) .ReturnsAsync(fmUtxos); @@ -733,7 +733,7 @@ async Task GenerateTemplatePSBT_Changeless_SingleSigSucceeds() var result = await bitcoinService.GenerateTemplatePSBT(withdrawalRequest); // Assert - var psbt = PSBT.Parse("cHNidP8BAF4BAAAAAdIKH+sAAAAAjKlUqwAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAD/////AZiUmAAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiAAAAAATwEENYfPA325Ro0AAAABg9H86IDUttPPFss+9te+0DByQgbeD7RPXNuVH9mh1qIDnMEWyKA+kvyG038on8+HxI+9AD8r6ZI1dNIDSGC8824Q7QIQyDAAAIABAACAAQAAAAABAR+AlpgAAAAAABYAFOk69QEyo0x+Xs/zV62OLrHh9eszIgYDe4P0dWOAXkT55CEen41rp96ZVilzhsIDgOaKZ6EB1ZwY7QIQyDAAAIABAACAAQAAAAAAAAAAAAAAAAA=", Network.RegTest); + var psbt = PSBT.Parse("cHNidP8BAF4BAAAAAdIKH+sAAAAAjKlUqwAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAD9////AZiUmAAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiAAAAAATwEENYfPA325Ro0AAAABg9H86IDUttPPFss+9te+0DByQgbeD7RPXNuVH9mh1qIDnMEWyKA+kvyG038on8+HxI+9AD8r6ZI1dNIDSGC8824Q7QIQyDAAAIABAACAAQAAAAABAR+AlpgAAAAAABYAFOk69QEyo0x+Xs/zV62OLrHh9eszIgYDe4P0dWOAXkT55CEen41rp96ZVilzhsIDgOaKZ6EB1ZwY7QIQyDAAAIABAACAAQAAAAAAAAAAAAAAAAA=", Network.RegTest); result.Should().BeEquivalentTo(psbt); } @@ -805,7 +805,7 @@ async Task PerformWithdrawal_SingleSigSucceeds() .ReturnsAsync(new BroadcastResult() { Success = true }); nodeRepository .Setup(x => x.GetAllManagedByNodeGuard(It.IsAny())) - .Returns(Task.FromResult(new List() {node})); + .Returns(Task.FromResult(new List() { node })); var bitcoinService = new BitcoinService(_logger, null, walletWithdrawalRequestRepository.Object, null, nodeRepository.Object, null, nbXplorerService.Object, null); // Act @@ -891,7 +891,7 @@ async Task PerformWithdrawal_MultiSigSucceeds() .ReturnsAsync(new BroadcastResult() { Success = true }); nodeRepository .Setup(x => x.GetAllManagedByNodeGuard(It.IsAny())) - .Returns(Task.FromResult(new List() {node})); + .Returns(Task.FromResult(new List() { node })); var bitcoinService = new BitcoinService(_logger, null, walletWithdrawalRequestRepository.Object, null, nodeRepository.Object, null, nbXplorerService.Object, null); // Act @@ -977,7 +977,7 @@ async Task PerformWithdrawal_LegacyMultiSigSucceeds() .ReturnsAsync(new BroadcastResult() { Success = true }); nodeRepository .Setup(x => x.GetAllManagedByNodeGuard(It.IsAny())) - .Returns(Task.FromResult(new List() {node})); + .Returns(Task.FromResult(new List() { node })); var bitcoinService = new BitcoinService(_logger, null, walletWithdrawalRequestRepository.Object, null, nodeRepository.Object, null, nbXplorerService.Object, null); // Act @@ -1072,17 +1072,17 @@ async Task GenerateTemplatePSBT_MultipleDestinations_SingleSigSucceeds() // Assert result.Should().NotBeNull(); - + // Verify that the PSBT has the expected number of outputs // 3 destination outputs + 1 change output = 4 total outputs result.Outputs.Count.Should().Be(4); - + // Verify destination amounts are present (order may vary due to shuffling) var outputValues = result.Outputs.Select(o => o.Value).ToList(); outputValues.Should().Contain(new Money(0.005m, MoneyUnit.BTC)); outputValues.Should().Contain(new Money(0.003m, MoneyUnit.BTC)); outputValues.Should().Contain(new Money(0.002m, MoneyUnit.BTC)); - + // Verify that there is a change output (should be greater than the destination amounts) var changeOutput = outputValues.Where(v => v > new Money(0.005m, MoneyUnit.BTC)).FirstOrDefault(); changeOutput.Should().NotBeNull(); @@ -1118,7 +1118,7 @@ async Task GenerateTemplatePSBT_WithdrawAllFunds_SingleSigSucceeds() var nbXplorerService = new Mock(); var utxoTagRepository = new Mock(); var mapper = new Mock(); - + walletWithdrawalRequestRepository .Setup((w) => w.GetById(It.IsAny())) .ReturnsAsync(withdrawalRequest); @@ -1211,7 +1211,7 @@ async Task GenerateTemplatePSBT_WithdrawAllFunds_MultipleDestinations_ShouldFail var nbXplorerService = new Mock(); var utxoTagRepository = new Mock(); var mapper = new Mock(); - + walletWithdrawalRequestRepository .Setup((w) => w.GetById(It.IsAny())) .ReturnsAsync(withdrawalRequest); @@ -1303,7 +1303,7 @@ async Task GenerateTemplatePSBT_Changeless_MultipleDestinations_ShouldFail() var nbXplorerService = new Mock(); var utxoTagRepository = new Mock(); var mapper = new Mock(); - + walletWithdrawalRequestRepository .Setup((w) => w.GetById(It.IsAny())) .ReturnsAsync(withdrawalRequest); @@ -1401,7 +1401,7 @@ async Task GenerateTemplatePSBT_ReuseTemplatePSBT_WhenUTXOsStillValid() var walletWithdrawalRequestRepository = new Mock(); var nbXplorerService = new Mock(); - + walletWithdrawalRequestRepository .Setup((w) => w.GetById(It.IsAny())) .ReturnsAsync(withdrawalRequest);