Skip to content

Commit 74d5384

Browse files
committed
feat(spl): auto-discover Solana tokens held by wallet
1 parent 51db6c7 commit 74d5384

3 files changed

Lines changed: 378 additions & 12 deletions

File tree

lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ import '../../../utilities/assets.dart';
2727
import '../../../utilities/constants.dart';
2828
import '../../../utilities/default_eth_tokens.dart';
2929
import '../../../utilities/default_sol_tokens.dart';
30+
import '../../../utilities/logger.dart';
3031
import '../../../utilities/text_styles.dart';
3132
import '../../../utilities/util.dart';
33+
import '../../../wallets/isar/providers/solana/discovered_sol_tokens_provider.dart';
3234
import '../../../wallets/isar/providers/wallet_info_provider.dart';
3335
import '../../../wallets/wallet/impl/ethereum_wallet.dart';
3436
import '../../../wallets/wallet/impl/solana_wallet.dart';
@@ -292,6 +294,73 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
292294
}
293295

294296
super.initState();
297+
298+
if (wallet is SolanaWallet) {
299+
unawaited(_loadDiscoveredTokens(wallet));
300+
}
301+
}
302+
303+
/// Discover the SPL tokens held by [wallet] and merge them into the list.
304+
///
305+
/// Tokens already present (e.g. defaults) are marked as selected, while
306+
/// newly discovered tokens are added to the top of the list and selected.
307+
Future<void> _loadDiscoveredTokens(SolanaWallet wallet) async {
308+
try {
309+
final address = await wallet.getCurrentReceivingAddress();
310+
if (address == null) {
311+
return;
312+
}
313+
314+
final discovered = await ref.read(
315+
pDiscoveredSolanaTokens((
316+
walletId: widget.walletId,
317+
walletAddress: address.value,
318+
)).future,
319+
);
320+
321+
if (discovered.isEmpty) {
322+
return;
323+
}
324+
325+
final newContracts = discovered
326+
.where(
327+
(token) =>
328+
tokenEntities.every((e) => e.token.address != token.address),
329+
)
330+
.toList();
331+
332+
if (newContracts.isNotEmpty) {
333+
await MainDB.instance.putSolContracts(newContracts);
334+
}
335+
336+
if (!mounted) {
337+
return;
338+
}
339+
340+
setState(() {
341+
var insertIndex = 0;
342+
for (final token in discovered) {
343+
final existing = tokenEntities
344+
.where((e) => e.token.address == token.address)
345+
.toList();
346+
if (existing.isNotEmpty) {
347+
existing.first.selected = true;
348+
} else {
349+
tokenEntities.insert(
350+
insertIndex,
351+
AddTokenListElementData(token)..selected = true,
352+
);
353+
insertIndex++;
354+
}
355+
}
356+
});
357+
} catch (e, s) {
358+
Logging.instance.w(
359+
"Failed to load discovered Solana tokens for ${widget.walletId}",
360+
error: e,
361+
stackTrace: s,
362+
);
363+
}
295364
}
296365

297366
@override

lib/services/solana/solana_token_api.dart

Lines changed: 212 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010
import 'package:solana/dto.dart';
1111
import 'package:solana/solana.dart';
1212

