From 8b82eb1cfdf0d8eba0ebe622be0989b0a8461dc2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 14:22:30 -0600 Subject: [PATCH 1/6] fix: checkBlockUTXO only checking the specific UTXO output --- lib/wallets/wallet/impl/particl_wallet.dart | 50 +++++++++++---------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index 6f2b9764e..6310bda55 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -73,34 +73,36 @@ class ParticlWallet String? blockedReason; String? utxoLabel; + // Only check the specific output this UTXO corresponds to, not all outputs. + final vout = jsonUTXO["tx_pos"] as int; final outputs = jsonTX["vout"] as List? ?? []; - for (final output in outputs) { - if (output is Map) { - if (output['ct_fee'] != null) { - // Blind output, ignore for now. - blocked = true; - blockedReason = "Blind output."; - utxoLabel = "Unsupported output type."; - } else if (output['rangeproof'] != null) { - // Private RingCT output, ignore for now. - blocked = true; - blockedReason = "Confidential output."; - utxoLabel = "Unsupported output type."; - } else if (output['data_hex'] != null) { - // Data output, ignore for now. + final output = outputs.cast?>().firstWhere( + (e) => e?["n"] == vout, + orElse: () => null, + ); + + if (output != null) { + if (output['ct_fee'] != null) { + blocked = true; + blockedReason = "Blind output."; + utxoLabel = "Unsupported output type."; + } else if (output['rangeproof'] != null) { + blocked = true; + blockedReason = "Confidential output."; + utxoLabel = "Unsupported output type."; + } else if (output['data_hex'] != null) { + blocked = true; + blockedReason = "Data output."; + utxoLabel = "Unsupported output type."; + } else if (output['scriptPubKey'] != null) { + if (output['scriptPubKey']?['asm'] is String && + (output['scriptPubKey']['asm'] as String).contains( + "OP_ISCOINSTAKE", + )) { blocked = true; - blockedReason = "Data output."; + blockedReason = "Spending staking"; utxoLabel = "Unsupported output type."; - } else if (output['scriptPubKey'] != null) { - if (output['scriptPubKey']?['asm'] is String && - (output['scriptPubKey']['asm'] as String).contains( - "OP_ISCOINSTAKE", - )) { - blocked = true; - blockedReason = "Spending staking"; - utxoLabel = "Unsupported output type."; - } } } } From 42e8cec00c0ee6b004f6f3e95709b5f218ef79a5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 14:22:50 -0600 Subject: [PATCH 2/6] fix: witness field parsing in particl updateTransactions --- lib/wallets/wallet/impl/particl_wallet.dart | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index 6310bda55..d1ed90c5d 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -239,17 +239,12 @@ class ParticlWallet addresses.addAll(prevOut.addresses); } - InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: map["scriptSig"]?["hex"] as String?, - scriptSigAsm: map["scriptSig"]?["asm"] as String?, - sequence: map["sequence"] as int?, + InputV2 input = InputV2.fromElectrumxJson( + json: map, outpoint: outpoint, - valueStringSats: valueStringSats, addresses: addresses, - witness: map["witness"] as String?, + valueStringSats: valueStringSats, coinbase: coinbase, - innerRedeemScriptAsm: map["innerRedeemscriptAsm"] as String?, - // Need addresses before we can know if the wallet owns this input. walletOwns: false, ); From 7cb913ccd4fe4de36eb3d28dbd4b8a160e0e5b70 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 14:23:26 -0600 Subject: [PATCH 3/6] fix: particl transactionVersion property to return 160 --- lib/wallets/crypto_currency/coins/particl.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/wallets/crypto_currency/coins/particl.dart b/lib/wallets/crypto_currency/coins/particl.dart index 2b07aad7e..a631e94e4 100644 --- a/lib/wallets/crypto_currency/coins/particl.dart +++ b/lib/wallets/crypto_currency/coins/particl.dart @@ -233,7 +233,7 @@ class Particl extends Bip39HDCurrency with ElectrumXCurrencyInterface { } @override - int get transactionVersion => 1; + int get transactionVersion => 160; @override BigInt get defaultFeeRate => BigInt.from(20000); From 933d26869de25174c585a8e3bac5cefd315a3725 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 14:24:31 -0600 Subject: [PATCH 4/6] fix: temp input script index in particl buildTransaction --- lib/wallets/wallet/impl/particl_wallet.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index d1ed90c5d..2d16f0b7c 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -451,7 +451,7 @@ class ParticlWallet tempInputs.add( InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: txb.inputs.first.script?.toHex, + scriptSigHex: txb.inputs[i].script?.toHex, scriptSigAsm: null, sequence: 0xffffffff - 1, outpoint: OutpointV2.isarCantDoRequiredInDefaultConstructor( From 0ecaaa3bced997c6a18417a2fc55b77e27c8b880 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 14:33:18 -0600 Subject: [PATCH 5/6] fix: show error screen instead of black screen when second instance launched --- lib/main.dart | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index ea6880af6..ee3308187 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -178,8 +178,31 @@ void main(List args) async { (await StackFileSystem.applicationHiveDirectory()).path, ); - await DB.instance.hive.openBox(DB.boxNameDBInfo); - await DB.instance.hive.openBox(DB.boxNamePrefs); + try { + await DB.instance.hive.openBox(DB.boxNameDBInfo); + await DB.instance.hive.openBox(DB.boxNamePrefs); + } on FileSystemException catch (e) { + if (e.osError?.errorCode == 11 || e.message.contains('lock failed')) { + // Another instance of the app already holds the Hive database lock. + // Show a simple error screen rather than crashing to a black screen. + runApp( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: Text( + '${AppConfig.appName} is already running.\n' + 'Close the other window and try again.', + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + return; + } + rethrow; + } await Prefs.instance.init(); await Logging.instance.initialize( From e919046ed3d598b78e4bc3b8743a449d151b9061 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 14:56:00 -0600 Subject: [PATCH 6/6] fix: show themed error screen when second instance tries to start --- lib/main.dart | 47 +++++-- lib/pages/already_running_view.dart | 191 ++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 lib/pages/already_running_view.dart diff --git a/lib/main.dart b/lib/main.dart index ee3308187..3b4083c45 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ import 'models/models.dart'; import 'models/node_model.dart'; import 'models/notification_model.dart'; import 'models/trade_wallet_lookup.dart'; +import 'pages/already_running_view.dart'; import 'pages/campfire_migrate_view.dart'; import 'pages/home_view/home_view.dart'; import 'pages/intro_view.dart'; @@ -183,22 +184,48 @@ void main(List args) async { await DB.instance.hive.openBox(DB.boxNamePrefs); } on FileSystemException catch (e) { if (e.osError?.errorCode == 11 || e.message.contains('lock failed')) { - // Another instance of the app already holds the Hive database lock. - // Show a simple error screen rather than crashing to a black screen. - runApp( - MaterialApp( + // Another instance already holds the Hive database lock. + // Try to bootstrap just enough of the theme system (Isar is independent + // of Hive) so the error screen looks like a real Stack Wallet screen. + Widget errorApp; + try { + await StackFileSystem.initThemesDir(); + await MainDB.instance.initMainDB(); + ThemeService.instance.init(MainDB.instance); + errorApp = const ProviderScope(child: AlreadyRunningApp()); + } catch (_) { + // Isar is also unavailable (e.g., another error). Fall back to a + // minimal but still Inter-font styled screen. + errorApp = MaterialApp( debugShowCheckedModeBanner: false, + theme: ThemeData(fontFamily: GoogleFonts.inter().fontFamily), home: Scaffold( body: Center( - child: Text( - '${AppConfig.appName} is already running.\n' - 'Close the other window and try again.', - textAlign: TextAlign.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'is already running.\n' + 'Close the other window and try again.', + textAlign: TextAlign.center, + style: GoogleFonts.inter(fontSize: 16), + ), + ], ), ), ), - ), - ); + ); + } + runApp(errorApp); return; } rethrow; diff --git a/lib/pages/already_running_view.dart b/lib/pages/already_running_view.dart new file mode 100644 index 000000000..1678276e5 --- /dev/null +++ b/lib/pages/already_running_view.dart @@ -0,0 +1,191 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../app_config.dart'; +import '../themes/stack_colors.dart'; +import '../themes/theme_providers.dart'; +import '../themes/theme_service.dart'; +import '../utilities/stack_file_system.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../widgets/app_icon.dart'; +import '../widgets/background.dart'; + +/// Root app widget for the "already running" error path. +/// +/// Mirrors the theme bootstrap performed by [MaterialAppWithTheme] in main.dart +/// but without touching Hive. Requires Isar + ThemeService to already be +/// initialized before [runApp] is called. +class AlreadyRunningApp extends ConsumerStatefulWidget { + const AlreadyRunningApp({super.key}); + + @override + ConsumerState createState() => _AlreadyRunningAppState(); +} + +class _AlreadyRunningAppState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(applicationThemesDirectoryPathProvider.notifier).state = + StackFileSystem.themesDir!.path; + // The first instance already verified/installed the light theme, so + // getTheme cannot return null here. + ref.read(themeProvider.state).state = ref + .read(pThemeService) + .getTheme(themeId: "light")!; + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = ref.watch(colorProvider.state).state; + return MaterialApp( + debugShowCheckedModeBanner: false, + title: AppConfig.appName, + theme: ThemeData( + extensions: [colorScheme], + fontFamily: GoogleFonts.inter().fontFamily, + splashColor: Colors.transparent, + ), + home: const AlreadyRunningView(), + ); + } +} + +/// Error screen shown when this is a second instance of the app. +/// +/// Mirrors [IntroView]'s layout: themed background, logo, app name heading, +/// short description subtitle, then the error message (in label style, smaller +/// than the subtitle) in place of the action buttons. +class AlreadyRunningView extends ConsumerWidget { + const AlreadyRunningView({super.key}); + + static const _errorMessage = + "${AppConfig.appName} is already running. " + "Close the other window and try again."; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + final stack = ref.watch( + themeProvider.select((value) => value.assets.stack), + ); + + return Background( + child: Scaffold( + backgroundColor: colors.background, + body: SafeArea( + child: Center( + child: !isDesktop + ? Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(flex: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: SizedBox( + width: 266, + height: 266, + child: stack.endsWith(".png") + ? Image.file(File(stack)) + : SvgPicture.file( + File(stack), + width: 266, + height: 266, + ), + ), + ), + ), + const Spacer(flex: 1), + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Text( + AppConfig.shortDescriptionText, + textAlign: TextAlign.center, + style: STextStyles.subtitle(context), + ), + ), + const Spacer(flex: 4), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + child: Text( + _errorMessage, + textAlign: TextAlign.center, + style: STextStyles.label(context), + ), + ), + ], + ) + : SizedBox( + width: 350, + height: 540, + child: Column( + children: [ + const Spacer(flex: 2), + const SizedBox( + width: 130, + height: 130, + child: AppIcon(), + ), + const Spacer(flex: 42), + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1( + context, + ).copyWith(fontSize: 40), + ), + const Spacer(flex: 24), + Text( + AppConfig.shortDescriptionText, + textAlign: TextAlign.center, + style: STextStyles.subtitle( + context, + ).copyWith(fontSize: 24), + ), + const Spacer(flex: 42), + Text( + _errorMessage, + textAlign: TextAlign.center, + style: STextStyles.label( + context, + ).copyWith(fontSize: 18), + ), + const Spacer(flex: 65), + ], + ), + ), + ), + ), + ), + ); + } +}