feat(spl): cache local sol & spl txs and update txs as they confirm
What changed, and why it matters
This commit adds local caching of pending Solana and SPL token transactions so the wallet UI can show a 'Sending' state immediately, and later updates those records once the network confirms them. It is a feature/refactoring change, not an obvious security fix. There are no clear signs it was released to patch a vulnerability, and no independent researcher or CVE is mentioned.
Treat as a normal feature commit. Reviewers should verify that the pending TransactionV2 cannot be mistaken for a confirmed transaction by downstream UI or balance logic (blockHash/height null is the intended signal), and that updateOrPutTransactionV2s correctly deduplicates by txid to avoid duplicate history entries. No urgent security action is indicated by the diff alone.
Security signals we found
Data model migration from legacy Transaction to TransactionV2
Pending transaction persisted locally before on-chain confirmation
Existing overrideFee preserved across pending-to-confirmed transition
Defensive null checks and try/catch added around transaction parsing
Token transaction history now fetched from Solana RPC and cached locally
No explicit security disclosure, CVE, or researcher attribution in commit
Evidence from the diff
The patch refactors SolanaWallet and SolanaTokenWallet to use TransactionV2 records instead of the older isar.Transaction model. It persists a pending TransactionV2 right after signAndSendTransaction returns a txid (with blockHash/height null to indicate pending), and later updateTransactions() replaces or enriches that record with on-chain data while preserving an overrideFee stored in otherData. The token wallet also newly implements updateTransactions() by fetching parsed RPC history for the associated token account. Error handling is added around parsing, and null/edge cases (missing metadata, non-parsed transactions, missing signatures) are skipped rather than crashing. No cryptographic, RPC authentication, or permission changes are present.
Changed components
lib/wallets/wallet/impl/solana_wallet.dartlib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dartSolana transaction persistence layerSPL token transaction syncInspect captured patch +351 / −63
diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart
index a079151..7f9c941 100644
--- a/lib/wallets/wallet/impl/solana_wallet.dart
+++ b/lib/wallets/wallet/impl/solana_wallet.dart
@@ -13,6 +13,9 @@ import '../../../app_config.dart';
import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart';
import '../../../models/balance.dart';
import '../../../models/isar/models/blockchain_data/transaction.dart' as isar;
+import '../../../models/isar/models/blockchain_data/v2/input_v2.dart';
+import '../../../models/isar/models/blockchain_data/v2/output_v2.dart';
+import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../models/isar/models/isar_models.dart';
import '../../../models/node_model.dart';
import '../../../models/paymint/fee_object_model.dart';
@@ -217,6 +220,52 @@ class SolanaWallet extends Bip39Wallet<Solana> {
);
final txid = await _rpcClient?.signAndSendTransaction(message, [keyPair]);
+
+ // Persist pending transaction immediately so UI shows "Sending" status.
+ if (txid != null) {
+ final senderAddress = keyPair.address;
+ final isToSelf = senderAddress == recipientAccount.address;
+
+ final tempTx = TransactionV2(
+ walletId: walletId,
+ blockHash: null, // CRITICAL: indicates pending.
+ hash: txid,
+ txid: txid,
+ timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
+ height: null, // CRITICAL: indicates pending.
+ inputs: [
+ InputV2.isarCantDoRequiredInDefaultConstructor(
+ scriptSigHex: null,
+ scriptSigAsm: null,
+ sequence: null,
+ outpoint: null,
+ addresses: [senderAddress],
+ valueStringSats: txData.amount!.raw.toString(),
+ witness: null,
+ innerRedeemScriptAsm: null,
+ coinbase: null,
+ walletOwns: true,
+ ),
+ ],
+ outputs: [
+ OutputV2.isarCantDoRequiredInDefaultConstructor(
+ scriptPubKeyHex: "00",
+ valueStringSats: txData.amount!.raw.toString(),
+ addresses: [recipientAccount.address],
+ walletOwns: isToSelf,
+ ),
+ ],
+ version: -1,
+ type: isToSelf ? isar.TransactionType.sentToSelf : isar.TransactionType.outgoing,
+ subType: isar.TransactionSubType.none,
+ otherData: jsonEncode({
+ "overrideFee": txData.fee!.toJsonString(),
+ }),
+ );
+
+ await mainDB.updateOrPutTransactionV2s([tempTx]);
+ }
+
return txData.copyWith(txid: txid);
} catch (e, s) {
Logging.instance.e(
@@ -253,7 +302,7 @@ class SolanaWallet extends Bip39Wallet<Solana> {
final fee = await _getEstimatedNetworkFee(
Amount.fromDecimal(
- Decimal.one, // 1 SOL
+ Decimal.one, // 1 SOL.
fractionDigits: cryptoCurrency.fractionDigits,
),
);
@@ -411,81 +460,157 @@ class SolanaWallet extends Bip39Wallet<Solana> {
(await _getKeyPair()).publicKey,
encoding: Encoding.jsonParsed,
);
- final txsList = List<Tuple2<isar.Transaction, Address>>.empty(
- growable: true,
- );
final myAddress = (await getCurrentReceivingAddress())!;
- // TODO [prio=low]: Revisit null assertion below.
+ if (transactionsList == null) {
+ return;
+ }
- for (final tx in transactionsList!) {
- final senderAddress =
- (tx.transaction as ParsedTransaction).message.accountKeys[0].pubkey;
- var receiverAddress =
- (tx.transaction as ParsedTransaction).message.accountKeys[1].pubkey;
- var txType = isar.TransactionType.unknown;
- final txAmount = Amount(
- rawValue: BigInt.from(
+ final txns = <TransactionV2>[];
+ int skippedCount = 0;
+
+ for (final tx in transactionsList) {
+ try {
+ // Skip transactions without metadata.
+ if (tx.meta == null) {
+ skippedCount++;
+ continue;
+ }
+
+ if (tx.transaction is! ParsedTransaction) {
+ skippedCount++;
+ continue;
+ }
+
+ final parsedTx = tx.transaction as ParsedTransaction;
+ final txid = parsedTx.signatures.isNotEmpty ? parsedTx.signatures[0] : null;
+ 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;
+
+ if ((senderAddress == myAddress.value) &&
+ (receiverAddress == "11111111111111111111111111111111")) {
+ // System Program account means sent to self.
+ 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],
- ),
- fractionDigits: cryptoCurrency.fractionDigits,
- );
+ );
- if ((senderAddress == myAddress.value) &&
- (receiverAddress == "11111111111111111111111111111111")) {
- // The account that is only 1's are System Program accounts which
- // means there is no receiver except the sender,
- // see: https://explorer.solana.com/address/11111111111111111111111111111111
- txType = isar.TransactionType.sentToSelf;
- receiverAddress = senderAddress;
- } else if (senderAddress == myAddress.value) {
- txType = isar.TransactionType.outgoing;
- } else if (receiverAddress == myAddress.value) {
- txType = isar.TransactionType.incoming;
- }
+ // 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;
+ }
+
+ // Create TransactionV2 object.
+ final txn = TransactionV2(
+ walletId: walletId,
+ blockHash: null,
+ hash: txid,
+ txid: txid,
+ timestamp: tx.blockTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
+ height: tx.slot,
+ inputs: [
+ InputV2.isarCantDoRequiredInDefaultConstructor(
+ scriptSigHex: null,
+ scriptSigAsm: null,
+ sequence: null,
+ outpoint: null,
+ addresses: [senderAddress],
+ valueStringSats: amount.toString(),
+ witness: null,
+ innerRedeemScriptAsm: null,
+ coinbase: null,
+ walletOwns: senderAddress == myAddress.value,
+ ),
+ ],
+ outputs: [
+ OutputV2.isarCantDoRequiredInDefaultConstructor(
+ scriptPubKeyHex: "00",
+ valueStringSats: amount.toString(),
+ addresses: [receiverAddress],
+ walletOwns: receiverAddress == myAddress.value,
+ ),
+ ],
+ version: -1,
+ type: txType,
+ subType: isar.TransactionSubType.none,
+ otherData: otherDataMap.isNotEmpty ? jsonEncode(otherDataMap) : null,
+ );
- final transaction = isar.Transaction(
- walletId: walletId,
- txid: (tx.transaction as ParsedTransaction).signatures[0],
- timestamp: tx.blockTime!,
- type: txType,
- subType: isar.TransactionSubType.none,
- amount: tx.meta!.postBalances[1] - tx.meta!.preBalances[1],
- amountString: txAmount.toJsonString(),
- fee: tx.meta!.fee,
- height: tx.slot,
- isCancelled: false,
- isLelantus: false,
- slateId: null,
- otherData: null,
- inputs: [],
- outputs: [],
- nonce: null,
- numberOfMessages: 0,
- );
+ txns.add(txn);
+ } catch (e, s) {
+ Logging.instance.w(
+ "$runtimeType updateTransactions: Failed to parse transaction",
+ error: e,
+ stackTrace: s,
+ );
+ skippedCount++;
+ continue;
+ }
+ }
- final txAddress = Address(
- walletId: walletId,
- value: receiverAddress,
- publicKey: List<int>.empty(),
- derivationIndex: 0,
- derivationPath: DerivationPath()..value = _addressDerivationPath,
- type: AddressType.solana,
- subType: txType == isar.TransactionType.outgoing
- ? AddressSubType.unknown
- : AddressSubType.receiving,
+ // Persist all transactions if any were parsed.
+ if (txns.isNotEmpty) {
+ await mainDB.updateOrPutTransactionV2s(txns);
+ Logging.instance.i(
+ "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)",
);
-
- txsList.add(Tuple2(transaction, txAddress));
}
- await mainDB.addNewTransactionData(txsList, walletId);
} on NodeTorMismatchConfigException {
rethrow;
} catch (e, s) {
Logging.instance.e(
- "Error occurred in solana_wallet.dart while getting"
- " transactions for solana: $e\n$s",
+ "$runtimeType updateTransactions failed: ",
+ error: e,
+ stackTrace: s,
);
}
}
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 720e238..c41ca6c 100644
--- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart
+++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart
@@ -389,7 +389,169 @@ class SolanaTokenWallet extends Wallet {
@override
Future<void> updateTransactions() async {
- // TODO: Fetch token transfer history from Solana RPC.
+ try {
+ final rpcClient = parentSolanaWallet.getRpcClient();
+ if (rpcClient == null) {
+ Logging.instance.w(
+ "$runtimeType updateTransactions: RPC client not initialized",
+ );
+ return;
+ }
+
+ final keyPair = await parentSolanaWallet.getKeyPair();
+ final walletAddress = keyPair.address;
+
+ // Find token account for this mint.
+ final senderTokenAccount = await _findTokenAccount(
+ ownerAddress: walletAddress,
+ mint: tokenMint,
+ rpcClient: rpcClient,
+ );
+
+ if (senderTokenAccount == null) {
+ return;
+ }
+
+ // Fetch recent transactions for this token account.
+ final txListIterable = await rpcClient.getTransactionsList(
+ Ed25519HDPublicKey.fromBase58(senderTokenAccount),
+ encoding: Encoding.jsonParsed,
+ );
+
+ final txList = txListIterable.toList();
+
+ if (txList.isEmpty) {
+ return;
+ }
+
+ final txns = <TransactionV2>[];
+ int skippedCount = 0;
+
+ for (int i = 0; i < txList.length; i++) {
+ final txDetails = txList[i];
+ try {
+ // Skip failed transactions or those without metadata.
+ if (txDetails.meta == null) {
+ skippedCount++;
+ continue;
+ }
+
+ // Cast transaction to ParsedTransaction if available.
+ if (txDetails.transaction is! ParsedTransaction) {
+ skippedCount++;
+ 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.
+ }
+
+ // 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;
+ }
+
+ // Create placeholder TransactionV2 object.
+ final txn = TransactionV2(
+ walletId: walletId,
+ blockHash: null,
+ hash: txid,
+ txid: txid,
+ timestamp:
+ txDetails.blockTime ??
+ DateTime.now().millisecondsSinceEpoch ~/ 1000,
+ height: txDetails.slot,
+ inputs: [
+ InputV2.isarCantDoRequiredInDefaultConstructor(
+ scriptSigHex: null,
+ scriptSigAsm: null,
+ sequence: null,
+ outpoint: null,
+ addresses: [senderTokenAccount],
+ valueStringSats: "0",
+ witness: null,
+ innerRedeemScriptAsm: null,
+ coinbase: null,
+ walletOwns: true,
+ ),
+ ],
+ outputs: [
+ OutputV2.isarCantDoRequiredInDefaultConstructor(
+ scriptPubKeyHex: "00",
+ valueStringSats: "0",
+ addresses: [senderTokenAccount],
+ walletOwns: false,
+ ),
+ ],
+ version: -1,
+ type: TransactionType.outgoing,
+ subType: TransactionSubType.splToken,
+ otherData: jsonEncode(otherDataMap),
+ );
+
+ txns.add(txn);
+ } catch (e, s) {
+ Logging.instance.w(
+ "$runtimeType updateTransactions: Failed to parse transaction at index $i",
+ error: e,
+ stackTrace: s,
+ );
+ skippedCount++;
+ continue;
+ }
+ }
+
+ // Persist all transactions if any were parsed.
+ if (txns.isNotEmpty) {
+ await mainDB.updateOrPutTransactionV2s(txns);
+ Logging.instance.i(
+ "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)",
+ );
+ }
+ } catch (e, s) {
+ Logging.instance.e(
+ "$runtimeType updateTransactions FAILED: ",
+ error: e,
+ stackTrace: s,
+ );
+ }
}
@override
@@ -518,6 +680,7 @@ class SolanaTokenWallet extends Wallet {
// This ensures the cached token balance in the database is updated.
await parentSolanaWallet.refresh();
await updateBalance();
+ await updateTransactions();
}
@override
Why this scored 25/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.