13+
import '../../utilities/default_sol_tokens.dart';
14+
15+
/// A token mint discovered in a wallet, with its decimals when known.
16+
class DiscoveredSolMint {
17+
final String mint;
18+
final int? decimals;
19+
20+
const DiscoveredSolMint({this.mint = '', this.decimals});
21+
}
22+
1323
/// Exception for Solana token API errors.
1424
class SolanaTokenApiException implements Exception {
1525
final String message;
@@ -338,19 +348,28 @@ class SolanaTokenAPI {
338348
try {
339349
_checkClient();
340350

341-
// TODO: Implement proper metadata PDA derivation when solana package
342-
// exposes findProgramAddress() utilities.
343-
//
344-
// The Solana Token Metadata program (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb)
345-
// stores token metadata at a PDA derived from the mint address using:
346-
// findProgramAddress(
347-
// ["metadata", metadataProgram, mintPubkey],
348-
// metadataProgram
349-
// )
350-
//
351-
// Until then, return null to allow users to enter custom token details.
351+
// Resolve name/symbol/logo from the bundled known token list when the
352+
// mint matches a well known token.
353+
for (final token in DefaultSolTokens.list) {
354+
if (token.address == mintAddress) {
355+
return SolanaTokenApiResponse<Map<String, dynamic>?>(
356+
value: {
357+
"name": token.name,
358+
"symbol": token.symbol,
359+
"decimals": token.decimals,
360+
"logoUri": token.logoUri,
361+
},
362+
);
363+
}
364+
}
352365

353-
// Metadata PDA derivation not yet implemented
366+
// On-chain metadata lookup is not implemented here: it would require
367+
// deriving the Token Metadata program PDA
368+
// (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) from the mint and
369+
// decoding the Metaplex account, which the solana package does not yet
370+
// expose helpers for. Returning null lets callers fall back to a
371+
// mint-derived placeholder name/symbol while still using the correct
372+
// on-chain decimals.
354373
return SolanaTokenApiResponse<Map<String, dynamic>?>(
355374
value: null,
356375
);
@@ -362,6 +381,187 @@ class SolanaTokenAPI {
362381
}
363382
}
364383

384+
/// Discover all SPL token mints held by a wallet.
385+
///
386+
/// Queries the wallet's token accounts for both the standard SPL Token
387+
/// program and the Token2022 program, then extracts the unique mint
388+
/// addresses from those accounts along with the number of decimals each
389+
/// mint is configured with. The decimals are read directly from the parsed
390+
/// token account data ('tokenAmount.decimals'), which mirrors the value
391+
/// stored on the mint account, so balances are scaled correctly.
392+
Future<SolanaTokenApiResponse<List<DiscoveredSolMint>>>
393+
discoverTokensForWallet({
394+
required String walletAddress,
395+
}) async {
396+
try {
397+
_checkClient();
398+
399+
const splTokenProgramId = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
400+
const token2022ProgramId = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';
401+
402+
final splResponse = await _rpcClient!.getTokenAccountsByOwner(
403+
walletAddress,
404+
TokenAccountsFilter.byProgramId(splTokenProgramId),
405+
encoding: Encoding.jsonParsed,
406+
);
407+
408+
final token2022Response = await _rpcClient!.getTokenAccountsByOwner(
409+
walletAddress,
410+
TokenAccountsFilter.byProgramId(token2022ProgramId),
411+
encoding: Encoding.jsonParsed,
412+
);
413+
414+
final accounts = [
415+
...splResponse.value,
416+
...token2022Response.value,
417+
];
418+
419+
final byMint = <String, DiscoveredSolMint>{};
420+
for (final account in accounts) {
421+
final extracted =
422+
_extractMintFromParsedTokenAccount(account.account.data);
423+
final mint = extracted.mint;
424+
if (mint.isEmpty) {
425+
continue;
426+
}
427+
428+
// Prefer an entry that already has decimals resolved.
429+
final existing = byMint[mint];
430+
if (existing == null || existing.decimals == null) {
431+
byMint[mint] = extracted;
432+
}
433+
}
434+
435+
// For any mint whose decimals could not be read from the token account
436+
// data, fetch the mint account directly and read its decimals.
437+
final resolved = <DiscoveredSolMint>[];
438+
for (final entry in byMint.values) {
439+
if (entry.decimals != null) {
440+
resolved.add(entry);
441+
} else {
442+
final decimals = await _fetchMintDecimals(entry.mint);
443+
resolved.add(
444+
DiscoveredSolMint(mint: entry.mint, decimals: decimals),
445+
);
446+
}
447+
}
448+
449+
return SolanaTokenApiResponse<List<DiscoveredSolMint>>(value: resolved);
450+
} on Exception catch (e) {
451+
return SolanaTokenApiResponse<List<DiscoveredSolMint>>(
452+
exception: SolanaTokenApiException(
453+
'Failed to discover tokens: ${e.toString()}',
454+
originalException: e,
455+
),
456+
);
457+
}
458+
}
459+
460+
/// Fetch the number of decimals configured on a token's mint account.
461+
///
462+
/// Used as a fallback when the decimals could not be read from a parsed
463+
/// token account. Returns null if the mint account cannot be read or parsed.
464+
Future<int?> _fetchMintDecimals(String mintAddress) async {
465+
try {
466+
final response = await _rpcClient!.getAccountInfo(
467+
mintAddress,
468+
encoding: Encoding.jsonParsed,
469+
);
470+
471+
final data = response.value?.data;
472+
if (data is ParsedAccountData) {
473+
return data.when(
474+
splToken: (spl) => spl.when(
475+
account: (info, type, accountType) => null,
476+
mint: (info, type, accountType) => info.decimals,
477+
unknown: (type) => null,
478+
),
479+
token2022: (token2022data) => token2022data.when(
480+
account: (info, type, accountType) => null,
481+
mint: (info, type, accountType) => info.decimals,
482+
unknown: (type) => null,
483+
),
484+
stake: (_) => null,
485+
unsupported: (_) => null,
486+
);
487+
}
488+
489+
if (data is Map<String, dynamic>) {
490+
final parsed = data['parsed'];
491+
if (parsed is Map<String, dynamic>) {
492+
final info = parsed['info'];
493+
if (info is Map<String, dynamic>) {
494+
final decimals = info['decimals'];
495+
if (decimals is int) {
496+
return decimals;
497+
}
498+
return int.tryParse(decimals?.toString() ?? '');
499+
}
500+
}
501+
}
502+
} catch (_) {
503+
// Ignore and report unknown decimals.
504+
}
505+
506+
return null;
507+
}
508+
509+
/// Extract the mint address and decimals from a parsed token account's data.
510+
///
511+
/// Handles both standard SPL Token and Token2022 account data. The decimals
512+
/// come from 'tokenAmount.decimals' on the holding, which matches the value
513+
/// stored on the mint account. Returns an empty mint when the data is not a
514+
/// token account or cannot be parsed, and null decimals when unavailable.
515+
DiscoveredSolMint _extractMintFromParsedTokenAccount(dynamic data) {
516+
try {
517+
if (data is ParsedAccountData) {
518+
return data.when(
519+
splToken: (spl) => spl.when(
520+
account: (info, type, accountType) => DiscoveredSolMint(
521+
mint: info.mint,
522+
decimals: info.tokenAmount.decimals,
523+
),
524+
mint: (info, type, accountType) => const DiscoveredSolMint(),
525+
unknown: (type) => const DiscoveredSolMint(),
526+
),
527+
token2022: (token2022data) => token2022data.when(
528+
account: (info, type, accountType) => DiscoveredSolMint(
529+
mint: info.mint,
530+
decimals: info.tokenAmount.decimals,
531+
),
532+
mint: (info, type, accountType) => const DiscoveredSolMint(),
533+
unknown: (type) => const DiscoveredSolMint(),
534+
),
535+
stake: (_) => const DiscoveredSolMint(),
536+
unsupported: (_) => const DiscoveredSolMint(),
537+
);
538+
}
539+
540+
if (data is Map<String, dynamic>) {
541+
final parsed = data['parsed'];
542+
if (parsed is Map<String, dynamic>) {
543+
final info = parsed['info'];
544+
if (info is Map<String, dynamic>) {
545+
final mint = info['mint'];
546+
if (mint is String) {
547+
int? decimals;
548+
final tokenAmount = info['tokenAmount'];
549+
if (tokenAmount is Map) {
550+
final d = tokenAmount['decimals'];
551+
decimals = d is int ? d : int.tryParse(d?.toString() ?? '');
552+
}
553+
return DiscoveredSolMint(mint: mint, decimals: decimals);
554+
}
555+
}
556+
}
557+
}
558+
} catch (_) {
559+
// Ignore parsing errors and treat as no mint found.
560+
}
561+
562+
return const DiscoveredSolMint();
563+
}
564+
365565
/// Validate if a string is a valid Solana mint address.
366566
///
367567
/// A valid Solana address must:

0 commit comments

Comments
 (0)