What changed, and why it matters
This commit adds an optional memo field to Solana (SOL) and Solana token sends in the Stack Wallet app, similar to the existing memo field for Stellar. It updates the user interface and the wallet code that builds Solana transactions so that any memo entered by the user is included in the transaction. The commit title says 'UNTESTED', meaning the author flagged it as not yet verified. There is no direct evidence in the diff of a security vulnerability, but adding a new untested code path for transaction construction creates a small chance of bugs such as incorrect fees, failed transactions, or unexpected memo handling.
Treat this as a normal feature commit requiring QA, not as a confirmed vulnerability. Before release, test Solana and Solana-token sends with and without memos, verify fee estimates match actual network fees, validate memo length/content to avoid oversized transactions, and ensure memos do not interfere with exchange pay-in IDs or token transfer instructions.
Security signals we found
New untested transaction-building code path (author-labeled 'UNTESTED')
User-supplied memo passed into Solana transaction instructions without visible length or content validation
Fee estimation now depends on memo presence, which could affect fee calculation accuracy if memo size is unbounded
MemoInstruction added to both SOL and SPL-token transfer messages
Evidence from the diff
The change extends memo support from Stellar to Solana across mobile and desktop send views, plus exchange sends. UI state controllers for memos are added to SolTokenSendView and DesktopSolTokenSend. In solana_wallet.dart, _getEstimatedNetworkFee now accepts a memo and includes a MemoInstruction when estimating fees; the actual send path also includes a MemoInstruction if txData.memo is set. SolanaTokenWallet’s token transfer preparation and broadcast similarly include a MemoInstruction when a memo is present, and the fee-estimation helper _getTokenTransferFeeEstimate takes a required memo parameter. The patch is flagged UNTESTED by the author and contains formatting-only changes alongside functional ones.
Changed components
lib/pages/send_view/send_view.dartlib/pages/send_view/sol_token_send_view.dartlib/pages/exchange_view/send_from_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dartlib/wallets/wallet/impl/solana_wallet.dartlib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dartInspect captured patch +446 / −268
diff --git a/lib/pages/exchange_view/send_from_view.dart b/lib/pages/exchange_view/send_from_view.dart
index 471ae9a..4811b65 100644
--- a/lib/pages/exchange_view/send_from_view.dart
+++ b/lib/pages/exchange_view/send_from_view.dart
@@ -280,7 +280,7 @@ class _SendFromCardState extends ConsumerState<SendFromCard> {
// if not firo then do normal send
if (shouldSendPublicFiroFunds == null) {
- final memo = coin is Stellar
+ final memo = coin is Stellar || coin is Solana
? trade.payInExtraId.isNotEmpty
? trade.payInExtraId
: null
diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart
index a58ec0b..0964a89 100644
--- a/lib/pages/send_view/send_view.dart
+++ b/lib/pages/send_view/send_view.dart
@@ -67,13 +67,13 @@ import '../../widgets/background.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../widgets/custom_buttons/blue_text_button.dart';
import '../../widgets/dialogs/firo_exchange_address_dialog.dart';
+import '../../widgets/epic_txs_method_toggle.dart';
import '../../widgets/eth_fee_form.dart';
import '../../widgets/fee_slider.dart';
import '../../widgets/icon_widgets/addressbook_icon.dart';
import '../../widgets/icon_widgets/clipboard_icon.dart';
import '../../widgets/icon_widgets/qrcode_icon.dart';
import '../../widgets/icon_widgets/x_icon.dart';
-import '../../widgets/epic_txs_method_toggle.dart';
import '../../widgets/mwc_txs_method_toggle.dart';
import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_dialog.dart';
@@ -139,7 +139,7 @@ class _SendViewState extends ConsumerState<SendView> {
final _baseFocus = FocusNode();
final _memoFocus = FocusNode();
- late final bool isStellar;
+ late final bool hasOptionalMemo;
late final bool isFiro;
late final bool isEth;
@@ -706,8 +706,7 @@ class _SendViewState extends ConsumerState<SendView> {
try {
if (mounted) {
- final wallet =
- ref.read(pWallets).getWallet(walletId) as EpiccashWallet;
+ final wallet = ref.read(pWallets).getWallet(walletId) as EpiccashWallet;
final amount = ref.read(pSendAmount)!;
@@ -1279,7 +1278,7 @@ class _SendViewState extends ConsumerState<SendView> {
_data = widget.autoFillData;
walletId = widget.walletId;
clipboard = widget.clipboard;
- isStellar = coin is Stellar;
+ hasOptionalMemo = coin is Stellar || coin is Solana;
isFiro = coin is Firo;
isEth = coin is Ethereum;
@@ -1849,7 +1848,7 @@ class _SendViewState extends ConsumerState<SendView> {
),
),
const SizedBox(height: 10),
- if (isStellar ||
+ if (hasOptionalMemo ||
ref.watch(pValidSparkSendToAddress))
ClipRRect(
borderRadius: BorderRadius.circular(
@@ -2665,8 +2664,8 @@ class _SendViewState extends ConsumerState<SendView> {
? isMwcSlatepack
? _createSlatepack
: isEpicSlatepack
- ? _createEpicSlatepack
- : _previewTransaction
+ ? _createEpicSlatepack
+ : _previewTransaction
: null,
style: ref.watch(pPreviewTxButtonEnabled(coin))
? Theme.of(context)
@@ -2676,9 +2675,7 @@ class _SendViewState extends ConsumerState<SendView> {
.extension<StackColors>()!
.getPrimaryDisabledButtonStyle(context),
child: Text(
- isSlatepackMode
- ? "Create slate"
- : "Preview",
+ isSlatepackMode ? "Create slate" : "Preview",
style: STextStyles.button(context),
),
),
diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart
index 62dfbbc..d35d177 100644
--- a/lib/pages/send_view/sol_token_send_view.dart
+++ b/lib/pages/send_view/sol_token_send_view.dart
@@ -27,9 +27,9 @@ import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
+import '../../utilities/assets.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';
@@ -82,6 +82,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
late final ClipboardInterface clipboard;
late TextEditingController sendToController;
+ late TextEditingController memoController;
late TextEditingController cryptoAmountController;
late TextEditingController baseAmountController;
late TextEditingController noteController;
@@ -93,6 +94,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
final _noteFocusNode = FocusNode();
final _cryptoFocus = FocusNode();
final _baseFocus = FocusNode();
+ final _memoFocus = FocusNode();
Amount? _amountToSend;
Amount? _cachedAmountToSend;
@@ -467,6 +469,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
addressType: AddressType.solana,
),
],
+ memo: memoController.text.isEmpty ? null : memoController.text,
feeRateType: ref.read(feeRateTypeMobileStateProvider),
note: noteController.text,
tokenMint: tokenMint,
@@ -542,6 +545,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
void clearSendForm() {
sendToController.text = "";
+ memoController.text = "";
cryptoAmountController.text = "";
baseAmountController.text = "";
noteController.text = "";
@@ -568,6 +572,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
clipboard = widget.clipboard;
sendToController = TextEditingController();
+ memoController = TextEditingController();
cryptoAmountController = TextEditingController();
baseAmountController = TextEditingController();
noteController = TextEditingController();
@@ -598,6 +603,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
baseAmountController.removeListener(_baseAmountChanged);
sendToController.dispose();
+ memoController.dispose();
cryptoAmountController.dispose();
baseAmountController.dispose();
noteController.dispose();
@@ -607,6 +613,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
_addressFocusNode.dispose();
_cryptoFocus.dispose();
_baseFocus.dispose();
+ _memoFocus.dispose();
super.dispose();
}
@@ -936,6 +943,95 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
}
},
),
+ const SizedBox(height: 10),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key("sendViewMemoFieldKey"),
+ controller: memoController,
+ readOnly: false,
+ autocorrect: false,
+ enableSuggestions: false,
+ focusNode: _memoFocus,
+ style: STextStyles.field(context),
+ onChanged: (_) {
+ setState(() {});
+ },
+ decoration:
+ standardInputDecoration(
+ "Enter memo (optional)",
+ _memoFocus,
+ context,
+ ).copyWith(
+ counterText: '',
+ contentPadding: const EdgeInsets.only(
+ left: 16,
+ top: 6,
+ bottom: 8,
+ right: 5,
+ ),
+ suffixIcon: Padding(
+ padding: memoController.text.isEmpty
+ ? const EdgeInsets.only(right: 8)
+ : const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ mainAxisAlignment:
+ MainAxisAlignment.spaceAround,
+ children: [
+ memoController.text.isNotEmpty
+ ? TextFieldIconButton(
+ semanticsLabel:
+ "Clear Button. Clears The Memo Field Input.",
+ key: const Key(
+ "sendSolTokenViewClearMemoFieldButtonKey",
+ ),
+ onTap: () {
+ memoController.text =
+ "";
+ setState(() {});
+ },
+ child: const XIcon(),
+ )
+ : TextFieldIconButton(
+ semanticsLabel:
+ "Paste Button. Pastes From Clipboard To Memo Field Input.",
+ key: const Key(
+ "sendSolTokenViewPasteMemoFieldButtonKey",
+ ),
+ onTap: () async {
+ final ClipboardData?
+ data = await clipboard
+ .getData(
+ Clipboard
+ .kTextPlain,
+ );
+ if (data?.text !=
+ null &&
+ data!
+ .text!
+ .isNotEmpty) {
+ final String content =
+ data.text!.trim();
+
+ memoController.text =
+ content.trim();
+
+ setState(() {});
+ }
+ },
+ child:
+ const ClipboardIcon(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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 28c1fea..b8dc85f 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
@@ -67,12 +67,12 @@ import '../../../../widgets/desktop/primary_button.dart';
import '../../../../widgets/desktop/qr_code_scanner_dialog.dart';
import '../../../../widgets/desktop/secondary_button.dart';
import '../../../../widgets/dialogs/firo_exchange_address_dialog.dart';
+import '../../../../widgets/epic_txs_method_toggle.dart';
import '../../../../widgets/eth_fee_form.dart';
import '../../../../widgets/icon_widgets/addressbook_icon.dart';
import '../../../../widgets/icon_widgets/clipboard_icon.dart';
import '../../../../widgets/icon_widgets/qrcode_icon.dart';
import '../../../../widgets/icon_widgets/x_icon.dart';
-import '../../../../widgets/epic_txs_method_toggle.dart';
import '../../../../widgets/mwc_txs_method_toggle.dart';
import '../../../../widgets/rounded_container.dart';
import '../../../../widgets/stack_text_field.dart';
@@ -120,7 +120,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
final _memoFocus = FocusNode();
final _nonceFocusNode = FocusNode();
- late final bool isStellar;
+ late final bool hasOptionalMemo;
late final bool isMimblewimblecoin;
late final bool isEpiccash;
@@ -705,7 +705,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
);
} else {
- final memo = isStellar ? memoController.text : null;
+ final memo = hasOptionalMemo ? memoController.text : null;
txDataFuture = wallet.prepareSend(
txData: TxData(
recipients: [
@@ -1212,7 +1212,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
coin = ref.read(pWalletInfo(walletId)).coin;
clipboard = widget.clipboard;
- isStellar = coin is Stellar;
+ hasOptionalMemo = coin is Stellar || coin is Solana;
isMimblewimblecoin = coin is Mimblewimblecoin;
isEpiccash = coin is Epiccash;
@@ -1705,7 +1705,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
const SizedBox(height: 20),
if (!isPaynymSend &&
- !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))))
+ !((isMimblewimblecoin || isEpiccash) &&
+ ref.watch(pIsSlatepack(widget.walletId))))
Text(
"Send to",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
@@ -1716,10 +1717,12 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
textAlign: TextAlign.left,
),
if (!isPaynymSend &&
- !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))))
+ !((isMimblewimblecoin || isEpiccash) &&
+ ref.watch(pIsSlatepack(widget.walletId))))
const SizedBox(height: 10),
if (!isPaynymSend &&
- !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))))
+ !((isMimblewimblecoin || isEpiccash) &&
+ ref.watch(pIsSlatepack(widget.walletId))))
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
@@ -1898,7 +1901,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
),
if (!isPaynymSend &&
- !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))))
+ !((isMimblewimblecoin || isEpiccash) &&
+ ref.watch(pIsSlatepack(widget.walletId))))
Builder(
builder: (_) {
final String? error;
@@ -1950,9 +1954,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
}
},
),
- if (isStellar || ref.watch(pValidSparkSendToAddress))
+ if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress))
const SizedBox(height: 10),
- if (isStellar || ref.watch(pValidSparkSendToAddress))
+ if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress))
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
index cc20e75..7dd0c81 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
@@ -66,7 +66,8 @@ class DesktopSolTokenSend extends ConsumerStatefulWidget {
final PaynymAccountLite? accountLite;
@override
- ConsumerState<DesktopSolTokenSend> createState() => _DesktopSolTokenSendState();
+ ConsumerState<DesktopSolTokenSend> createState() =>
+ _DesktopSolTokenSendState();
}
class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
@@ -77,15 +78,14 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
late TextEditingController sendToController;
late TextEditingController cryptoAmountController;
late TextEditingController baseAmountController;
- late TextEditingController nonceController;
+ late TextEditingController memoController;
late final SendViewAutoFillData? _data;
final _addressFocusNode = FocusNode();
final _cryptoFocus = FocusNode();
final _baseFocus = FocusNode();
- // Solana doesn't use nonces like Ethereum.
- // final _nonceFocusNode = FocusNode();
+ final _memoFocusNode = FocusNode();
String? _note;
@@ -98,6 +98,23 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
bool _cryptoAmountChangeLock = false;
late VoidCallback onCryptoAmountChanged;
+ Future<void> pasteMemo() async {
+ if (memoController.text.isNotEmpty) {
+ setState(() {
+ memoController.text = "";
+ });
+ } else {
+ final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain);
+ if (data?.text != null && data!.text!.isNotEmpty) {
+ final String content = data.text!.trim();
+
+ setState(() {
+ memoController.text = content;
+ });
+ }
+ }
+ }
+
Future<void> previewSend() async {
final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
@@ -142,7 +159,8 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Padding(
padding: const EdgeInsets.only(right: 32),
child: Text(
- "You are about to send your entire balance. Would you like to continue?",
+ "You are about to send your entire balance. "
+ "Would you like to continue?",
textAlign: TextAlign.left,
style: STextStyles.desktopTextExtraExtraSmall(
context,
@@ -224,10 +242,11 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
TxData txData;
Future<TxData> txDataFuture;
-
+
final tokenSymbol = tokenWallet.tokenSymbol;
final tokenMint = tokenWallet.tokenMint;
final tokenDecimals = tokenWallet.tokenDecimals;
+ final memo = memoController.text.isEmpty ? null : memoController.text;
txDataFuture = tokenWallet.prepareSend(
txData: TxData(
@@ -236,10 +255,12 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
address: _address!,
amount: amount,
isChange: false,
- addressType:
- tokenWallet.cryptoCurrency.getAddressType(_address!)!,
+ addressType: tokenWallet.cryptoCurrency.getAddressType(
+ _address!,
+ )!,
),
],
+ memo: memo,
tokenSymbol: tokenSymbol,
tokenMint: tokenMint,
tokenDecimals: tokenDecimals,
@@ -264,18 +285,17 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
unawaited(
showDialog(
context: context,
- builder:
- (context) => DesktopDialog(
- maxHeight: MediaQuery.of(context).size.height - 64,
- maxWidth: 580,
- child: ConfirmTransactionView(
- txData: txData,
- walletId: walletId,
- onSuccess: clearSendForm,
- isTokenTx: true,
- routeOnSuccessName: DesktopHomeView.routeName,
- ),
- ),
+ builder: (context) => DesktopDialog(
+ maxHeight: MediaQuery.of(context).size.height - 64,
+ maxWidth: 580,
+ child: ConfirmTransactionView(
+ txData: txData,
+ walletId: walletId,
+ onSuccess: clearSendForm,
+ isTokenTx: true,
+ routeOnSuccessName: DesktopHomeView.routeName,
+ ),
+ ),
),
);
}
@@ -350,7 +370,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
sendToController.text = "";
cryptoAmountController.text = "";
baseAmountController.text = "";
- // Note: Solana doesn't use nonces like Ethereum.
+ memoController.text = "";
_address = "";
_addressToggleFlag = false;
if (mounted) {
@@ -361,8 +381,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
// Get the token's decimal places for proper amount parsing
- final tokenDecimals =
- ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals;
+ final tokenDecimals = ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals;
if (cryptoAmountController.text.isNotEmpty &&
cryptoAmountController.text != "." &&
@@ -388,18 +407,19 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
final price = ref
.read(priceAnd24hChangeNotifierProvider)
- .getTokenPrice(
- ref.read(pCurrentSolanaTokenWallet)!.tokenMint,
- )
+ .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint)
?.value;
if (price != null && price > Decimal.zero) {
- final String fiatAmountString = Amount.fromDecimal(
- _amountToSend!.decimal * price,
- fractionDigits: 2,
- ).fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final String fiatAmountString =
+ Amount.fromDecimal(
+ _amountToSend!.decimal * price,
+ fractionDigits: 2,
+ ).fiatString(
+ locale: ref
+ .read(localeServiceChangeNotifierProvider)
+ .locale,
+ );
baseAmountController.text = fiatAmountString;
}
@@ -485,8 +505,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
// autofill amount field
if (paymentData.amount != null) {
final Amount amount = Decimal.parse(paymentData.amount!).toAmount(
- fractionDigits:
- ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals,
+ fractionDigits: ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals,
);
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
@@ -540,36 +559,32 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
}
void fiatTextFieldOnChanged(String baseAmountString) {
- final int tokenDecimals =
- ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals;
+ final int tokenDecimals = ref
+ .read(pCurrentSolanaTokenWallet)!
+ .tokenDecimals;
if (baseAmountString.isNotEmpty &&
baseAmountString != "." &&
baseAmountString != ",") {
- final baseAmount =
- baseAmountString.contains(",")
- ? Decimal.parse(
- baseAmountString.replaceFirst(",", "."),
- ).toAmount(fractionDigits: 2)
- : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2);
-
- final Decimal? _price =
- ref
- .read(priceAnd24hChangeNotifierProvider)
- .getTokenPrice(
- ref.read(pCurrentSolanaTokenWallet)!.tokenMint,
- )
- ?.value;
+ final baseAmount = baseAmountString.contains(",")
+ ? Decimal.parse(
+ baseAmountString.replaceFirst(",", "."),
+ ).toAmount(fractionDigits: 2)
+ : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2);
+
+ final Decimal? _price = ref
+ .read(priceAnd24hChangeNotifierProvider)
+ .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint)
+ ?.value;
if (_price == null || _price == Decimal.zero) {
_amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals);
} else {
- _amountToSend =
- baseAmount <= Amount.zero
- ? Decimal.zero.toAmount(fractionDigits: tokenDecimals)
- : (baseAmount.decimal / _price)
- .toDecimal(scaleOnInfinitePrecision: tokenDecimals)
- .toAmount(fractionDigits: tokenDecimals);
+ _amountToSend = baseAmount <= Amount.zero
+ ? Decimal.zero.toAmount(fractionDigits: tokenDecimals)
+ : (baseAmount.decimal / _price)
+ .toDecimal(scaleOnInfinitePrecision: tokenDecimals)
+ .toAmount(fractionDigits: tokenDecimals);
}
if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) {
return;
@@ -578,10 +593,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
final amountString = ref
.read(pAmountFormatter(coin))
- .format(
- _amountToSend!,
- withUnitName: false,
- );
+ .format(_amountToSend!, withUnitName: false);
_cryptoAmountChangeLock = true;
cryptoAmountController.text = amountString;
@@ -605,10 +617,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
)),
);
- cryptoAmountController.text = balance
- .spendable
- .decimal
- .toStringAsFixed(tokenWallet.tokenDecimals);
+ cryptoAmountController.text = balance.spendable.decimal.toStringAsFixed(
+ tokenWallet.tokenDecimals,
+ );
}
@override
@@ -628,8 +639,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
sendToController = TextEditingController();
cryptoAmountController = TextEditingController();
baseAmountController = TextEditingController();
- // Solana doesn't use nonces like Ethereum.
- // nonceController = TextEditingController();
+ memoController = TextEditingController();
// feeController = TextEditingController();
onCryptoAmountChanged = _cryptoAmountChanged;
@@ -654,13 +664,13 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
sendToController.dispose();
cryptoAmountController.dispose();
baseAmountController.dispose();
- // nonceController.dispose(); // Solana doesn't use nonces.
+ memoController.dispose();
// feeController.dispose();
_addressFocusNode.dispose();
_cryptoFocus.dispose();
_baseFocus.dispose();
- // _nonceFocusNode.dispose(); // Solana doesn't use nonces.
+ _memoFocusNode.dispose();
super.dispose();
}
@@ -688,10 +698,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Text(
"Send from",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -701,10 +710,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Text(
"Amount",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -724,13 +732,12 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
key: const Key("amountInputFieldCryptoTextFieldKey"),
controller: cryptoAmountController,
focusNode: _cryptoFocus,
- keyboardType:
- Util.isDesktop
- ? null
- : const TextInputType.numberWithOptions(
- signed: false,
- decimal: true,
- ),
+ keyboardType: Util.isDesktop
+ ? null
+ : const TextInputType.numberWithOptions(
+ signed: false,
+ decimal: true,
+ ),
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
@@ -761,10 +768,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -773,10 +779,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
child: Text(
tokenWallet.tokenSymbol,
style: STextStyles.smallMed14(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
),
),
),
@@ -799,13 +804,12 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
key: const Key("amountInputFieldFiatTextFieldKey"),
controller: baseAmountController,
focusNode: _baseFocus,
- keyboardType:
- Util.isDesktop
- ? null
- : const TextInputType.numberWithOptions(
- signed: false,
- decimal: true,
- ),
+ keyboardType: Util.isDesktop
+ ? null
+ : const TextInputType.numberWithOptions(
+ signed: false,
+ decimal: true,
+ ),
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
@@ -832,10 +836,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -848,10 +851,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
),
),
style: STextStyles.smallMed14(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
),
),
),
@@ -862,10 +864,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Text(
"Send to",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -902,127 +903,128 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
},
focusNode: _addressFocusNode,
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveText,
height: 1.8,
),
- decoration: standardInputDecoration(
- "Enter Solana address",
- _addressFocusNode,
- context,
- desktopMed: true,
- ).copyWith(
- contentPadding: const EdgeInsets.only(
- left: 16,
- top: 11,
- bottom: 12,
- right: 5,
- ),
- suffixIcon: Padding(
- padding:
- sendToController.text.isEmpty
+ decoration:
+ standardInputDecoration(
+ "Enter Solana address",
+ _addressFocusNode,
+ context,
+ desktopMed: true,
+ ).copyWith(
+ contentPadding: const EdgeInsets.only(
+ left: 16,
+ top: 11,
+ bottom: 12,
+ right: 5,
+ ),
+ suffixIcon: Padding(
+ padding: sendToController.text.isEmpty
? const EdgeInsets.only(right: 8)
: const EdgeInsets.only(right: 0),
- child: UnconstrainedBox(
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceAround,
- children: [
- _addressToggleFlag
- ? TextFieldIconButton(
- key: const Key(
- "sendTokenViewClearAddressFieldButtonKey",
- ),
- onTap: () {
- sendToController.text = "";
- _address = "";
- _updatePreviewButtonState(
- _address,
- _amountToSend,
- );
- setState(() {
- _addressToggleFlag = false;
- });
- },
- child: const XIcon(),
- )
- : TextFieldIconButton(
- key: const Key(
- "sendTokenViewPasteAddressFieldButtonKey",
- ),
- onTap: pasteAddress,
- child:
- sendToController.text.isEmpty
- ? const ClipboardIcon()
- : const XIcon(),
- ),
- if (sendToController.text.isEmpty)
- TextFieldIconButton(
- key: const Key("sendTokenViewAddressBookButtonKey"),
- onTap: () async {
- final entry = await showDialog<
- ContactAddressEntry?
- >(
- context: context,
- builder:
- (context) => DesktopDialog(
- maxWidth: 696,
- maxHeight: 600,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Row(
- mainAxisAlignment:
- MainAxisAlignment.spaceBetween,
+ child: UnconstrainedBox(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ _addressToggleFlag
+ ? TextFieldIconButton(
+ key: const Key(
+ "sendTokenViewClearAddressFieldButtonKey",
+ ),
+ onTap: () {
+ sendToController.text = "";
+ _address = "";
+ _updatePreviewButtonState(
+ _address,
+ _amountToSend,
+ );
+ setState(() {
+ _addressToggleFlag = false;
+ });
+ },
+ child: const XIcon(),
+ )
+ : TextFieldIconButton(
+ key: const Key(
+ "sendTokenViewPasteAddressFieldButtonKey",
+ ),
+ onTap: pasteAddress,
+ child: sendToController.text.isEmpty
+ ? const ClipboardIcon()
+ : const XIcon(),
+ ),
+ if (sendToController.text.isEmpty)
+ TextFieldIconButton(
+ key: const Key(
+ "sendTokenViewAddressBookButtonKey",
+ ),
+ onTap: () async {
+ final entry =
+ await showDialog<ContactAddressEntry?>(
+ context: context,
+ builder: (context) => DesktopDialog(
+ maxWidth: 696,
+ maxHeight: 600,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
children: [
- Padding(
- padding: const EdgeInsets.only(
- left: 32,
- ),
- child: Text(
- "Address book",
- style: STextStyles.desktopH3(
- context,
+ Row(
+ mainAxisAlignment:
+ MainAxisAlignment
+ .spaceBetween,
+ children: [
+ Padding(
+ padding:
+ const EdgeInsets.only(
+ left: 32,
+ ),
+ child: Text(
+ "Address book",
+ style:
+ STextStyles.desktopH3(
+ context,
+ ),
+ ),
),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: AddressBookAddressChooser(
+ coin: coin,
),
),
- const DesktopDialogCloseButton(),
],
),
- Expanded(
- child: AddressBookAddressChooser(
- coin: coin,
- ),
- ),
- ],
- ),
- ),
- );
+ ),
+ );
- if (entry != null) {
- sendToController.text =
- entry.other ?? entry.label;
+ if (entry != null) {
+ sendToController.text =
+ entry.other ?? entry.label;
- _address = entry.address;
+ _address = entry.address;
- _updatePreviewButtonState(
- _address,
- _amountToSend,
- );
+ _updatePreviewButtonState(
+ _address,
+ _amountToSend,
+ );
- setState(() {
- _addressToggleFlag = true;
- });
- }
- },
- child: const AddressBookIcon(),
- ),
- ],
+ setState(() {
+ _addressToggleFlag = true;
+ });
+ }
+ },
+ child: const AddressBookIcon(),
+ ),
+ ],
+ ),
+ ),
),
),
- ),
- ),
),
),
Builder(
@@ -1040,8 +1042,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
error,
textAlign: TextAlign.left,
style: STextStyles.label(context).copyWith(
- color:
- Theme.of(context).extension<StackColors>()!.textError,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textError,
),
),
),
@@ -1049,15 +1052,74 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
}
},
),
+ const SizedBox(height: 10),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ maxLength: (coin is Firo) ? 31 : null,
+ minLines: 1,
+ maxLines: 5,
+ key: const Key("sendViewMemoFieldKey"),
+ controller: memoController,
+ readOnly: false,
+ autocorrect: false,
+ enableSuggestions: false,
+ focusNode: _memoFocusNode,
+ onChanged: (_) {
+ setState(() {});
+ },
+ style: STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveText,
+ height: 1.8,
+ ),
+ decoration:
+ standardInputDecoration(
+ "Enter memo (optional)",
+ _memoFocusNode,
+ context,
+ desktopMed: true,
+ ).copyWith(
+ counterText: '',
+ contentPadding: const EdgeInsets.only(
+ left: 16,
+ top: 11,
+ bottom: 12,
+ right: 5,
+ ),
+ suffixIcon: Padding(
+ padding: memoController.text.isEmpty
+ ? const EdgeInsets.only(right: 8)
+ : const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ TextFieldIconButton(
+ key: const Key("sendViewPasteMemoButtonKey"),
+ onTap: pasteMemo,
+ child: memoController.text.isEmpty
+ ? const ClipboardIcon()
+ : const XIcon(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
const SizedBox(height: 36),
PrimaryButton(
buttonHeight: ButtonHeight.l,
label: "Preview send",
enabled: ref.watch(previewTokenTxButtonStateProvider.state).state,
- onPressed:
- ref.watch(previewTokenTxButtonStateProvider.state).state
- ? previewSend
- : null,
+ onPressed: ref.watch(previewTokenTxButtonStateProvider.state).state
+ ? previewSend
+ : null,
),
],
);
diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart
index e9b9fcc..3f02277 100644
--- a/lib/wallets/wallet/impl/solana_wallet.dart
+++ b/lib/wallets/wallet/impl/solana_wallet.dart
@@ -74,7 +74,10 @@ class SolanaWallet extends Bip39Wallet<Solana> {
return BigInt.from(balance!.value);
}
- Future<BigInt?> _getEstimatedNetworkFee(Amount transferAmount) async {
+ Future<BigInt?> _getEstimatedNetworkFee(
+ Amount transferAmount,
+ String? memo,
+ ) async {
checkClient();
final latestBlockhash = await _rpcClient?.getLatestBlockhash();
final pubKey = (await _getKeyPair()).publicKey;
@@ -82,6 +85,7 @@ class SolanaWallet extends Bip39Wallet<Solana> {
final compiledMessage =
Message(
instructions: [
+ if (memo != null) MemoInstruction(signers: const [], memo: memo),
SystemInstruction.transfer(
fundingAccount: pubKey,
recipientAccount: pubKey,
@@ -140,7 +144,7 @@ class SolanaWallet extends Bip39Wallet<Solana> {
throw Exception("Insufficient available balance");
}
- final feeAmount = await _getEstimatedNetworkFee(sendAmount);
+ final feeAmount = await _getEstimatedNetworkFee(sendAmount, txData.memo);
if (feeAmount == null) {
throw Exception(
"Failed to get fees, please check your node connection.",
@@ -198,6 +202,8 @@ class SolanaWallet extends Bip39Wallet<Solana> {
);
final message = Message(
instructions: [
+ if (txData.memo != null)
+ MemoInstruction(signers: const [], memo: txData.memo!),
SystemInstruction.transfer(
fundingAccount: keyPair.publicKey,
recipientAccount: recipientPubKey,
@@ -301,6 +307,7 @@ class SolanaWallet extends Bip39Wallet<Solana> {
Decimal.one, // 1 SOL.
fractionDigits: cryptoCurrency.fractionDigits,
),
+ null, // ?
);
if (baseFee == null) {
throw Exception("Failed to get fees, please check your node connection.");
diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart
index d03b0fd..fc7c050 100644
--- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart
+++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart
@@ -215,10 +215,10 @@ class SolanaTokenWallet extends Wallet {
}
final TokenProgramType tokenProgram =
- tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'
- && tokenProgramId.startsWith('Token')
- ? TokenProgramType.token2022Program
- : TokenProgramType.tokenProgram;
+ tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' &&
+ tokenProgramId.startsWith('Token')
+ ? TokenProgramType.token2022Program
+ : TokenProgramType.tokenProgram;
// ignore: unused_local_variable
final instruction = TokenInstruction.transferChecked(
@@ -238,14 +238,12 @@ class SolanaTokenWallet extends Wallet {
ownerPublicKey: keyPair.publicKey,
amount: txData.amount!.raw.toInt(),
rpcClient: rpcClient,
+ memo: txData.memo,
) ??
5000;
return txData.copyWith(
- fee: Amount(
- rawValue: BigInt.from(feeEstimate),
- fractionDigits: 9,
- ),
+ fee: Amount(rawValue: BigInt.from(feeEstimate), fractionDigits: 9),
solanaRecipientTokenAccount: recipientTokenAccount,
);
} catch (e, s) {
@@ -340,10 +338,10 @@ class SolanaTokenWallet extends Wallet {
// Build the TransferChecked instruction.
final TokenProgramType tokenProgram =
- tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'
- && tokenProgramId.startsWith('Token') // Token-2022 variant.
- ? TokenProgramType.token2022Program
- : TokenProgramType.tokenProgram;
+ tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' &&
+ tokenProgramId.startsWith('Token') // Token-2022 variant.
+ ? TokenProgramType.token2022Program
+ : TokenProgramType.tokenProgram;
final instruction = TokenInstruction.transferChecked(
source: senderTokenAccountKey,
@@ -356,7 +354,13 @@ class SolanaTokenWallet extends Wallet {
);
// Create message.
- final message = Message(instructions: [instruction]);
+ final message = Message(
+ instructions: [
+ if (txData.memo != null)
+ MemoInstruction(signers: const [], memo: txData.memo!),
+ instruction,
+ ],
+ );
// Sign and broadcast tx.
final txid = await rpcClient.signAndSendTransaction(message, [keyPair]);
@@ -896,6 +900,7 @@ class SolanaTokenWallet extends Wallet {
required Ed25519HDPublicKey ownerPublicKey,
required int amount,
required RpcClient rpcClient,
+ required String? memo,
}) async {
try {
// Get latest blockhash for message compilation.
@@ -910,7 +915,8 @@ class SolanaTokenWallet extends Wallet {
tokenMint,
encoding: Encoding.jsonParsed,
);
- tokenProgramId = mintInfo.value?.owner ??
+ tokenProgramId =
+ mintInfo.value?.owner ??
'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA';
} catch (e) {
tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA';
@@ -919,10 +925,10 @@ class SolanaTokenWallet extends Wallet {
// Build the TransferChecked instruction.
// Determine which token program type to use based on the queried owner.
final TokenProgramType tokenProgram =
- tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'
- && tokenProgramId.startsWith('Token')
- ? TokenProgramType.token2022Program
- : TokenProgramType.tokenProgram;
+ tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' &&
+ tokenProgramId.startsWith('Token')
+ ? TokenProgramType.token2022Program
+ : TokenProgramType.tokenProgram;
final instruction = TokenInstruction.transferChecked(
source: senderTokenAccountKey,
@@ -935,10 +941,16 @@ class SolanaTokenWallet extends Wallet {
);
// Compile the message with the blockhash.
- final compiledMessage = Message(instructions: [instruction]).compile(
- recentBlockhash: latestBlockhash.value.blockhash,
- feePayer: ownerPublicKey,
- );
+ final compiledMessage =
+ Message(
+ instructions: [
+ if (memo != null) MemoInstruction(signers: const [], memo: memo),
+ instruction,
+ ],
+ ).compile(
+ recentBlockhash: latestBlockhash.value.blockhash,
+ feePayer: ownerPublicKey,
+ );
// Get the fee for this compiled message.
final feeEstimate = await rpcClient.getFeeForMessage(
Why this scored 18/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.