sol transaction parsing and gui updates
What changed, and why it matters
This commit updates how Stack Wallet parses and displays Solana (SOL) and Solana token (SPL) transactions. It changes the code that reads transaction data from the Solana blockchain so it looks at the actual 'transfer' instructions inside each transaction rather than guessing sender/receiver from account key order. It also adds Solana-specific support to several user-interface screens that previously only handled Ethereum tokens. There is no clear security fix or vulnerability being patched; it reads as a feature/bug-fix update for Solana transaction handling.
Treat as a routine feature/bug-fix commit. Reviewers should verify that the new JSON-parsed Solana instruction parsing handles edge cases (multiple instructions, token program variants, RPC errors) gracefully, and that removing overrideFee preservation does not regress fee display for pending transactions. No immediate security response is indicated by the available evidence.
Security signals we found
Parsing logic now relies on JSON fields from RPC 'jsonParsed' responses (e.g., transfer['parsed']['info']['lamports']) without visible validation, which could mis-parse or crash on unexpected node responses.
Removed fallback that preserved a pending transaction's overrideFee; on-chain fee is now always used, which could change displayed fees for pending or failed transactions.
Solana token transaction list now asserts pCurrentSolanaTokenWallet is non-null when building the query, removing earlier lazy-initialization guard.
Transaction direction classification is still heuristic and logs warnings when assumptions are violated, but does not stop ingestion in all ambiguous cases.
No explicit security claim, CVE, or advisory is present in the commit message or diff.
Evidence from the diff
The patch refactors Solana transaction parsing in solana_wallet.dart and solana_token_wallet.dart. For SOL transfers it now inspects parsed System Program ‘transfer’ instructions to extract lamports, source, and destination, and skips transactions that do not contain exactly one such transfer. For SPL tokens it inspects SPL Token ‘transferChecked’ instructions similarly. It also sets isarTransactionVersion to 2, adds transaction/address filter operations, and updates UI widgets to recognize TransactionSubType.splToken and SolContract objects. Several UI files are adjusted to format amounts for Solana tokens and to handle Solana in fee-subtraction logic. The commit removes some defensive null-checking around query initialization in the Solana token transaction list and removes the prior logic that preserved an existing ‘overrideFee’ from pending transactions, replacing it with the on-chain fee from tx.meta.
Changed components
lib/wallets/wallet/impl/solana_wallet.dartlib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dartlib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dartlib/pages/token_view/sub_widgets/token_transaction_list_widget.dartlib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dartlib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dartlib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dartInspect captured patch +497 / −412
diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart
index dc82557..d69414f 100644
--- a/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart
+++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart
@@ -13,9 +13,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar_community/isar.dart';
+
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
-import '../../wallet_view/sub_widgets/no_transactions_found.dart';
-import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart';
import '../../../providers/db/main_db_provider.dart';
import '../../../providers/global/wallets_provider.dart';
import '../../../themes/stack_colors.dart';
@@ -23,12 +22,11 @@ import '../../../utilities/constants.dart';
import '../../../utilities/util.dart';
import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart';
import '../../../widgets/loading_indicator.dart';
+import '../../wallet_view/sub_widgets/no_transactions_found.dart';
+import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart';
class TokenTransactionsList extends ConsumerStatefulWidget {
- const TokenTransactionsList({
- super.key,
- required this.walletId,
- });
+ const TokenTransactionsList({super.key, required this.walletId});
final String walletId;
@@ -48,23 +46,15 @@ class _TransactionsListState extends ConsumerState<TokenTransactionsList> {
BorderRadius get _borderRadiusFirst {
return BorderRadius.only(
- topLeft: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
- topRight: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
+ topLeft: Radius.circular(Constants.size.circularBorderRadius),
+ topRight: Radius.circular(Constants.size.circularBorderRadius),
);
}
BorderRadius get _borderRadiusLast {
return BorderRadius.only(
- bottomLeft: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
- bottomRight: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
+ bottomLeft: Radius.circular(Constants.size.circularBorderRadius),
+ bottomRight: Radius.circular(Constants.size.circularBorderRadius),
);
}
@@ -75,22 +65,20 @@ class _TransactionsListState extends ConsumerState<TokenTransactionsList> {
.getWallet(widget.walletId)
.cryptoCurrency
.minConfirms;
- _query =
- ref.read(mainDBProvider).isar.transactionV2s.buildQuery<TransactionV2>(
- whereClauses: [
- IndexWhereClause.equalTo(
- indexName: 'walletId',
- value: [widget.walletId],
- ),
- ],
- filter: ref.read(pCurrentTokenWallet)!.transactionFilterOperation,
- sortBy: [
- const SortProperty(
- property: "timestamp",
- sort: Sort.desc,
- ),
- ],
- );
+ _query = ref
+ .read(mainDBProvider)
+ .isar
+ .transactionV2s
+ .buildQuery<TransactionV2>(
+ whereClauses: [
+ IndexWhereClause.equalTo(
+ indexName: 'walletId',
+ value: [widget.walletId],
+ ),
+ ],
+ filter: ref.read(pCurrentTokenWallet)!.transactionFilterOperation,
+ sortBy: [const SortProperty(property: "timestamp", sort: Sort.desc)],
+ );
_subscription = _query.watch().listen((event) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -110,8 +98,9 @@ class _TransactionsListState extends ConsumerState<TokenTransactionsList> {
@override
Widget build(BuildContext context) {
- final wallet =
- ref.watch(pWallets.select((value) => value.getWallet(widget.walletId)));
+ final wallet = ref.watch(
+ pWallets.select((value) => value.getWallet(widget.walletId)),
+ );
return FutureBuilder(
future: _query.findAll(),
@@ -125,22 +114,14 @@ class _TransactionsListState extends ConsumerState<TokenTransactionsList> {
return const Column(
children: [
Spacer(),
- Center(
- child: LoadingIndicator(
- height: 50,
- width: 50,
- ),
- ),
- Spacer(
- flex: 4,
- ),
+ Center(child: LoadingIndicator(height: 50, width: 50)),
+ Spacer(flex: 4),
],
);
}
if (_transactions.isEmpty) {
return const NoTransActionsFound();
} else {
- _transactions.sort((a, b) => b.timestamp - a.timestamp);
return RefreshIndicator(
onRefresh: () async {
if (!ref.read(pCurrentTokenWallet)!.refreshMutex.isLocked) {
@@ -171,9 +152,9 @@ class _TransactionsListState extends ConsumerState<TokenTransactionsList> {
return Container(
width: double.infinity,
height: 2,
- color: Theme.of(context)
- .extension<StackColors>()!
- .background,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.background,
);
},
itemCount: _transactions.length,
diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart
index ffbfb2e..59a0a44 100644
--- a/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart
+++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart
@@ -14,23 +14,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar_community/isar.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
-import '../../wallet_view/sub_widgets/no_transactions_found.dart';
-import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart';
import '../../../providers/db/main_db_provider.dart';
import '../../../providers/global/wallets_provider.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/constants.dart';
+import '../../../utilities/util.dart';
import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart';
import '../../../widgets/loading_indicator.dart';
+import '../../wallet_view/sub_widgets/no_transactions_found.dart';
+import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart';
/// Solana-specific transaction list widget.
///
/// Displays transactions for a Solana token using the Solana token wallet provider.
class SolanaTokenTransactionsList extends ConsumerStatefulWidget {
- const SolanaTokenTransactionsList({
- super.key,
- required this.walletId,
- });
+ const SolanaTokenTransactionsList({super.key, required this.walletId});
final String walletId;
@@ -39,34 +37,27 @@ class SolanaTokenTransactionsList extends ConsumerStatefulWidget {
_SolanaTransactionsListState();
}
-class _SolanaTransactionsListState extends ConsumerState<SolanaTokenTransactionsList> {
+class _SolanaTransactionsListState
+ extends ConsumerState<SolanaTokenTransactionsList> {
late final int minConfirms;
bool _hasLoaded = false;
List<TransactionV2> _transactions = [];
- StreamSubscription<List<TransactionV2>>? _subscription;
- Query<TransactionV2>? _query;
+ late final StreamSubscription<List<TransactionV2>> _subscription;
+ late final Query<TransactionV2> _query;
BorderRadius get _borderRadiusFirst {
return BorderRadius.only(
- topLeft: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
- topRight: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
+ topLeft: Radius.circular(Constants.size.circularBorderRadius),
+ topRight: Radius.circular(Constants.size.circularBorderRadius),
);
}
BorderRadius get _borderRadiusLast {
return BorderRadius.only(
- bottomLeft: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
- bottomRight: Radius.circular(
- Constants.size.circularBorderRadius,
- ),
+ bottomLeft: Radius.circular(Constants.size.circularBorderRadius),
+ bottomRight: Radius.circular(Constants.size.circularBorderRadius),
);
}
@@ -77,125 +68,126 @@ class _SolanaTransactionsListState extends ConsumerState<SolanaTokenTransactions
.getWallet(widget.walletId)
.cryptoCurrency
.minConfirms;
- super.initState();
- }
-
- /// Initialize the query and subscription when the wallet becomes available.
- void _initializeQuery() {
- if (_query != null) {
- return; // Already initialized.
- }
-
- // Get transaction filter from Solana token wallet if available.
- final solanaTokenWallet = ref.read(pCurrentSolanaTokenWallet);
- FilterOperation? transactionFilter;
-
- if (solanaTokenWallet != null) {
- transactionFilter = solanaTokenWallet.transactionFilterOperation;
- }
-
- _query = ref.read(mainDBProvider).isar.transactionV2s.buildQuery<TransactionV2>(
- whereClauses: [
- IndexWhereClause.equalTo(
- indexName: 'walletId',
- value: [widget.walletId],
- ),
- ],
- filter: transactionFilter,
- sortBy: [
- const SortProperty(
- property: "timestamp",
- sort: Sort.desc,
- ),
- ],
- );
+ _query = ref
+ .read(mainDBProvider)
+ .isar
+ .transactionV2s
+ .buildQuery<TransactionV2>(
+ whereClauses: [
+ IndexWhereClause.equalTo(
+ indexName: 'walletId',
+ value: [widget.walletId],
+ ),
+ ],
+ filter: ref
+ .read(pCurrentSolanaTokenWallet)!
+ .transactionFilterOperation,
+ sortBy: [const SortProperty(property: "timestamp", sort: Sort.desc)],
+ );
- _subscription = _query!.watch().listen((event) {
+ _subscription = _query.watch().listen((event) {
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) {
- setState(() {
- _transactions = event;
- });
- }
+ setState(() {
+ _transactions = event;
+ });
});
});
+ super.initState();
}
@override
void dispose() {
- _subscription?.cancel();
+ _subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
- final wallet =
- ref.watch(pWallets.select((value) => value.getWallet(widget.walletId)));
-
- // Ensure query is initialized when wallet becomes available.
- _initializeQuery();
-
- // If query hasn't been initialized yet, show loading.
- if (_query == null) {
- return Center(
- child: Container(
- color: Theme.of(context).extension<StackColors>()!.background,
- child: const LoadingIndicator(
- width: 100,
- height: 100,
- ),
- ),
- );
- }
+ final wallet = ref.watch(
+ pWallets.select((value) => value.getWallet(widget.walletId)),
+ );
return FutureBuilder(
- future: _query!.findAll(),
+ future: _query.findAll(),
builder: (fbContext, AsyncSnapshot<List<TransactionV2>> snapshot) {
if (snapshot.connectionState == ConnectionState.done &&
snapshot.hasData) {
- if (!_hasLoaded) {
- _hasLoaded = true;
- _transactions = snapshot.data ?? [];
- }
-
- if (_transactions.isEmpty) {
- return const NoTransActionsFound();
- }
-
- return CustomScrollView(
- slivers: [
- SliverList(
- delegate: SliverChildBuilderDelegate(
- (context, index) {
- return TxListItem(
- key: Key(
- "solanaTokenTransactionV2ListItemKey_${_transactions[index].txid}",
- ),
- tx: _transactions[index],
- coin: wallet.cryptoCurrency,
- radius: index == 0
- ? _borderRadiusFirst
- : index == _transactions.length - 1
- ? _borderRadiusLast
- : null,
- );
- },
- childCount: _transactions.length,
- ),
- ),
+ _transactions = snapshot.data!;
+ _hasLoaded = true;
+ }
+ if (!_hasLoaded) {
+ return const Column(
+ children: [
+ Spacer(),
+ Center(child: LoadingIndicator(height: 50, width: 50)),
+ Spacer(flex: 4),
],
);
}
- return Center(
- child: Container(
- color: Theme.of(context).extension<StackColors>()!.background,
- child: const LoadingIndicator(
- width: 100,
- height: 100,
- ),
- ),
- );
+ if (_transactions.isEmpty) {
+ return const NoTransActionsFound();
+ } else {
+ return RefreshIndicator(
+ onRefresh: () async {
+ if (!ref.read(pCurrentSolanaTokenWallet)!.refreshMutex.isLocked) {
+ unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh());
+ }
+ },
+ child: Util.isDesktop
+ ? ListView.separated(
+ itemBuilder: (context, index) {
+ BorderRadius? radius;
+ if (_transactions.length == 1) {
+ radius = BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ );
+ } else if (index == _transactions.length - 1) {
+ radius = _borderRadiusLast;
+ } else if (index == 0) {
+ radius = _borderRadiusFirst;
+ }
+ final tx = _transactions[index];
+ return TxListItem(
+ tx: tx,
+ coin: wallet.info.coin,
+ radius: radius,
+ );
+ },
+ separatorBuilder: (context, index) {
+ return Container(
+ width: double.infinity,
+ height: 2,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.background,
+ );
+ },
+ itemCount: _transactions.length,
+ )
+ : ListView.builder(
+ itemCount: _transactions.length,
+ itemBuilder: (context, index) {
+ BorderRadius? radius;
+ if (_transactions.length == 1) {
+ radius = BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ );
+ } else if (index == _transactions.length - 1) {
+ radius = _borderRadiusLast;
+ } else if (index == 0) {
+ radius = _borderRadiusFirst;
+ }
+ final tx = _transactions[index];
+ return TxListItem(
+ tx: tx,
+ coin: wallet.info.coin,
+ radius: radius,
+ );
+ },
+ ),
+ );
+ }
},
);
}
diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart
index 49d51f2..fd678c0 100644
--- a/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart
+++ b/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart
@@ -19,6 +19,7 @@ import 'package:isar_community/isar.dart';
import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../../models/isar/models/contact_entry.dart';
+import '../../../../models/isar/models/contract.dart';
import '../../../../models/isar/models/isar_models.dart';
import '../../../../models/transaction_filter.dart';
import '../../../../providers/global/address_book_service_provider.dart';
@@ -33,6 +34,7 @@ import '../../../../utilities/format.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
import '../../../../wallets/crypto_currency/coins/ethereum.dart';
+import '../../../../wallets/crypto_currency/coins/solana.dart';
import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
@@ -836,9 +838,9 @@ class _DesktopTransactionCardRowState
late final TransactionV2 _transaction;
late final String walletId;
late final int minConfirms;
- late final EthContract? ethContract;
+ late final Contract? contract;
- bool get isTokenTx => ethContract != null;
+ bool get isTokenTx => contract != null;
String whatIsIt(TransactionV2 tx, int height) => tx.statusLabel(
currentChainHeight: height,
@@ -860,12 +862,16 @@ class _DesktopTransactionCardRowState
.minConfirms;
_transaction = widget.transaction;
- if (_transaction.subType == TransactionSubType.ethToken) {
- ethContract = ref
+ if (_transaction.subType == TransactionSubType.splToken) {
+ contract = ref
+ .read(mainDBProvider)
+ .getSolContractSync(_transaction.contractAddress!);
+ } else if (_transaction.subType == TransactionSubType.ethToken) {
+ contract = ref
.read(mainDBProvider)
.getEthContractSync(_transaction.contractAddress!);
} else {
- ethContract = null;
+ contract = null;
}
super.initState();
@@ -912,7 +918,7 @@ class _DesktopTransactionCardRowState
final currentHeight = ref.watch(pWalletChainHeight(walletId));
final Amount amount;
- final fractionDigits = ethContract?.decimals ?? coin.fractionDigits;
+ final fractionDigits = contract?.decimals ?? coin.fractionDigits;
if (_transaction.subType == TransactionSubType.cashFusion) {
amount = _transaction.getAmountReceivedInThisWallet(
fractionDigits: fractionDigits,
@@ -922,7 +928,7 @@ class _DesktopTransactionCardRowState
case TransactionType.outgoing:
amount = _transaction.getAmountSentFromThisWallet(
fractionDigits: fractionDigits,
- subtractFee: coin is! Ethereum,
+ subtractFee: !(coin is Ethereum || coin is Solana),
);
break;
@@ -954,7 +960,7 @@ class _DesktopTransactionCardRowState
case TransactionType.unknown:
amount = _transaction.getAmountSentFromThisWallet(
fractionDigits: fractionDigits,
- subtractFee: coin is! Ethereum,
+ subtractFee: !(coin is Ethereum || coin is Solana),
);
break;
}
@@ -1036,22 +1042,46 @@ class _DesktopTransactionCardRowState
),
Expanded(
flex: 6,
- child: Text(
- "$prefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: ethContract)}",
- style: STextStyles.desktopTextExtraExtraSmall(context)
- .copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
- ),
+ child: Builder(
+ builder: (context) {
+ final formattedAmount = ref
+ .watch(pAmountFormatter(coin))
+ .format(
+ amount,
+ ethContract: contract is EthContract
+ ? contract as EthContract
+ : null,
+ solContract: contract is SolContract
+ ? contract as SolContract
+ : null,
+ );
+
+ return Text(
+ "$prefix$formattedAmount",
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textDark,
+ ),
+ );
+ },
),
),
if (price != null)
Expanded(
flex: 4,
- child: Text(
- "$prefix${(amount.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} $baseCurrency",
- style: STextStyles.desktopTextExtraExtraSmall(context),
+ child: Builder(
+ builder: (context) {
+ final formattedFiat = (amount.decimal * price!)
+ .toAmount(fractionDigits: 2)
+ .fiatString(locale: locale);
+
+ return Text(
+ "$prefix$formattedFiat $baseCurrency",
+ style: STextStyles.desktopTextExtraExtraSmall(context),
+ );
+ },
),
),
SvgPicture.asset(
diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart
index 5f5f952..3ee5cd7 100644
--- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart
+++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
+import '../../../../models/isar/models/contract.dart';
import '../../../../models/isar/models/isar_models.dart';
import '../../../../providers/db/main_db_provider.dart';
import '../../../../providers/global/locale_provider.dart';
@@ -43,7 +44,7 @@ class _TransactionCardStateV2 extends ConsumerState<TransactionCardV2> {
late final String unit;
late final CryptoCurrency coin;
late final TransactionType txType;
- late final EthContract? tokenContract;
+ late final Contract? tokenContract;
bool get isTokenTx => tokenContract != null;
@@ -75,6 +76,12 @@ class _TransactionCardStateV2 extends ConsumerState<TransactionCardV2> {
.read(mainDBProvider)
.getEthContractSync(_transaction.contractAddress!);
+ unit = tokenContract!.symbol;
+ } else if (_transaction.subType == TransactionSubType.splToken) {
+ tokenContract = ref
+ .read(mainDBProvider)
+ .getSolContractSync(_transaction.contractAddress!);
+
unit = tokenContract!.symbol;
} else {
tokenContract = null;
@@ -262,9 +269,23 @@ class _TransactionCardStateV2 extends ConsumerState<TransactionCardV2> {
child: FittedBox(
fit: BoxFit.scaleDown,
child: Builder(
- builder: (_) {
+ builder: (context) {
+ final formattedAmount = ref
+ .watch(pAmountFormatter(coin))
+ .format(
+ amount,
+ ethContract:
+ tokenContract is EthContract
+ ? tokenContract as EthContract
+ : null,
+ solContract:
+ tokenContract is SolContract
+ ? tokenContract as SolContract
+ : null,
+ );
+
return Text(
- "$prefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: tokenContract)}",
+ "$prefix$formattedAmount",
style: STextStyles.itemSubtitle12(context),
);
},
@@ -293,9 +314,14 @@ class _TransactionCardStateV2 extends ConsumerState<TransactionCardV2> {
child: FittedBox(
fit: BoxFit.scaleDown,
child: Builder(
- builder: (_) {
+ builder: (context) {
+ final formattedFiat =
+ (amount.decimal * price!)
+ .toAmount(fractionDigits: 2)
+ .fiatString(locale: locale);
+
return Text(
- "$prefix${Amount.fromDecimal(amount.decimal * price!, fractionDigits: 2).fiatString(locale: locale)} $baseCurrency",
+ "$prefix$formattedFiat $baseCurrency",
style: STextStyles.label(context),
);
},
diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart
index 2b310bb..46dc95b 100644
--- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart
+++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart
@@ -22,7 +22,9 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../../models/isar/models/blockchain_data/transaction.dart';
import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
+import '../../../../models/isar/models/contract.dart';
import '../../../../models/isar/models/ethereum/eth_contract.dart';
+import '../../../../models/isar/models/solana/sol_contract.dart';
import '../../../../notifications/show_flush_bar.dart';
import '../../../../providers/global/address_book_service_provider.dart';
import '../../../../providers/providers.dart';
@@ -97,11 +99,11 @@ class _TransactionV2DetailsViewState
late final String amountPrefix;
late final String unit;
late final int minConfirms;
- late final EthContract? ethContract;
+ late final Contract? tokenContract;
late final bool supportsRbf;
late final bool hasTxKeyProbably;
- bool get isTokenTx => ethContract != null;
+ bool get isTokenTx => tokenContract != null;
late final List<({List<String> addresses, Amount amount})> data;
@@ -200,13 +202,13 @@ class _TransactionV2DetailsViewState
coin = widget.coin;
if (_transaction.subType == TransactionSubType.ethToken) {
- ethContract = ref
+ tokenContract = ref
.read(mainDBProvider)
.getEthContractSync(_transaction.contractAddress!);
- unit = ethContract!.symbol;
+ unit = tokenContract!.symbol;
} else {
- ethContract = null;
+ tokenContract = null;
unit = coin.ticker;
}
@@ -216,7 +218,7 @@ class _TransactionV2DetailsViewState
.cryptoCurrency
.minConfirms;
- final fractionDigits = ethContract?.decimals ?? coin.fractionDigits;
+ final fractionDigits = tokenContract?.decimals ?? coin.fractionDigits;
fee = _transaction.getFee(fractionDigits: fractionDigits);
@@ -571,109 +573,19 @@ class _TransactionV2DetailsViewState
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- RoundedWhiteContainer(
- padding: isDesktop
- ? const EdgeInsets.all(0)
- : const EdgeInsets.all(12),
- child: Container(
- decoration: isDesktop
- ? BoxDecoration(
- color: Theme.of(context)
- .extension<StackColors>()!
- .backgroundAppBar,
- borderRadius: BorderRadius.vertical(
- top: Radius.circular(
- Constants
- .size
- .circularBorderRadius,
- ),
- ),
- )
- : null,
- child: Padding(
- padding: isDesktop
- ? const EdgeInsets.all(12)
- : const EdgeInsets.all(0),
- child: Row(
- mainAxisAlignment:
- MainAxisAlignment.spaceBetween,
- children: [
- if (isDesktop)
- Row(
- children: [
- TxIcon(
- transaction: _transaction,
- currentHeight: currentHeight,
- coin: coin,
- ),
- const SizedBox(width: 16),
- SelectableText(
- whatIsIt(
- _transaction,
- currentHeight,
- ),
- style:
- STextStyles.desktopTextMedium(
- context,
- ),
- ),
- ],
- ),
- Column(
- crossAxisAlignment: isDesktop
- ? CrossAxisAlignment.end
- : CrossAxisAlignment.start,
- children: [
- SelectableText(
- "$amountPrefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: ethContract)}",
- style: detailStyle,
- ),
- const SizedBox(height: 2),
- if (price != null)
- Builder(
- builder: (context) {
- final total =
- (amount.decimal * price!)
- .toAmount(
- fractionDigits: 2,
- );
- final formatted = total
- .fiatString(
- locale: ref.watch(
- localeServiceChangeNotifierProvider
- .select(
- (value) => value
- .locale,
- ),
- ),
- );
- final ticker = ref.watch(
- prefsChangeNotifierProvider
- .select(
- (value) =>
- value.currency,
- ),
- );
- return SelectableText(
- "$amountPrefix$formatted $ticker",
- style: labelStyle,
- );
- },
- ),
- ],
- ),
- if (!isDesktop)
- TxIcon(
- transaction: _transaction,
- currentHeight: currentHeight,
- coin: coin,
- ),
- ],
- ),
- ),
- ),
+ _TxDetailsAmountHeader(
+ isDesktop: isDesktop,
+ currentHeight: currentHeight,
+ transaction: _transaction,
+ coin: coin,
+ whatIsIt: whatIsIt,
+ amount: amount,
+ price: price,
+ labelStyle: labelStyle,
+ detailStyle: detailStyle,
+ amountPrefix: amountPrefix,
+ tokenContract: tokenContract,
),
-
isDesktop
? const _Divider()
: const SizedBox(height: 12),
@@ -2216,3 +2128,132 @@ class _TxidDetailItemState extends ConsumerState<_TxidDetailItem> {
);
}
}
+
+class _TxDetailsAmountHeader extends ConsumerWidget {
+ const _TxDetailsAmountHeader({
+ required this.isDesktop,
+ required this.currentHeight,
+ required this.transaction,
+ required this.coin,
+ required this.whatIsIt,
+ required this.amount,
+ this.price,
+ required this.labelStyle,
+ required this.detailStyle,
+ required this.amountPrefix,
+ this.tokenContract,
+ });
+
+ final bool isDesktop;
+ final int currentHeight;
+ final TransactionV2 transaction;
+ final CryptoCurrency coin;
+ final String Function(TransactionV2, int) whatIsIt;
+ final Amount amount;
+ final Decimal? price;
+ final TextStyle labelStyle;
+ final TextStyle detailStyle;
+ final String amountPrefix;
+ final Contract? tokenContract;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return RoundedWhiteContainer(
+ padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12),
+ child: Container(
+ decoration: isDesktop
+ ? BoxDecoration(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.backgroundAppBar,
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Constants.size.circularBorderRadius),
+ ),
+ )
+ : null,
+ child: Padding(
+ padding: isDesktop
+ ? const EdgeInsets.all(12)
+ : const EdgeInsets.all(0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ if (isDesktop)
+ Row(
+ children: [
+ TxIcon(
+ transaction: transaction,
+ currentHeight: currentHeight,
+ coin: coin,
+ ),
+ const SizedBox(width: 16),
+ SelectableText(
+ whatIsIt(transaction, currentHeight),
+ style: STextStyles.desktopTextMedium(context),
+ ),
+ ],
+ ),
+ Column(
+ crossAxisAlignment: isDesktop
+ ? CrossAxisAlignment.end
+ : CrossAxisAlignment.start,
+ children: [
+ Builder(
+ builder: (context) {
+ final formattedAmount = ref
+ .watch(pAmountFormatter(coin))
+ .format(
+ amount,
+ ethContract: tokenContract is EthContract
+ ? tokenContract as EthContract
+ : null,
+ solContract: tokenContract is SolContract
+ ? tokenContract as SolContract
+ : null,
+ );
+ return SelectableText(
+ "$amountPrefix$formattedAmount",
+ style: detailStyle,
+ );
+ },
+ ),
+ const SizedBox(height: 2),
+ if (price != null)
+ Builder(
+ builder: (context) {
+ final total = (amount.decimal * price!).toAmount(
+ fractionDigits: 2,
+ );
+ final formatted = total.fiatString(
+ locale: ref.watch(
+ localeServiceChangeNotifierProvider.select(
+ (value) => value.locale,
+ ),
+ ),
+ );
+ final ticker = ref.watch(
+ prefsChangeNotifierProvider.select(
+ (value) => value.currency,
+ ),
+ );
+ return SelectableText(
+ "$amountPrefix$formatted $ticker",
+ style: labelStyle,
+ );
+ },
+ ),
+ ],
+ ),
+ if (!isDesktop)
+ TxIcon(
+ transaction: transaction,
+ currentHeight: currentHeight,
+ coin: coin,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart
index 3f02277..97ff59c 100644
--- a/lib/wallets/wallet/impl/solana_wallet.dart
+++ b/lib/wallets/wallet/impl/solana_wallet.dart
@@ -106,9 +106,24 @@ class SolanaWallet extends Bip39Wallet<Solana> {
return BigInt.from(estimate);
}
+ @override
+ int get isarTransactionVersion => 2;
+
+ @override
+ FilterOperation? get transactionFilterOperation => FilterGroup.not(
+ const FilterCondition.equalTo(
+ property: r"subType",
+ value: TransactionSubType.splToken,
+ ),
+ );
+
@override
FilterOperation? get changeAddressFilterOperation =>
- throw UnimplementedError();
+ FilterGroup.and(standardChangeAddressFilters);
+
+ @override
+ FilterOperation? get receivingAddressFilterOperation =>
+ FilterGroup.and(standardReceivingAddressFilters);
@override
Future<void> checkSaveInitialReceivingAddress() async {
@@ -363,10 +378,6 @@ class SolanaWallet extends Bip39Wallet<Solana> {
}
}
- @override
- FilterOperation? get receivingAddressFilterOperation =>
- FilterGroup.and(standardReceivingAddressFilters);
-
@override
Future<void> recover({required bool isRescan}) async {
await refreshMutex.protect(() async {
@@ -515,67 +526,57 @@ class SolanaWallet extends Bip39Wallet<Solana> {
final txid = parsedTx.signatures.isNotEmpty
? parsedTx.signatures[0]
: null;
+
+ if (parsedTx.signatures.length > 1) {
+ Logging.instance.w(
+ "SOL $walletId found tx with "
+ "${parsedTx.signatures.length} signatures",
+ );
+ }
+
if (txid == null) {
skippedCount++;
continue;
}
- // Determine transaction direction.
- final senderAddress = parsedTx.message.accountKeys[0].pubkey;
- var receiverAddress = parsedTx.message.accountKeys.length > 1
- ? parsedTx.message.accountKeys[1].pubkey
- : senderAddress;
- var txType = isar.TransactionType.unknown;
+ final systemTransfers = parsedTx.message.instructions
+ .map((e) => e.toJson())
+ .where(
+ (e) =>
+ e.containsKey("parsed") &&
+ e["program"] == "system" &&
+ e["parsed"]["type"] == "transfer",
+ );
+
+ if (systemTransfers.length != 1) {
+ Logging.instance.w(
+ "SOL $walletId found tx with "
+ "${systemTransfers.length} system transfer! Skipping...",
+ );
+ skippedCount++;
+ continue;
+ }
+ final transfer = systemTransfers.first;
+ final lamports = BigInt.parse(
+ transfer["parsed"]["info"]["lamports"].toString(),
+ );
+ final senderAddress = transfer["parsed"]["info"]["source"] as String;
+ final receiverAddress =
+ transfer["parsed"]["info"]["destination"] as String;
+
+ final isar.TransactionType txType;
if ((senderAddress == myAddress.value) &&
- (receiverAddress == "11111111111111111111111111111111")) {
- // System Program account means sent to self.
+ (receiverAddress == senderAddress)) {
txType = isar.TransactionType.sentToSelf;
- receiverAddress = senderAddress;
} else if (senderAddress == myAddress.value) {
txType = isar.TransactionType.outgoing;
} else if (receiverAddress == myAddress.value) {
txType = isar.TransactionType.incoming;
- }
-
- // Calculate transfer amount.
- final amount = BigInt.from(
- tx.meta!.postBalances[1] - tx.meta!.preBalances[1],
- );
-
- // Check if this transaction already exists.
- // If it does, preserve the overrideFee from the pending transaction.
- dynamic existingOverrideFee;
- try {
- final allTxsForWallet = await mainDB.isar.transactionV2s
- .where()
- .walletIdEqualTo(walletId)
- .findAll();
- for (final existingTx in allTxsForWallet) {
- if (existingTx.txid == txid) {
- final existingOtherData = existingTx.otherData;
- if (existingOtherData != null && existingOtherData.isNotEmpty) {
- try {
- final otherDataMap = jsonDecode(existingOtherData);
- if (otherDataMap is Map &&
- otherDataMap.containsKey('overrideFee')) {
- existingOverrideFee = otherDataMap['overrideFee'];
- }
- } catch (e) {
- // Ignore parsing errors.
- }
- }
- break;
- }
- }
- } catch (e) {
- // Ignore database query errors.
- }
-
- // Build otherData, preserving overrideFee if it existed.
- final otherDataMap = <String, dynamic>{};
- if (existingOverrideFee != null) {
- otherDataMap["overrideFee"] = existingOverrideFee;
+ } else {
+ // probably should never get here? If so, then this fragile parsing
+ // is broken which isn't surprising...
+ txType = isar.TransactionType.unknown;
}
// Create TransactionV2 object.
@@ -594,7 +595,7 @@ class SolanaWallet extends Bip39Wallet<Solana> {
sequence: null,
outpoint: null,
addresses: [senderAddress],
- valueStringSats: amount.toString(),
+ valueStringSats: lamports.toString(),
witness: null,
innerRedeemScriptAsm: null,
coinbase: null,
@@ -604,17 +605,17 @@ class SolanaWallet extends Bip39Wallet<Solana> {
outputs: [
OutputV2.isarCantDoRequiredInDefaultConstructor(
scriptPubKeyHex: "00",
- valueStringSats: amount.toString(),
+ valueStringSats: lamports.toString(),
addresses: [receiverAddress],
walletOwns: receiverAddress == myAddress.value,
),
],
- version: -1,
+ version: tx.version?.version?.toInt() ?? -1,
type: txType,
subType: isar.TransactionSubType.none,
- otherData: otherDataMap.isNotEmpty
- ? jsonEncode(otherDataMap)
- : null,
+ otherData: jsonEncode({
+ TxV2OdKeys.overrideFee: tx.meta!.fee.toString(),
+ }),
);
txns.add(txn);
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 49879a1..523c03f 100644
--- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart
+++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart
@@ -56,7 +56,7 @@ class SolanaTokenWallet extends Wallet {
FilterCondition.equalTo(property: r"contractAddress", value: tokenMint),
const FilterCondition.equalTo(
property: r"subType",
- value: TransactionSubType.ethToken,
+ value: TransactionSubType.splToken,
),
]);
@@ -448,19 +448,19 @@ class SolanaTokenWallet extends Wallet {
final walletAddress = keyPair.address;
// Find token account for this mint.
- final senderTokenAccount = await _findTokenAccount(
+ final myTokenAccount = await _findTokenAccount(
ownerAddress: walletAddress,
mint: tokenMint,
rpcClient: rpcClient,
);
- if (senderTokenAccount == null) {
+ if (myTokenAccount == null) {
return;
}
// Fetch recent transactions for this token account.
final txListIterable = await rpcClient.getTransactionsList(
- Ed25519HDPublicKey.fromBase58(senderTokenAccount),
+ Ed25519HDPublicKey.fromBase58(myTokenAccount),
encoding: Encoding.jsonParsed,
);
@@ -488,50 +488,60 @@ class SolanaTokenWallet extends Wallet {
continue;
}
final parsedTx = txDetails.transaction as ParsedTransaction;
-
- // Get the txid for this transaction
final txid = parsedTx.signatures.isNotEmpty
? parsedTx.signatures[0]
- : "unknown_txid_$i";
-
- // Check if this transaction already exists in the database.
- // If it does, preserve the overrideFee from the pending transaction.
- dynamic existingOverrideFee;
- try {
- final allTxsForWallet = await mainDB.isar.transactionV2s
- .where()
- .walletIdEqualTo(walletId)
- .findAll();
- for (final tx in allTxsForWallet) {
- if (tx.txid == txid) {
- final existingOtherData = tx.otherData;
- if (existingOtherData != null && existingOtherData.isNotEmpty) {
- try {
- final otherDataMap = jsonDecode(existingOtherData);
- if (otherDataMap is Map &&
- otherDataMap.containsKey('overrideFee')) {
- existingOverrideFee = otherDataMap['overrideFee'];
- }
- } catch (e) {
- // Ignore parsing errors.
- }
- }
- break;
- }
- }
- } catch (e) {
- // Ignore database query errors.
+ : null;
+
+ if (parsedTx.signatures.length > 1) {
+ Logging.instance.w(
+ "SOL $walletId found tx with "
+ "${parsedTx.signatures.length} signatures",
+ );
}
- // Build otherData, preserving overrideFee if it existed.
- final otherDataMap = <String, dynamic>{
- "mint": tokenMint,
- "senderTokenAccount": senderTokenAccount,
- "recipientTokenAccount": senderTokenAccount,
- "isCancelled": (txDetails.meta!.err != null),
- };
- if (existingOverrideFee != null) {
- otherDataMap["overrideFee"] = existingOverrideFee;
+ if (txid == null) {
+ skippedCount++;
+ continue;
+ }
+
+ final splTransfers = parsedTx.message.instructions
+ .map((e) => e.toJson())
+ .where(
+ (e) =>
+ e.containsKey("parsed") &&
+ e["program"] == "spl-token" &&
+ e["parsed"]["type"] == "transferChecked",
+ );
+
+ if (splTransfers.length != 1) {
+ Logging.instance.w(
+ "SOL $walletId found tx with "
+ "${splTransfers.length} spl transfer! Skipping...",
+ );
+ skippedCount++;
+ continue;
+ }
+ final transfer = splTransfers.first;
+ final lamports = BigInt.parse(
+ transfer["parsed"]["info"]["tokenAmount"]["amount"].toString(),
+ );
+ final senderAddress = transfer["parsed"]["info"]["source"] as String;
+ final receiverAddress =
+ transfer["parsed"]["info"]["destination"] as String;
+
+ final TransactionType txType;
+
+ if ((senderAddress == myTokenAccount) &&
+ (receiverAddress == senderAddress)) {
+ txType = TransactionType.sentToSelf;
+ } else if (senderAddress == myTokenAccount) {
+ txType = TransactionType.outgoing;
+ } else if (receiverAddress == myTokenAccount) {
+ txType = TransactionType.incoming;
+ } else {
+ // probably should never get here? If so, then this fragile parsing
+ // is broken which isn't surprising...
+ txType = TransactionType.unknown;
}
// Create placeholder TransactionV2 object.
@@ -550,26 +560,30 @@ class SolanaTokenWallet extends Wallet {
scriptSigAsm: null,
sequence: null,
outpoint: null,
- addresses: [senderTokenAccount],
- valueStringSats: "0",
+ addresses: [senderAddress],
+ valueStringSats: lamports.toString(),
witness: null,
innerRedeemScriptAsm: null,
coinbase: null,
- walletOwns: true,
+ walletOwns: senderAddress == myTokenAccount,
),
],
outputs: [
OutputV2.isarCantDoRequiredInDefaultConstructor(
scriptPubKeyHex: "00",
- valueStringSats: "0",
- addresses: [senderTokenAccount],
- walletOwns: false,
+ valueStringSats: lamports.toString(),
+ addresses: [receiverAddress],
+ walletOwns: receiverAddress == myTokenAccount,
),
],
- version: -1,
- type: TransactionType.outgoing,
+ version: txDetails.version?.version?.toInt() ?? -1,
+ type: txType,
subType: TransactionSubType.splToken,
- otherData: jsonEncode(otherDataMap),
+ otherData: jsonEncode({
+ TxV2OdKeys.contractAddress: tokenMint,
+ TxV2OdKeys.isCancelled: (txDetails.meta!.err != null),
+ TxV2OdKeys.overrideFee: txDetails.meta!.fee.toString(),
+ }),
);
txns.add(txn);
Why this scored 24/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.