What changed, and why it matters
This commit adds a user-facing fee selection feature for Solana and Solana token (SPL) sends in Stack Wallet. It replaces a hardcoded placeholder fee with live fee estimates based on network conditions, offering slow/average/fast tiers. There is no direct evidence this fixes a security vulnerability; it is a feature/UX improvement.
No immediate security action required. Treat as a normal feature commit. If reviewing for release, verify that the fee multipliers and clamp bounds align with Solana network expectations and that estimateFeeFor correctly interprets feeRate as a total lamport amount for Solana transactions.
Security signals we found
Replaces hardcoded fee placeholder with tiered estimation
Adds fee bounds/clamping (5000-1000000 lamports) to prevent extreme fee values
Changes fee handling for Solana and SPL token sends
Evidence from the diff
The patch wires up Solana fee estimation in three files: (1) sol_token_send_view.dart now reads a feeRateTypeMobileStateProvider, calls wallet.fees to obtain tiered fee objects, and estimates the token send fee via wallet.estimateFeeFor(Amount.zero, feeRate); (2) desktop_send_fee_form.dart enables editable Solana fees and removes a TODO for SPL token fee estimation; (3) solana_wallet.dart implements tiered fee calculation (1.0x/1.5x/2.0x multipliers on a base network fee, clamped between 5000 and 1,000,000 lamports). The commit message says ‘TODO dynamic estimation via api’, indicating this is still a local heuristic approximation rather than a fully dynamic API-based estimator.
Changed components
lib/pages/send_view/sol_token_send_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dartlib/wallets/wallet/impl/solana_wallet.dartInspect captured patch +169 / −44
diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart
index 396f7f7..c94528f 100644
--- a/lib/pages/send_view/sol_token_send_view.dart
+++ b/lib/pages/send_view/sol_token_send_view.dart
@@ -14,6 +14,7 @@ import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/flutter_svg.dart';
import '../../models/isar/models/isar_models.dart';
import '../../models/send_view_auto_fill_data.dart';
@@ -28,7 +29,9 @@ import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/barcode_scanner_interface.dart';
import '../../utilities/clipboard_interface.dart';
+import '../../utilities/assets.dart';
import '../../utilities/constants.dart';
+import '../../utilities/enums/fee_rate_type_enum.dart';
import '../../utilities/logger.dart';
import '../../utilities/prefs.dart';
import '../../utilities/text_styles.dart';
@@ -340,10 +343,45 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
}
Future<String> calculateFees() async {
- // TODO: Implement Solana fee calculation.
- // For now, return a placeholder fee
- cachedFees = "0.000005 SOL";
- return cachedFees;
+ try {
+ final wallet = ref.read(pCurrentSolanaTokenWallet);
+ if (wallet == null) {
+ return "0.000005 SOL";
+ }
+
+ final feeObject = await wallet.fees;
+
+ late final BigInt feeRate;
+
+ switch (ref.read(feeRateTypeMobileStateProvider.state).state) {
+ case FeeRateType.fast:
+ feeRate = feeObject.fast;
+ break;
+ case FeeRateType.average:
+ feeRate = feeObject.medium;
+ break;
+ case FeeRateType.slow:
+ feeRate = feeObject.slow;
+ break;
+ default:
+ feeRate = BigInt.from(-1);
+ }
+
+ final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate);
+ cachedFees = ref
+ .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main)))
+ .format(fee, withUnitName: true, indicatePrecisionLoss: false);
+
+ return cachedFees;
+ } catch (e, s) {
+ Logging.instance.w(
+ "Failed to calculate Solana token fees: ",
+ error: e,
+ stackTrace: s,
+ );
+ // Return minimum fee as fallback.
+ return "0.000005 SOL";
+ }
}
Future<void> _previewTransaction() async {
@@ -522,6 +560,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
@override
void initState() {
ref.refresh(feeSheetSessionCacheProvider);
+ ref.read(feeRateTypeMobileStateProvider.state).state = FeeRateType.slow;
_calculateFeesFuture = calculateFees();
_data = widget.autoFillData;
@@ -1114,38 +1153,100 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
),
),
onPressed: () {
- // TODO: Implement fee selection for Solana.
+ showModalBottomSheet<dynamic>(
+ backgroundColor: Colors.transparent,
+ context: context,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(20),
+ ),
+ ),
+ builder: (_) =>
+ TransactionFeeSelectionSheet(
+ walletId: walletId,
+ isToken: true,
+ amount:
+ (Decimal.tryParse(
+ cryptoAmountController
+ .text,
+ ) ??
+ Decimal.zero)
+ .toAmount(
+ fractionDigits:
+ tokenWallet
+ .tokenDecimals,
+ ),
+ updateChosen: (String fee) {
+ setState(() {
+ _calculateFeesFuture = Future(
+ () => fee,
+ );
+ });
+ },
+ ),
+ );
},
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
- FutureBuilder(
- future: _calculateFeesFuture,
- builder: (context, snapshot) {
- if (snapshot.connectionState ==
- ConnectionState.done &&
- snapshot.hasData) {
- return Text(
- "~${snapshot.data!}",
- style: STextStyles.itemSubtitle(
- context,
- ),
- );
- } else {
- return AnimatedText(
- stringsToLoopThrough: const [
- "Calculating",
- "Calculating.",
- "Calculating..",
- "Calculating...",
- ],
- style: STextStyles.itemSubtitle(
- context,
- ),
- );
- }
- },
+ Row(
+ children: [
+ Text(
+ ref
+ .watch(
+ feeRateTypeMobileStateProvider
+ .state,
+ )
+ .state
+ .prettyName,
+ style: STextStyles.itemSubtitle12(
+ context,
+ ),
+ ),
+ const SizedBox(width: 10),
+ FutureBuilder(
+ future: _calculateFeesFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState ==
+ ConnectionState.done &&
+ snapshot.hasData) {
+ return Text(
+ "~${snapshot.data!}",
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ );
+ } else {
+ return AnimatedText(
+ stringsToLoopThrough:
+ const [
+ "Calculating",
+ "Calculating.",
+ "Calculating..",
+ "Calculating...",
+ ],
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ );
+ }
+ },
+ ),
+ ],
+ ),
+ SvgPicture.asset(
+ Assets.svg.chevronDown,
+ width: 8,
+ height: 4,
+ colorFilter: ColorFilter.mode(
+ Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle2,
+ BlendMode.srcIn,
+ ),
),
],
),
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
index 9779f40..f677504 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
@@ -77,6 +77,7 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
Widget build(BuildContext context) {
final canEditFees =
isEth ||
+ cryptoCurrency is Solana ||
(cryptoCurrency is ElectrumXCurrencyInterface &&
!(((cryptoCurrency is Firo) &&
(ref.watch(publicPrivateBalanceStateProvider.state).state ==
@@ -211,7 +212,7 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
.estimateFeeFor(amount, feeRate);
}
} else {
- // TODO: Implement fee estimation for Solana tokens.
+ // Token fee estimation (works for ERC20 and SPL tokens).
try {
final tokenWallet = ref.read(
pCurrentTokenWallet,
@@ -223,7 +224,7 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
.average[amount] =
fee;
} catch (_) {
- // Token wallet not available (Solana).
+ // Token wallet not available.
debugPrint("Token fee estimation not available");
}
}
diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart
index 6caa875..b7a49be 100644
--- a/lib/wallets/wallet/impl/solana_wallet.dart
+++ b/lib/wallets/wallet/impl/solana_wallet.dart
@@ -289,35 +289,58 @@ class SolanaWallet extends Bip39Wallet<Solana> {
);
}
- final fee = await _getEstimatedNetworkFee(amount);
- if (fee == null) {
- throw Exception("Failed to get fees, please check your node connection.");
- }
-
- return Amount(rawValue: fee, fractionDigits: cryptoCurrency.fractionDigits);
+ // The feeRate parameter contains the total fee amount to use.
+ // For Solana, this is already calculated based on priority tier.
+ // Simply return it as the fee estimate.
+ return Amount(rawValue: feeRate, fractionDigits: cryptoCurrency.fractionDigits);
}
@override
Future<FeeObject> get fees async {
_checkClient();
- final fee = await _getEstimatedNetworkFee(
+ final baseFee = await _getEstimatedNetworkFee(
Amount.fromDecimal(
Decimal.one, // 1 SOL.
fractionDigits: cryptoCurrency.fractionDigits,
),
);
- if (fee == null) {
+ if (baseFee == null) {
throw Exception("Failed to get fees, please check your node connection.");
}
+ // Differentiate fees by tier using multipliers:
+ // Base fee is typically around 5000 lamports.
+ // Slow: minimum 5000 lamports.
+ // Average: base fee * 1.5 (but not less than slow).
+ // Fast: base fee * 2.0 (but not less than average).
+ // Ensure all fees stay within bounds: 5000-1000000 lamports.
+ const minFeeBig = 5000;
+ const maxFeeBig = 1000000;
+
+ // Calculate tier fees with multipliers.
+ final slowFee = baseFee; // Use base fee for slow.
+ final averageFee = (baseFee * BigInt.from(3)) ~/ BigInt.from(2); // 1.5x.
+ final fastFee = baseFee * BigInt.from(2); // 2.0x.
+
+ // Clamp all fees to the allowed range.
+ final _clamp = (BigInt value) {
+ if (value < BigInt.from(minFeeBig)) return BigInt.from(minFeeBig);
+ if (value > BigInt.from(maxFeeBig)) return BigInt.from(maxFeeBig);
+ return value;
+ };
+
+ final clampedSlow = _clamp(slowFee);
+ final clampedAverage = _clamp(averageFee);
+ final clampedFast = _clamp(fastFee);
+
return FeeObject(
numberOfBlocksFast: 1,
numberOfBlocksAverage: 1,
numberOfBlocksSlow: 1,
- fast: fee,
- medium: fee,
- slow: fee,
+ fast: clampedFast,
+ medium: clampedAverage,
+ slow: clampedSlow,
);
}
Why this scored 19/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.