What changed, and why it matters
This commit is a collection of small fixes in the Stack Wallet app. The most notable changes correct how Firo masternode registration transactions are signed and built, fix a bug where the app might pick the same address for both collateral and owner, and improve how the app detects and navigates after a masternode transaction. There are also UI layout tweaks and a change to the Campfire app configuration so it uses the main Firo network instead of the test network. The commit message gives no indication these are security fixes, and the changes look like ordinary bug fixes rather than patches for an exploitable vulnerability.
Treat as a routine bug-fix/maintenance commit. Reviewers may want to verify the new Firo message-prefix helper behaves correctly for both mainnet and testnet message prefixes, and confirm the ProReg payload assert does not fire in production builds (asserts are typically disabled in release Flutter builds). No urgent security response is indicated.
Security signals we found
Firo masternode ProReg signature prefix handling changed to avoid incorrect magic-byte stripping
Owner/collateral address derivation hardened with retry loop and distinctness check
Post-transaction navigation guards against use of unmounted BuildContext
Spark cache clearing now updates metadata for all matching Firo wallets, not just the active one
Campfire app config switched from Firo testnet to mainnet
Evidence from the diff
The diff touches seven files. In firo_wallet.dart, the ProReg transaction builder now loops up to 32 times to derive an owner address distinct from the collateral address, removes a duplicate check, and uses a new helper (firo_pro_reg_signed_message_prefix.dart) to strip the Firo network magic byte 0x16 before passing the message prefix to coinlib’s MessageSignature.sign. It also adds an assert that the ProReg payload is present in the final signed transaction hex and fixes masternode ProTx hash resolution by tracking resolved collateral txids separately. confirm_transaction_view.dart refactors post-send navigation so the parent route is popped before showing the masternode creation dialog, using rootContext when available and guarding against unmounted contexts. masternodes_home_view.dart adjusts a null check and imports. desktop_menu.dart wraps menu items in Expanded/SingleChildScrollView. more_features_dialog.dart clears Spark cache metadata across all Firo wallets instead of only the current one. configure_campfire.sh switches the supported coin from Firo testnet to mainnet.
Changed components
lib/wallets/wallet/impl/firo_wallet.dartlib/utilities/firo_pro_reg_signed_message_prefix.dartlib/pages/send_view/confirm_transaction_view.dartlib/pages/masternodes/masternodes_home_view.dartlib/pages_desktop_specific/desktop_menu.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dartscripts/app_config/configure_campfire.shInspect captured patch +196 / −137
diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart
index 0aa7b00..2503c08 100644
--- a/lib/pages/masternodes/masternodes_home_view.dart
+++ b/lib/pages/masternodes/masternodes_home_view.dart
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
-import 'package:isar_community/isar.dart';
import '../../providers/global/wallets_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
@@ -11,6 +10,7 @@ import '../../utilities/assets.dart';
import '../../utilities/logger.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
+import '../../models/isar/models/blockchain_data/utxo.dart';
import '../../wallets/isar/models/wallet_info.dart';
import '../../wallets/wallet/impl/firo_wallet.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
@@ -36,8 +36,7 @@ class MasternodesHomeView extends ConsumerStatefulWidget {
_MasternodesHomeViewState();
}
-class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView>
-{
+class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView> {
late Future<List<MasternodeInfo>> _masternodesFuture;
bool _hasPromptedForCollateral = false;
bool _isCheckingForCollateral = false;
@@ -69,7 +68,9 @@ class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView>
async {
final wallet =
ref.read(pWallets).getWallet(widget.walletId) as FiroWallet;
- final utxos = await wallet.mainDB.getUTXOs(widget.walletId).findAll();
+ final List<UTXO> utxos =
+ await (wallet.mainDB.getUTXOs(widget.walletId) as dynamic).findAll()
+ as List<UTXO>;
final currentChainHeight = await wallet.chainHeight;
final masternodeRaw = Amount.fromDecimal(
kMasterNodeValue,
@@ -200,7 +201,7 @@ class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView>
),
);
- if (wantsMN == false) {
+ if (wantsMN == false || wantsMN == null) {
await _persistDismissedCollateral(
wallet,
collateral.txid,
@@ -282,9 +283,6 @@ class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView>
});
}
- @override
- void dispose() => super.dispose();
-
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart
index 699595f..39123a0 100644
--- a/lib/pages/send_view/confirm_transaction_view.dart
+++ b/lib/pages/send_view/confirm_transaction_view.dart
@@ -532,6 +532,7 @@ class _ConfirmTransactionViewState
final txFeeRaw = confirmedTx.fee?.raw ?? BigInt.zero;
final mnRecipient = confirmedTx.recipients!
+ // Exact 1000 FIRO: multiple such outputs uses the first match only.
.where((r) => !r.isChange && r.amount == masternodeAmount)
.firstOrNull;
@@ -569,14 +570,31 @@ class _ConfirmTransactionViewState
} else {
navigatedToMN = true;
final rootContext = ref.read(pNavKey).currentContext;
+
+ void completeMnParentNavigation() {
+ if (widget.onSuccessInsteadOfRouteOnSuccess == null) {
+ if (isDesktop) {
+ Navigator.of(context).popUntil(
+ ModalRoute.withName(routeOnSuccessName),
+ );
+ } else {
+ final navigator = Navigator.of(context);
+ navigator.popUntil(
+ ModalRoute.withName(routeOnSuccessName),
+ );
+ }
+ } else {
+ widget.onSuccessInsteadOfRouteOnSuccess!.call();
+ }
+ }
+
+ completeMnParentNavigation();
+
if (isDesktop) {
- Navigator.of(context).popUntil(
- ModalRoute.withName(routeOnSuccessName),
- );
- if (context.mounted) {
+ if (rootContext != null && rootContext.mounted) {
unawaited(
showDialog<Object>(
- context: context,
+ context: rootContext,
barrierDismissible: true,
builder: (_) => SDialog(
child: CreateMasternodeView(
@@ -594,12 +612,15 @@ class _ConfirmTransactionViewState
);
}
} else {
- final navigator = Navigator.of(context);
- navigator.popUntil(
- ModalRoute.withName(routeOnSuccessName),
- );
+ final navContext =
+ (rootContext != null && rootContext.mounted)
+ ? rootContext
+ : context;
+ if (!navContext.mounted) {
+ return;
+ }
unawaited(
- navigator.pushNamed(
+ Navigator.of(navContext).pushNamed(
CreateMasternodeView.routeName,
arguments: {
'walletId': walletId,
diff --git a/lib/pages_desktop_specific/desktop_menu.dart b/lib/pages_desktop_specific/desktop_menu.dart
index 4b95064..a4044a3 100644
--- a/lib/pages_desktop_specific/desktop_menu.dart
+++ b/lib/pages_desktop_specific/desktop_menu.dart
@@ -175,102 +175,110 @@ class _DesktopMenuState extends ConsumerState<DesktopMenu> {
? _width -
32 // 16 padding on either side
: _width - 16, // 8 padding on either side
- child: SingleChildScrollView(
- child: Column(
+ child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- DesktopMenuItem(
- key: const ValueKey('myStack'),
- duration: duration,
- icon: const DesktopMyStackIcon(),
- label: "My ${AppConfig.prefix}",
- value: DesktopMenuItemId.myStack,
- onChanged: updateSelectedMenuItem,
- controller: controllers[0],
- isExpandedInitially: !_isMinimized,
- ),
- if (AppConfig.hasFeature(AppFeature.swap) &&
- showExchange) ...[
- const SizedBox(height: 2),
- DesktopMenuItem(
- key: const ValueKey('swap'),
- duration: duration,
- icon: const DesktopExchangeIcon(),
- label: "Swap",
- value: DesktopMenuItemId.exchange,
- onChanged: updateSelectedMenuItem,
- controller: controllers[1],
- isExpandedInitially: !_isMinimized,
+ Expanded(
+ child: SingleChildScrollView(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ DesktopMenuItem(
+ key: const ValueKey('myStack'),
+ duration: duration,
+ icon: const DesktopMyStackIcon(),
+ label: "My ${AppConfig.prefix}",
+ value: DesktopMenuItemId.myStack,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[0],
+ isExpandedInitially: !_isMinimized,
+ ),
+ if (AppConfig.hasFeature(AppFeature.swap) &&
+ showExchange) ...[
+ const SizedBox(height: 2),
+ DesktopMenuItem(
+ key: const ValueKey('swap'),
+ duration: duration,
+ icon: const DesktopExchangeIcon(),
+ label: "Swap",
+ value: DesktopMenuItemId.exchange,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[1],
+ isExpandedInitially: !_isMinimized,
+ ),
+ ],
+ if (AppConfig.hasFeature(AppFeature.buy) &&
+ showExchange) ...[
+ const SizedBox(height: 2),
+ DesktopMenuItem(
+ key: const ValueKey('buy'),
+ duration: duration,
+ icon: const DesktopBuyIcon(),
+ label: "Buy crypto",
+ value: DesktopMenuItemId.buy,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[2],
+ isExpandedInitially: !_isMinimized,
+ ),
+ ],
+ const SizedBox(height: 2),
+ DesktopMenuItem(
+ key: const ValueKey('notifications'),
+ duration: duration,
+ icon: const DesktopNotificationsIcon(),
+ label: "Notifications",
+ value: DesktopMenuItemId.notifications,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[3],
+ isExpandedInitially: !_isMinimized,
+ ),
+ const SizedBox(height: 2),
+ DesktopMenuItem(
+ key: const ValueKey('addressBook'),
+ duration: duration,
+ icon: const DesktopAddressBookIcon(),
+ label: "Address Book",
+ value: DesktopMenuItemId.addressBook,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[4],
+ isExpandedInitially: !_isMinimized,
+ ),
+ const SizedBox(height: 2),
+ DesktopMenuItem(
+ key: const ValueKey('settings'),
+ duration: duration,
+ icon: const DesktopSettingsIcon(),
+ label: "Settings",
+ value: DesktopMenuItemId.settings,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[5],
+ isExpandedInitially: !_isMinimized,
+ ),
+ const SizedBox(height: 2),
+ DesktopMenuItem(
+ key: const ValueKey('support'),
+ duration: duration,
+ icon: const DesktopSupportIcon(),
+ label: "Support",
+ value: DesktopMenuItemId.support,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[6],
+ isExpandedInitially: !_isMinimized,
+ ),
+ const SizedBox(height: 2),
+ DesktopMenuItem(
+ key: const ValueKey('about'),
+ duration: duration,
+ icon: const DesktopAboutIcon(),
+ label: "About",
+ value: DesktopMenuItemId.about,
+ onChanged: updateSelectedMenuItem,
+ controller: controllers[7],
+ isExpandedInitially: !_isMinimized,
+ ),
+ ],
+ ),
),
- ],
- if (AppConfig.hasFeature(AppFeature.buy) &&
- showExchange) ...[
- const SizedBox(height: 2),
- DesktopMenuItem(
- key: const ValueKey('buy'),
- duration: duration,
- icon: const DesktopBuyIcon(),
- label: "Buy crypto",
- value: DesktopMenuItemId.buy,
- onChanged: updateSelectedMenuItem,
- controller: controllers[2],
- isExpandedInitially: !_isMinimized,
- ),
- ],
- const SizedBox(height: 2),
- DesktopMenuItem(
- key: const ValueKey('notifications'),
- duration: duration,
- icon: const DesktopNotificationsIcon(),
- label: "Notifications",
- value: DesktopMenuItemId.notifications,
- onChanged: updateSelectedMenuItem,
- controller: controllers[3],
- isExpandedInitially: !_isMinimized,
- ),
- const SizedBox(height: 2),
- DesktopMenuItem(
- key: const ValueKey('addressBook'),
- duration: duration,
- icon: const DesktopAddressBookIcon(),
- label: "Address Book",
- value: DesktopMenuItemId.addressBook,
- onChanged: updateSelectedMenuItem,
- controller: controllers[4],
- isExpandedInitially: !_isMinimized,
- ),
- const SizedBox(height: 2),
- DesktopMenuItem(
- key: const ValueKey('settings'),
- duration: duration,
- icon: const DesktopSettingsIcon(),
- label: "Settings",
- value: DesktopMenuItemId.settings,
- onChanged: updateSelectedMenuItem,
- controller: controllers[5],
- isExpandedInitially: !_isMinimized,
- ),
- const SizedBox(height: 2),
- DesktopMenuItem(
- key: const ValueKey('support'),
- duration: duration,
- icon: const DesktopSupportIcon(),
- label: "Support",
- value: DesktopMenuItemId.support,
- onChanged: updateSelectedMenuItem,
- controller: controllers[6],
- isExpandedInitially: !_isMinimized,
- ),
- const SizedBox(height: 2),
- DesktopMenuItem(
- key: const ValueKey('about'),
- duration: duration,
- icon: const DesktopAboutIcon(),
- label: "About",
- value: DesktopMenuItemId.about,
- onChanged: updateSelectedMenuItem,
- controller: controllers[7],
- isExpandedInitially: !_isMinimized,
),
if (!Platform.isIOS) ...[
const SizedBox(height: 16),
@@ -298,7 +306,6 @@ class _DesktopMenuState extends ConsumerState<DesktopMenu> {
],
],
),
- ),
),
),
Row(
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart
index a7971f7..e70ae01 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart
@@ -23,6 +23,7 @@ import '../../../../../utilities/assets.dart';
import '../../../../../utilities/text_styles.dart';
import '../../../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../../../wallets/isar/models/wallet_info.dart';
+import '../../../../../wallets/isar/providers/all_wallets_info_provider.dart';
import '../../../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart';
import '../../../../../widgets/custom_buttons/draggable_switch_button.dart';
@@ -688,12 +689,23 @@ class _MoreFeaturesClearSparkCacheItemState
await FiroCacheCoordinator.clearSharedCache(
widget.cryptoCurrency.network,
);
- await ref.read(pWalletInfo(widget.walletId)).updateOtherData(
- newEntries: {
- WalletInfoKeys.firoSparkCacheSetBlockHashCache: <String, String>{},
- },
- isar: ref.read(mainDBProvider).isar,
- );
+ final isar = ref.read(mainDBProvider).isar;
+ final sparkWalletInfos = ref
+ .read(pAllWalletsInfo)
+ .where(
+ (info) =>
+ info.coin.identifier == widget.cryptoCurrency.identifier,
+ )
+ .toList();
+ for (final info in sparkWalletInfos) {
+ await info.updateOtherData(
+ newEntries: {
+ WalletInfoKeys.firoSparkCacheSetBlockHashCache:
+ <String, String>{},
+ },
+ isar: isar,
+ );
+ }
setState(() {
// trigger rebuild for cache size display
});
diff --git a/lib/utilities/firo_pro_reg_signed_message_prefix.dart b/lib/utilities/firo_pro_reg_signed_message_prefix.dart
new file mode 100644
index 0000000..0844a34
--- /dev/null
+++ b/lib/utilities/firo_pro_reg_signed_message_prefix.dart
@@ -0,0 +1,18 @@
+/// Helpers for Firo ProReg collateral signatures that use Bitcoin-style
+/// signed-message framing with [coinlib.MessageSignature.sign].
+///
+/// [coinlib.Network.messagePrefix] for Firo includes the Core magic byte
+/// `0x16` before `"Zcoin Signed Message:\\n"`. Coinlib adds its own length
+/// framing for signing, so that byte must be supplied explicitly rather than
+/// inferred from accidental equality with `length - 1`.
+library firo_pro_reg_signed_message_prefix;
+
+/// Prefix string passed to [MessageSignature.sign] for Firo/Zcoin networks.
+String firoMessagePrefixForCoinlibSign(String networkMessagePrefix) {
+ const magic = 0x16;
+ final bytes = networkMessagePrefix.codeUnits;
+ if (bytes.isNotEmpty && bytes.first == magic) {
+ return String.fromCharCodes(bytes.sublist(1));
+ }
+ return networkMessagePrefix;
+}
diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart
index 72253b2..901f695 100644
--- a/lib/wallets/wallet/impl/firo_wallet.dart
+++ b/lib/wallets/wallet/impl/firo_wallet.dart
@@ -17,6 +17,7 @@ import '../../../models/isar/models/isar_models.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/extensions/extensions.dart';
+import '../../../utilities/firo_pro_reg_signed_message_prefix.dart';
import '../../../utilities/logger.dart';
import '../../../utilities/util.dart';
import '../../crypto_currency/crypto_currency.dart';
@@ -991,18 +992,19 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
}
Address? ownerAddress = await getCurrentReceivingAddress();
- if (ownerAddress == null || ownerAddress.value == collateralAddress) {
+ const maxOwnerAttempts = 32;
+ for (var i = 0;
+ i < maxOwnerAttempts &&
+ (ownerAddress == null || ownerAddress.value == collateralAddress);
+ i++) {
await generateNewReceivingAddress();
ownerAddress = await getCurrentReceivingAddress();
}
if (ownerAddress == null || ownerAddress.value == collateralAddress) {
- await generateNewReceivingAddress();
- ownerAddress = await getCurrentReceivingAddress();
- }
- if (ownerAddress == null) {
- throw Exception("Could not derive owner address for masternode.");
+ throw Exception(
+ "Could not derive owner address distinct from collateral address.",
+ );
}
- await generateNewReceivingAddress();
final registrationTx = BytesBuilder();
@@ -1057,9 +1059,6 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
);
// keyIDOwner (20 bytes)
- if (ownerAddress.value == collateralAddress) {
- throw Exception("Owner address must differ from collateral address.");
- }
if (!cryptoCurrency.validateAddress(ownerAddress.value)) {
throw Exception("Invalid owner address: ${ownerAddress.value}");
}
@@ -1216,16 +1215,12 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
final collateralKeyPair = root.derivePath(
collateralAddr.derivationPath!.value,
);
- final messagePrefixBytes =
- cryptoCurrency.networkParams.messagePrefix.codeUnits;
- final cleanPrefix =
- messagePrefixBytes.first == messagePrefixBytes.length - 1
- ? String.fromCharCodes(messagePrefixBytes.sublist(1))
- : cryptoCurrency.networkParams.messagePrefix;
final signed = MessageSignature.sign(
key: collateralKeyPair.privateKey,
message: signString,
- prefix: cleanPrefix,
+ prefix: firoMessagePrefixForCoinlibSign(
+ cryptoCurrency.networkParams.messagePrefix,
+ ),
);
// vchSig — compact-size length + 65-byte compact signature
@@ -1244,6 +1239,12 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
);
final finalTransactionHex = finalTx.raw!;
+ assert(
+ finalTransactionHex.toLowerCase().contains(
+ registrationTx.toBytes().toHex.toLowerCase(),
+ ),
+ 'ProReg payload missing from signed transaction hex',
+ );
final broadcastedTxHash = await electrumXClient.broadcastTransaction(
rawTx: finalTransactionHex,
@@ -1312,6 +1313,7 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
Future<List<String>> getMyMasternodeProTxHashes() async {
final List<String> r = [];
final Set<String> collateralTxids = {};
+ final Set<String> resolvedCollateralTxids = {};
final utxos = await mainDB.getUTXOs(walletId).sortByBlockHeight().findAll();
final rawMasterNodeAmount = Amount.fromDecimal(
@@ -1358,6 +1360,7 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
collateralTxids.contains(collateralHash) &&
!r.contains(txid)) {
r.add(txid);
+ resolvedCollateralTxids.add(collateralHash);
}
}
}
@@ -1369,7 +1372,7 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
}
for (final txid in collateralTxids) {
- if (!r.contains(txid)) {
+ if (!resolvedCollateralTxids.contains(txid)) {
r.add(txid);
}
}
diff --git a/scripts/app_config/configure_campfire.sh b/scripts/app_config/configure_campfire.sh
index dea8676..e12697b 100755
--- a/scripts/app_config/configure_campfire.sh
+++ b/scripts/app_config/configure_campfire.sh
@@ -76,7 +76,7 @@ const ({String light, String dark})? _appIconAsset = (
);
final List<CryptoCurrency> _supportedCoins = List.unmodifiable([
- Firo(CryptoCurrencyNetwork.test),
+ Firo(CryptoCurrencyNetwork.main),
]);
final ({String from, String fromFuzzyNet, String to, String toFuzzyNet})
Why this scored 31/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.