Masternode collateral fee check and send cancel black screen
What changed, and why it matters
This commit fixes user-experience and minor reliability bugs in Stack Wallet's masternode collateral flow. It prevents users from accidentally creating a FIRO transaction that leaves them with less than the required 1000 FIRO collateral because of a hidden network fee. It also fixes a 'black screen' bug that could happen if a user cancels a send while the app is still building the transaction. A small Particl wallet change adds a flag but its security effect is unclear from the diff alone.
Review the `isParticl: true` change in particl_wallet.dart to confirm it does not alter signature hashing, prefix handling, or coin-selection in an unsafe way. Verify the fee-estimation fallback (`roughFeeEstimate`) is reasonable for all supported Particl/FIRO network conditions. Test the masternode collateral flow with exactly 1000 FIRO and with a user cancelling during transaction building on both desktop and mobile.
Security signals we found
UI logic change that prevents user from accidentally creating an unusable masternode collateral UTXO due to unaccounted network fees
State-scope fix: cancellation flag moved outside try{} so catch{} can read it
Double-pop / black-screen bug fixed by skipping Navigator.pop() when user already cancelled
Redundant Navigator.pop() removed from desktop send cancel callback
New boolean flag `isParticl: true` added to transaction input construction without visible validation or test changes
Evidence from the diff
The patch makes four related changes: (1) in masternodes_home_view.dart it estimates the consolidation fee and shows a warning when the transparent balance is exactly 1000 FIRO but cannot cover the fee, stopping a silent self-send that would produce a sub-1000 UTXO; (2) in send_view.dart it moves wasCancelled outside the try block so the catch block can see it, skips popping the building dialog when the user cancelled, and uses showLoading for desktop flows; (3) in desktop_send.dart it removes an extra Navigator.pop() from the cancel callback and only pops the dialog in catch when not cancelled; (4) in particl_wallet.dart it passes isParticl: true to a transaction-input builder. The first three are UI/flow hardening; the last is a wallet-specific behavior change whose implications are not visible in this diff.
Changed components
lib/pages/masternodes/masternodes_home_view.dartlib/pages/send_view/send_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartlib/wallets/wallet/impl/particl_wallet.dartInspect captured patch +131 / −32
diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart
index 2b49d42..0cdb31c 100644
--- a/lib/pages/masternodes/masternodes_home_view.dart
+++ b/lib/pages/masternodes/masternodes_home_view.dart
@@ -190,6 +190,47 @@ class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView> {
final spendableBalance = wallet.info.cachedBalance.spendable.raw;
final sparkBalance = wallet.info.cachedBalanceTertiary.spendable.raw;
+ Amount estimatedConsolidationFee;
+ try {
+ final feeObject = await wallet.fees;
+ final collateralAmount = Amount(
+ rawValue: _masternodeCollateralRaw,
+ fractionDigits: wallet.cryptoCurrency.fractionDigits,
+ );
+ estimatedConsolidationFee = await wallet.estimateFeeFor(
+ collateralAmount,
+ feeObject.medium,
+ );
+ } catch (_) {
+ estimatedConsolidationFee = wallet.roughFeeEstimate(
+ 10,
+ 2,
+ BigInt.from(100000),
+ );
+ }
+ if (!mounted) return;
+
+ if (spendableBalance >= _masternodeCollateralRaw &&
+ spendableBalance <
+ _masternodeCollateralRaw + estimatedConsolidationFee.raw) {
+ final feeDecimal = estimatedConsolidationFee.decimal;
+ await showDialog<void>(
+ context: context,
+ builder: (ctx) => StackOkDialog(
+ title: "Insufficient balance for consolidation fee",
+ message:
+ "You have exactly 1000 FIRO, but a network fee of "
+ "$feeDecimal FIRO is needed to consolidate your balance "
+ "into a single 1000 FIRO collateral UTXO.\n\n"
+ "Please add at least $feeDecimal FIRO to your wallet, "
+ "then click Create Masternode again.",
+ desktopPopRootNavigator: Util.isDesktop,
+ maxWidth: Util.isDesktop ? 420 : null,
+ ),
+ );
+ return;
+ }
+
if (spendableBalance < _masternodeCollateralRaw) {
final totalBalance = spendableBalance + sparkBalance;
if (totalBalance >= _masternodeCollateralRaw) {
@@ -207,20 +248,22 @@ class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView> {
builder: (ctx) => StackDialog(
title: "Unshield FIRO for masternode collateral?",
message:
- "You have enough FIRO in total, but part of it is in "
- "your private (Spark) balance. A masternode collateral "
- "must be a single 1000 FIRO amount on your transparent "
- "balance.\n\n"
- "We'll open the Send window pre-filled to move "
- "$deficitDecimal FIRO from your private balance to your "
- "own transparent address. Once confirmed, click Create "
- "Masternode again to continue.",
+ "Masternode collateral must be a single 1000 FIRO UTXO "
+ "in your transparent balance. You will need to unshield "
+ "part of your Spark private balance into your transparent "
+ "balance to create this collateral along with the "
+ "transaction fee required to register it.\n\n"
+ "Do you want to unshield $deficitDecimal FIRO from your "
+ "private Spark balance to your transparent balance? Once "
+ "this transaction is confirmed, click \"Create Masternode\" "
+ "again to continue to the next step.\n\n"
+ "Note: there may be an additional step to consolidate your "
+ "transparent balance into a single UTXO before allowing "
+ "you to register your masternode.",
leftButton: TextButton(
- style: Theme.of(
- ctx,
- ).extension<StackColors>()!.getSecondaryEnabledButtonStyle(
- ctx,
- ),
+ style: Theme.of(ctx)
+ .extension<StackColors>()!
+ .getSecondaryEnabledButtonStyle(ctx),
onPressed: () => Navigator.of(ctx).pop(),
child: Text(
"Cancel",
@@ -248,11 +291,53 @@ class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView> {
);
}
} else {
- await _openCreateCollateralSendFlow(
- wallet,
- fromPrivate: true,
- unshieldAmount: deficitDecimal,
+ final shouldOpenSend = await showDialog<bool>(
+ context: context,
+ builder: (ctx) => StackDialog(
+ title: "Unshield FIRO for masternode collateral?",
+ message:
+ "Masternode collateral must be a single 1000 FIRO UTXO "
+ "in your transparent balance. You will need to unshield "
+ "part of your Spark private balance into your transparent "
+ "balance to create this collateral along with the "
+ "transaction fee required to register it.\n\n"
+ "Do you want to unshield $deficitDecimal FIRO from your "
+ "private Spark balance to your transparent balance? Once "
+ "this transaction is confirmed, click \"Create Masternode\" "
+ "again to continue to the next step.\n\n"
+ "Note: there may be an additional step to consolidate your "
+ "transparent balance into a single UTXO before allowing "
+ "you to register your masternode.",
+ leftButton: TextButton(
+ style: Theme.of(ctx)
+ .extension<StackColors>()!
+ .getSecondaryEnabledButtonStyle(ctx),
+ onPressed: () => Navigator.of(ctx).pop(),
+ child: Text(
+ "Cancel",
+ style: STextStyles.button(ctx).copyWith(
+ color: Theme.of(
+ ctx,
+ ).extension<StackColors>()!.accentColorDark,
+ ),
+ ),
+ ),
+ rightButton: TextButton(
+ style: Theme.of(
+ ctx,
+ ).extension<StackColors>()!.getPrimaryEnabledButtonStyle(ctx),
+ onPressed: () => Navigator.of(ctx).pop(true),
+ child: Text("Open Send", style: STextStyles.button(ctx)),
+ ),
+ ),
);
+ if (shouldOpenSend == true && mounted) {
+ await _openCreateCollateralSendFlow(
+ wallet,
+ fromPrivate: true,
+ unshieldAmount: deficitDecimal,
+ );
+ }
}
} else {
await showDialog<void>(
@@ -371,7 +456,9 @@ class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView> {
SendViewAutoFillData(
address: selfAddress.value,
contactLabel: "My FIRO address",
- amount: fromPrivate ? (unshieldAmount ?? kMasterNodeValue) : kMasterNodeValue,
+ amount: fromPrivate
+ ? (unshieldAmount ?? kMasterNodeValue)
+ : kMasterNodeValue,
note: fromPrivate
? "Masternode collateral unshield (1000 FIRO to transparent)."
: "Masternode collateral prep (1000 FIRO self-send).",
diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart
index 1ad9894..8aa8dc7 100644
--- a/lib/pages/send_view/send_view.dart
+++ b/lib/pages/send_view/send_view.dart
@@ -948,9 +948,8 @@ class _SendViewState extends ConsumerState<SendView> {
}
final shouldShowBuildingDialog = mounted && !Util.isDesktop;
+ bool wasCancelled = false;
try {
- bool wasCancelled = false;
-
if (shouldShowBuildingDialog) {
unawaited(
showDialog<void>(
@@ -974,8 +973,6 @@ class _SendViewState extends ConsumerState<SendView> {
);
}
- final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
-
Future<TxData> txDataFuture;
if (isPaynymSend) {
@@ -1125,9 +1122,25 @@ class _SendViewState extends ConsumerState<SendView> {
);
}
- final results = await Future.wait([txDataFuture, time]);
-
- TxData txData = results.first as TxData;
+ TxData txData;
+ if (Util.isDesktop && mounted) {
+ Exception? buildEx;
+ final desktopResult = await showLoading<TxData>(
+ whileFuture: txDataFuture,
+ context: context,
+ message: "Generating transaction...",
+ delay: const Duration(milliseconds: 2500),
+ rootNavigator: true,
+ onException: (e) => buildEx = e,
+ );
+ if (buildEx != null) throw buildEx!;
+ if (desktopResult == null || !mounted) return;
+ txData = desktopResult;
+ } else {
+ final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
+ final results = await Future.wait([txDataFuture, time]);
+ txData = results.first as TxData;
+ }
if (!wasCancelled && mounted) {
if (isPaynymSend) {
@@ -1171,7 +1184,7 @@ class _SendViewState extends ConsumerState<SendView> {
} catch (e, s) {
Logging.instance.e("$e\n$s", error: e, stackTrace: s);
if (mounted) {
- if (shouldShowBuildingDialog) {
+ if (shouldShowBuildingDialog && !wasCancelled) {
// pop building dialog
Navigator.of(context, rootNavigator: false).pop();
}
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
index b8dc85f..b15555d 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -537,9 +537,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
}
}
+ bool wasCancelled = false;
try {
- bool wasCancelled = false;
-
if (mounted) {
unawaited(
showDialog<dynamic>(
@@ -562,8 +561,6 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
BalanceType.private,
onCancel: () {
wasCancelled = true;
-
- Navigator.of(context).pop();
},
),
),
@@ -775,8 +772,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
} catch (e, s) {
Logging.instance.e("Desktop send: ", error: e, stackTrace: s);
if (mounted) {
- // pop building dialog
- Navigator.of(context, rootNavigator: true).pop();
+ if (!wasCancelled) {
+ Navigator.of(context, rootNavigator: true).pop();
+ }
unawaited(
showDialog<void>(
diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart
index 7f4b84c..65bc9c4 100644
--- a/lib/wallets/wallet/impl/particl_wallet.dart
+++ b/lib/wallets/wallet/impl/particl_wallet.dart
@@ -512,6 +512,7 @@ class ParticlWallet<T extends ElectrumXCurrencyInterface>
),
witnessValue: insAndKeys[i].utxo.value,
redeemScript: extraData[i].redeem,
+ isParticl: true,
overridePrefix: cryptoCurrency.networkParams.bech32Hrp,
);
}
Why this scored 29/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.