What changed, and why it matters
This commit adds a new user interface screen for creating Salvium staking transactions in the Stack Wallet app. It is purely a UI feature: it adds a text box to enter an amount, a Preview button, and wiring to route the user to the existing transaction confirmation flow. There is no indication in the commit that it fixes a security bug or introduces a vulnerability.
No security action required. Treat as a normal feature review; verify the staking amount validation and transaction confirmation flow behave correctly during QA.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch introduces SalviumCreateStakeView, a Flutter/Riverpod widget that lets a user enter a staking amount and preview a Salvium stake transaction. It reuses the existing wallet.prepareSend() path with a salviumStakeTx: true flag and the existing ConfirmTransactionView. The change also adds navigation entries for mobile and desktop wallets and registers the route in RouteGenerator. No cryptographic, networking, or permission changes are present.
Changed components
lib/pages/salvium_stake/salvium_create_stake_view.dartlib/pages/wallet_view/wallet_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dartlib/route_generator.dartInspect captured patch +480 / −0
diff --git a/lib/pages/salvium_stake/salvium_create_stake_view.dart b/lib/pages/salvium_stake/salvium_create_stake_view.dart
new file mode 100644
index 0000000..58a9a5a
--- /dev/null
+++ b/lib/pages/salvium_stake/salvium_create_stake_view.dart
@@ -0,0 +1,414 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../pages_desktop_specific/desktop_home_view.dart';
+import '../../providers/global/locale_provider.dart';
+import '../../providers/global/wallets_provider.dart';
+import '../../route_generator.dart';
+import '../../themes/stack_colors.dart';
+import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_formatter.dart';
+import '../../utilities/amount/amount_input_formatter.dart';
+import '../../utilities/amount/amount_unit.dart';
+import '../../utilities/enums/fee_rate_type_enum.dart';
+import '../../utilities/logger.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../../wallets/isar/providers/wallet_info_provider.dart';
+import '../../wallets/models/tx_data.dart';
+import '../../wallets/wallet/impl/salvium_wallet.dart';
+import '../../widgets/background.dart';
+import '../../widgets/conditional_parent.dart';
+import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/desktop/desktop_dialog.dart';
+import '../../widgets/desktop/desktop_dialog_close_button.dart';
+import '../../widgets/desktop/primary_button.dart';
+import '../../widgets/desktop/secondary_button.dart';
+import '../../widgets/stack_dialog.dart';
+import '../../wl_gen/interfaces/cs_salvium_interface.dart';
+import '../send_view/confirm_transaction_view.dart';
+import '../send_view/sub_widgets/building_transaction_dialog.dart';
+
+class SalviumCreateStakeView extends ConsumerStatefulWidget {
+ const SalviumCreateStakeView({super.key, required this.walletId});
+
+ final String walletId;
+
+ static const routeName = "/salviumCreateStakeView";
+
+ @override
+ ConsumerState<SalviumCreateStakeView> createState() =>
+ _SalviumCreateStakeViewState();
+}
+
+class _SalviumCreateStakeViewState
+ extends ConsumerState<SalviumCreateStakeView> {
+ final _amountController = TextEditingController();
+ final _amountFocus = FocusNode();
+
+ Amount? _amount;
+
+ void _clearSendForm() {
+ if (mounted) {
+ _amountController.text = "";
+ }
+ }
+
+ void _parseAmount(String string) {
+ final cryptoAmount = ref
+ .read(pAmountFormatter(ref.read(pWalletCoin(widget.walletId))))
+ .tryParse(string);
+
+ if (_amount != cryptoAmount) {
+ setState(() {
+ _amount = cryptoAmount;
+ });
+ }
+ }
+
+ bool _lock = false;
+ Future<void> _previewPressed() async {
+ if (_lock) return;
+ _lock = true;
+
+ try {
+ bool wasCancelled = false;
+
+ unawaited(
+ showDialog<dynamic>(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: false,
+ builder: (context) {
+ return ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => DesktopDialog(
+ maxWidth: 400,
+ maxHeight: double.infinity,
+ child: Padding(padding: const EdgeInsets.all(32), child: child),
+ ),
+ child: BuildingTransactionDialog(
+ coin: ref.read(pWalletCoin(widget.walletId)),
+ isSpark: false,
+ onCancel: () {
+ wasCancelled = true;
+ Navigator.of(context).pop();
+ },
+ ),
+ );
+ },
+ ),
+ );
+
+ final wallet =
+ ref.read(pWallets).getWallet(widget.walletId) as SalviumWallet;
+
+ final address = csSalvium.getAddress(wallet.wallet!);
+
+ final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
+
+ final txDataFuture = wallet.prepareSend(
+ txData: TxData(
+ recipients: [
+ TxRecipient(
+ address: address,
+ amount: _amount!,
+ isChange: false,
+ addressType: wallet.cryptoCurrency.getAddressType(address)!,
+ ),
+ ],
+ feeRateType: FeeRateType.average,
+ note: "Stake transaction",
+ salviumStakeTx: true,
+ ),
+ );
+
+ final results = await Future.wait([txDataFuture, time]);
+ final txData = results.first as TxData;
+
+ if (!wasCancelled && mounted) {
+ // pop building dialog
+ Navigator.of(context, rootNavigator: Util.isDesktop).pop();
+
+ if (Util.isDesktop) {
+ unawaited(
+ showDialog(
+ context: context,
+ builder: (context) => DesktopDialog(
+ maxHeight: MediaQuery.of(context).size.height - 64,
+ maxWidth: 580,
+ child: ConfirmTransactionView(
+ txData: txData,
+ walletId: widget.walletId,
+ onSuccess: _clearSendForm,
+ routeOnSuccessName: DesktopHomeView.routeName,
+ ),
+ ),
+ ),
+ );
+ } else {
+ unawaited(
+ Navigator.of(context).push(
+ RouteGenerator.getRoute(
+ shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
+ builder: (_) => ConfirmTransactionView(
+ txData: txData,
+ walletId: widget.walletId,
+ onSuccess: _clearSendForm,
+ ),
+ settings: const RouteSettings(
+ name: ConfirmTransactionView.routeName,
+ ),
+ ),
+ ),
+ );
+ }
+ }
+ } catch (e, s) {
+ Logging.instance.e("Salvium stake preview: ", error: e, stackTrace: s);
+
+ if (mounted) {
+ // pop building dialog
+ Navigator.of(context, rootNavigator: Util.isDesktop).pop();
+
+ unawaited(
+ showDialog<dynamic>(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: true,
+ builder: (context) {
+ return Util.isDesktop
+ ? DesktopDialog(
+ maxWidth: 450,
+ maxHeight: double.infinity,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 32, bottom: 32),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Transaction failed",
+ style: STextStyles.desktopH3(context),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Padding(
+ padding: const EdgeInsets.only(right: 32),
+ child: Text(
+ e.toString(),
+ textAlign: TextAlign.left,
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(fontSize: 18),
+ ),
+ ),
+ const SizedBox(height: 40),
+ Row(
+ children: [
+ Expanded(
+ child: SecondaryButton(
+ buttonHeight: ButtonHeight.l,
+ label: "Ok",
+ onPressed: () {
+ Navigator.of(
+ context,
+ rootNavigator: true,
+ ).pop();
+ },
+ ),
+ ),
+ const SizedBox(width: 32),
+ ],
+ ),
+ ],
+ ),
+ ),
+ )
+ : StackDialog(
+ title: "Transaction failed",
+ message: e.toString(),
+ rightButton: TextButton(
+ style: Theme.of(context)
+ .extension<StackColors>()!
+ .getSecondaryEnabledButtonStyle(context),
+ child: Text(
+ "Ok",
+ style: STextStyles.button(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
+ ),
+ ),
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ );
+ },
+ ),
+ );
+ }
+ } finally {
+ _lock = false;
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _amountController.addListener(() {
+ if (mounted) {
+ _parseAmount(_amountController.text);
+ }
+ });
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) {
+ _amountFocus.requestFocus();
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _amountController.dispose();
+ _amountFocus.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final coin = ref.watch(pWalletCoin(widget.walletId));
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((s) => s.locale),
+ );
+
+ return ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => Background(
+ child: Scaffold(
+ appBar: AppBar(
+ backgroundColor: Colors.transparent,
+ // Theme.of(context).extension<StackColors>()!.background,
+ leading: const AppBarBackButton(),
+ title: Text(
+ "Stake transaction",
+ style: STextStyles.navBarTitle(context),
+ ),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight,
+ ),
+ child: IntrinsicHeight(child: child),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Amount",
+ style: STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ ),
+ textAlign: TextAlign.left,
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ TextField(
+ autocorrect: Util.isDesktop ? false : true,
+ enableSuggestions: Util.isDesktop ? false : true,
+ style: STextStyles.smallMed14(context).copyWith(
+ color: Theme.of(context).extension<StackColors>()!.textDark,
+ ),
+ key: const Key("amountStakingInputFieldCryptoTextFieldKey"),
+ controller: _amountController,
+ focusNode: _amountFocus,
+ keyboardType: Util.isDesktop
+ ? null
+ : const TextInputType.numberWithOptions(
+ signed: false,
+ decimal: true,
+ ),
+ textAlign: TextAlign.right,
+ inputFormatters: [
+ AmountInputFormatter(
+ decimals: coin.fractionDigits,
+ unit: ref.watch(pAmountUnit(coin)),
+ locale: locale,
+ ),
+ ],
+ decoration: InputDecoration(
+ contentPadding: Util.isDesktop
+ ? const EdgeInsets.only(top: 22, right: 12, bottom: 22)
+ : const EdgeInsets.only(top: 12, right: 12),
+ hintText: "0",
+ hintStyle: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldDefaultText,
+ )
+ : STextStyles.fieldLabel(context).copyWith(fontSize: 14),
+ prefixIcon: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Text(
+ ref.watch(pAmountUnit(coin)).unitForCoin(coin),
+ style: STextStyles.smallMed14(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 32),
+ if (!Util.isDesktop) const Spacer(),
+ ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [child],
+ ),
+ child: PrimaryButton(
+ width: Util.isDesktop ? 200 : null,
+ buttonHeight: Util.isDesktop ? ButtonHeight.l : null,
+ label: "Preview",
+ onPressed: _amount == null ? null : _previewPressed,
+ enabled: _amount != null,
+ ),
+ ),
+ if (!Util.isDesktop) const SizedBox(height: 16),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart
index c8971e6..209df99 100644
--- a/lib/pages/wallet_view/wallet_view.dart
+++ b/lib/pages/wallet_view/wallet_view.dart
@@ -98,6 +98,7 @@ import '../ordinals/ordinals_view.dart';
import '../paynym/paynym_claim_view.dart';
import '../paynym/paynym_home_view.dart';
import '../receive_view/receive_view.dart';
+import '../salvium_stake/salvium_create_stake_view.dart';
import '../send_view/frost_ms/frost_send_view.dart';
import '../send_view/send_view.dart';
import '../settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart';
@@ -1254,6 +1255,17 @@ class _WalletViewState extends ConsumerState<WalletView> {
);
},
),
+ if (wallet is LibSalviumWallet)
+ WalletNavigationBarItemData(
+ label: "Staking",
+ icon: const PaynymNavIcon(),
+ onTap: () {
+ Navigator.of(context).pushNamed(
+ SalviumCreateStakeView.routeName,
+ arguments: widget.walletId,
+ );
+ },
+ ),
if ((wallet is LibMoneroWallet ||
wallet is LibSalviumWallet) &&
!viewOnly)
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart
index c5e5428..8d14c78 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart
@@ -24,6 +24,7 @@ import '../../../../pages/monkey/monkey_view.dart';
import '../../../../pages/namecoin_names/namecoin_names_home_view.dart';
import '../../../../pages/paynym/paynym_claim_view.dart';
import '../../../../pages/paynym/paynym_home_view.dart';
+import '../../../../pages/salvium_stake/salvium_create_stake_view.dart';
import '../../../../pages/spark_names/spark_names_home_view.dart';
import '../../../../providers/desktop/current_desktop_menu_item.dart';
import '../../../../providers/global/paynym_api_provider.dart';
@@ -54,6 +55,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'
import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/custom_loading_overlay.dart';
import '../../../../widgets/desktop/desktop_dialog.dart';
+import '../../../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../../../widgets/desktop/primary_button.dart';
import '../../../../widgets/desktop/secondary_button.dart';
import '../../../../widgets/loading_indicator.dart';
@@ -85,6 +87,7 @@ enum WalletFeature {
churn("Churn", "Churning"),
namecoinName("Domains", "Namecoin DNS"),
sparkNames("Names", "Spark names"),
+ salviumStaking("Staking", "Staking"),
// special cases
clearSparkCache("", ""),
@@ -381,6 +384,39 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
).pushNamed(SparkNamesHomeView.routeName, arguments: widget.walletId);
}
+ Future<void> _onSalviumStakePressed() async {
+ await showDialog<void>(
+ context: context,
+ builder: (context) => DesktopDialog(
+ maxWidth: 500,
+ maxHeight: double.infinity,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "Create stake transaction",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ const SizedBox(height: 20),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32),
+ child: SalviumCreateStakeView(walletId: widget.walletId),
+ ),
+ const SizedBox(height: 32),
+ ],
+ ),
+ ),
+ );
+ }
+
List<(WalletFeature, String, FutureOr<void> Function())> _getOptions(
Wallet wallet,
bool showExchange,
@@ -412,6 +448,13 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
if (showExchange && AppConfig.hasFeature(AppFeature.buy))
(WalletFeature.buy, Assets.svg.swap, _onBuyPressed),
+ if (wallet is LibSalviumWallet)
+ (
+ WalletFeature.salviumStaking,
+ Assets.svg.recycle,
+ _onSalviumStakePressed,
+ ),
+
if (showCoinControl)
(
WalletFeature.coinControl,
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index 4775dad..b44550b 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -93,6 +93,7 @@ import 'pages/receive_view/addresses/edit_address_label_view.dart';
import 'pages/receive_view/addresses/wallet_addresses_view.dart';
import 'pages/receive_view/generate_receiving_uri_qr_code_view.dart';
import 'pages/receive_view/receive_view.dart';
+import 'pages/salvium_stake/salvium_create_stake_view.dart';
import 'pages/send_view/confirm_transaction_view.dart';
import 'pages/send_view/frost_ms/frost_send_view.dart';
import 'pages/send_view/send_view.dart';
@@ -690,6 +691,16 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
+ case SalviumCreateStakeView.routeName:
+ if (args is String) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => SalviumCreateStakeView(walletId: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
case ManageDomainView.routeName:
if (args is ({String walletId, UTXO utxo})) {
return getRoute(
Why this scored 17/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.