diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart index d81afe51e5..40eabc2e95 100644 --- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart +++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart @@ -27,8 +27,10 @@ import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/default_eth_tokens.dart'; import '../../../utilities/default_sol_tokens.dart'; +import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/solana/discovered_sol_tokens_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/ethereum_wallet.dart'; import '../../../wallets/wallet/impl/solana_wallet.dart'; @@ -80,6 +82,13 @@ class _EditWalletTokensViewState extends ConsumerState { final List tokenEntities = []; final bool isDesktop = Util.isDesktop; + bool _isDiscoveringSolanaTokens = false; + + String get _submitLabel => _isDiscoveringSolanaTokens + ? "Discovering..." + : widget.contractsToMarkSelected != null + ? "Save" + : "Next"; List filter( String text, @@ -100,11 +109,13 @@ class _EditWalletTokensViewState extends ConsumerState { } Future onNextPressed() async { - final selectedTokens = - tokenEntities - .where((e) => e.selected) - .map((e) => e.token.address) - .toList(); + if (_isDiscoveringSolanaTokens) { + return; + } + final selectedTokens = tokenEntities + .where((e) => e.selected) + .map((e) => e.token.address) + .toList(); final wallet = ref.read(pWallets).getWallet(widget.walletId); @@ -177,7 +188,9 @@ class _EditWalletTokensViewState extends ConsumerState { tokenEntities.add( AddTokenListElementData(contract!)..selected = true, ); - tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); + tokenEntities.sort( + (a, b) => a.token.name.compareTo(b.token.name), + ); } }); } @@ -199,9 +212,7 @@ class _EditWalletTokensViewState extends ConsumerState { ), ); } else { - final result = await Navigator.of( - context, - ).pushNamed( + final result = await Navigator.of(context).pushNamed( AddCustomSolanaTokenView.routeName, arguments: widget.walletId, ); @@ -228,9 +239,7 @@ class _EditWalletTokensViewState extends ConsumerState { if (tokenEntities .where((e) => e.token.address == token!.address) .isEmpty) { - tokenEntities.add( - AddTokenListElementData(token!)..selected = true, - ); + tokenEntities.add(AddTokenListElementData(token!)..selected = true); tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); } }); @@ -240,6 +249,8 @@ class _EditWalletTokensViewState extends ConsumerState { @override void initState() { + super.initState(); + _searchFieldController = TextEditingController(); _searchFocusNode = FocusNode(); @@ -291,7 +302,69 @@ class _EditWalletTokensViewState extends ConsumerState { e.selected = shouldMarkAsSelectedContracts.contains(e.token.address); } - super.initState(); + if (wallet is SolanaWallet) { + _isDiscoveringSolanaTokens = true; + unawaited(_loadDiscoveredTokens(wallet)); + } + } + + /// Discover the SPL tokens held by [wallet] and merge them into the list. + /// + /// Only tokens new to the stored catalog are selected automatically; + /// existing user selections are left unchanged. + Future _loadDiscoveredTokens(SolanaWallet wallet) async { + // Captured while still mounted; context is not safe to use after an await. + final container = ProviderScope.containerOf(context, listen: false); + try { + final address = await wallet.getCurrentReceivingAddress(); + if (address == null || !mounted) { + return; + } + + final discovered = await readDiscoveredSolanaTokens( + container, + walletId: widget.walletId, + walletAddress: address.value, + ); + + if (discovered.isEmpty || !mounted) { + return; + } + + final newContracts = newDiscoveredSolanaContracts( + knownContracts: tokenEntities + .map((entry) => entry.token) + .whereType(), + discoveredContracts: discovered, + ); + + if (newContracts.isNotEmpty) { + await MainDB.instance.putSolContracts(newContracts); + } + + if (!mounted) { + return; + } + + setState(() { + for (var index = 0; index < newContracts.length; index++) { + tokenEntities.insert( + index, + AddTokenListElementData(newContracts[index])..selected = true, + ); + } + }); + } catch (e, s) { + Logging.instance.w( + "Failed to load discovered Solana tokens for ${widget.walletId}", + error: e, + stackTrace: s, + ); + } finally { + if (mounted) { + setState(() => _isDiscoveringSolanaTokens = false); + } + } } @override @@ -366,10 +439,11 @@ class _EditWalletTokensViewState extends ConsumerState { height: 70, width: 480, child: PrimaryButton( - label: widget.contractsToMarkSelected != null - ? "Save" - : "Next", - onPressed: onNextPressed, + label: _submitLabel, + enabled: !_isDiscoveringSolanaTokens, + onPressed: _isDiscoveringSolanaTokens + ? null + : onNextPressed, ), ), const SizedBox(height: 32), @@ -420,9 +494,14 @@ class _EditWalletTokensViewState extends ConsumerState { const SizedBox(width: 16), Expanded( child: PrimaryButton( - label: "Done", + label: _isDiscoveringSolanaTokens + ? "Discovering..." + : "Done", buttonHeight: ButtonHeight.l, - onPressed: onNextPressed, + enabled: !_isDiscoveringSolanaTokens, + onPressed: _isDiscoveringSolanaTokens + ? null + : onNextPressed, ), ), ], @@ -625,10 +704,11 @@ class _EditWalletTokensViewState extends ConsumerState { ), const SizedBox(height: 16), PrimaryButton( - label: widget.contractsToMarkSelected != null - ? "Save" - : "Next", - onPressed: onNextPressed, + label: _submitLabel, + enabled: !_isDiscoveringSolanaTokens, + onPressed: _isDiscoveringSolanaTokens + ? null + : onNextPressed, ), ], ), diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index 3798e45c2a..36f3c70f22 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -10,6 +10,23 @@ import 'package:solana/dto.dart'; import 'package:solana/solana.dart'; +import '../../utilities/default_sol_tokens.dart'; + +/// A token mint discovered in a wallet, with its decimals when known. +class DiscoveredSolMint { + const DiscoveredSolMint({ + this.mint = '', + this.decimals, + this.rawBalance, + this.hasConflictingDecimals = false, + }); + + final String mint; + final int? decimals; + final BigInt? rawBalance; + final bool hasConflictingDecimals; +} + /// Exception for Solana token API errors. class SolanaTokenApiException implements Exception { final String message; @@ -110,30 +127,57 @@ class TokenAccountInfo { 'TokenAccountInfo(address=$address, owner=$owner, mint=$mint, balance=$balance, decimals=$decimals)'; } +abstract interface class SolanaTokenDiscoveryClient { + Future getTokenAccountsByProgram({ + required String walletAddress, + required String programId, + }); + + Future getAccountInfo(String mintAddress); +} + +class _RpcSolanaTokenDiscoveryClient implements SolanaTokenDiscoveryClient { + const _RpcSolanaTokenDiscoveryClient(this._client); + + final RpcClient _client; + + @override + Future getTokenAccountsByProgram({ + required String walletAddress, + required String programId, + }) => _client.getTokenAccountsByOwner( + walletAddress, + TokenAccountsFilter.byProgramId(programId), + encoding: Encoding.jsonParsed, + ); + + @override + Future getAccountInfo(String mintAddress) => + _client.getAccountInfo(mintAddress, encoding: Encoding.jsonParsed); +} + /// Solana SPL Token API service. /// /// Provides methods to interact with Solana token accounts and metadata /// using RPC calls. Uses the solana package's RpcClient under the hood. class SolanaTokenAPI { - static final SolanaTokenAPI _instance = SolanaTokenAPI._internal(); + factory SolanaTokenAPI({ + RpcClient? rpcClient, + SolanaTokenDiscoveryClient? discoveryClient, + }) => SolanaTokenAPI._( + rpcClient, + discoveryClient ?? + (rpcClient == null ? null : _RpcSolanaTokenDiscoveryClient(rpcClient)), + ); - factory SolanaTokenAPI() { - return _instance; - } - - SolanaTokenAPI._internal(); + SolanaTokenAPI._(this._rpcClient, this._discoveryClient); - RpcClient? _rpcClient; - - void initializeRpcClient(RpcClient rpcClient) { - _rpcClient = rpcClient; - } + final RpcClient? _rpcClient; + final SolanaTokenDiscoveryClient? _discoveryClient; void _checkClient() { if (_rpcClient == null) { - throw SolanaTokenApiException( - 'RPC client not initialized. Call initializeRpcClient() first.', - ); + throw SolanaTokenApiException('RPC client not configured.'); } } @@ -274,7 +318,7 @@ class SolanaTokenAPI { _checkClient(); // Return placeholder data. - // + // // TODO: Implement actual RPC call using proper client methods. return SolanaTokenApiResponse( value: TokenAccountInfo( @@ -332,34 +376,248 @@ class SolanaTokenAPI { } Future?>> - fetchTokenMetadataByMint( - String mintAddress, - ) async { + fetchTokenMetadataByMint(String mintAddress) async { try { - _checkClient(); - - // TODO: Implement proper metadata PDA derivation when solana package - // exposes findProgramAddress() utilities. - // - // The Solana Token Metadata program (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) - // stores token metadata at a PDA derived from the mint address using: - // findProgramAddress( - // ["metadata", metadataProgram, mintPubkey], - // metadataProgram - // ) - // - // Until then, return null to allow users to enter custom token details. + // Resolve name/symbol/logo from the bundled known token list when the + // mint matches a well known token. + for (final token in DefaultSolTokens.list) { + if (token.address == mintAddress) { + return SolanaTokenApiResponse?>( + value: { + "name": token.name, + "symbol": token.symbol, + "decimals": token.decimals, + "logoUri": token.logoUri, + }, + ); + } + } - // Metadata PDA derivation not yet implemented - return SolanaTokenApiResponse?>( - value: null, - ); + // On-chain metadata lookup is not implemented here: it would require + // deriving the Token Metadata program PDA + // (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) from the mint and + // decoding the Metaplex account, which the solana package does not yet + // expose helpers for. Returning null lets callers fall back to a + // mint-derived placeholder name/symbol while still using the correct + // on-chain decimals. + return SolanaTokenApiResponse?>(value: null); } on Exception { // On error, return null to allow user to manually enter token details - return SolanaTokenApiResponse?>( - value: null, + return SolanaTokenApiResponse?>(value: null); + } + } + + /// Discover all SPL token mints held by a wallet. + /// + /// Queries the wallet's token accounts for both the standard SPL Token + /// program and the Token2022 program, then extracts the unique mint + /// addresses from those accounts along with the number of decimals each + /// mint is configured with. The decimals are read directly from the parsed + /// token account data ('tokenAmount.decimals'), which mirrors the value + /// stored on the mint account, so balances are scaled correctly. + Future>> + discoverTokensForWallet({required String walletAddress}) async { + try { + final client = _discoveryClient; + if (client == null) { + throw SolanaTokenApiException('RPC client not configured.'); + } + + const splTokenProgramId = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; + const token2022ProgramId = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; + + final splResponse = await client.getTokenAccountsByProgram( + walletAddress: walletAddress, + programId: splTokenProgramId, ); + + final token2022Response = await client.getTokenAccountsByProgram( + walletAddress: walletAddress, + programId: token2022ProgramId, + ); + + final accounts = [...splResponse.value, ...token2022Response.value]; + + final byMint = {}; + for (final account in accounts) { + final extracted = _extractMintFromParsedTokenAccount( + account.account.data, + ); + final mint = extracted.mint; + final balance = extracted.rawBalance; + if (mint.isEmpty || balance == null) { + continue; + } + + final existing = byMint[mint]; + if (existing == null) { + byMint[mint] = extracted; + } else { + final conflict = + existing.hasConflictingDecimals || + existing.decimals != null && + extracted.decimals != null && + existing.decimals != extracted.decimals; + final decimals = conflict + ? null + : existing.decimals ?? extracted.decimals; + byMint[mint] = DiscoveredSolMint( + mint: mint, + decimals: decimals, + rawBalance: existing.rawBalance! + balance, + hasConflictingDecimals: conflict, + ); + } + } + + // For any mint whose decimals could not be read from the token account + // data, fetch the mint account directly and read its decimals. + final resolved = []; + for (final entry in byMint.values) { + // Positive wrapped/native token accounts are holdings too; closed or + // zero-balance accounts are not. + if (entry.rawBalance! <= BigInt.zero) { + continue; + } + if (entry.decimals != null) { + resolved.add(entry); + } else { + final decimals = await _fetchMintDecimals(entry.mint); + resolved.add( + DiscoveredSolMint( + mint: entry.mint, + decimals: decimals, + rawBalance: entry.rawBalance, + hasConflictingDecimals: + decimals == null && entry.hasConflictingDecimals, + ), + ); + } + } + + return SolanaTokenApiResponse>(value: resolved); + } on Exception catch (e) { + return SolanaTokenApiResponse>( + exception: SolanaTokenApiException( + 'Failed to discover tokens: ${e.toString()}', + originalException: e, + ), + ); + } + } + + /// Both token programs store a mint's decimals as a u8, so anything outside + /// 0..255 came from a malformed or hostile node. The value ends up as + /// Amount.fractionDigits on every send and balance for that token, where a + /// negative one throws and a huge one makes Decimal arithmetic crawl, so + /// treat it as unknown rather than persisting it to the token catalog. + static int? _validMintDecimals(int? decimals) { + if (decimals == null || decimals < 0 || decimals > 255) { + return null; + } + return decimals; + } + + /// Fetch the number of decimals configured on a token's mint account. + /// + /// Used as a fallback when the decimals could not be read from a parsed + /// token account. Returns null if the mint account cannot be read or parsed. + Future _fetchMintDecimals(String mintAddress) async { + try { + final response = await _discoveryClient!.getAccountInfo(mintAddress); + + final data = response.value?.data; + if (data is ParsedAccountData) { + return data.when( + splToken: (spl) => spl.when( + account: (info, type, accountType) => null, + mint: (info, type, accountType) => + _validMintDecimals(info.decimals), + unknown: (type) => null, + ), + token2022: (token2022data) => token2022data.when( + account: (info, type, accountType) => null, + mint: (info, type, accountType) => + _validMintDecimals(info.decimals), + unknown: (type) => null, + ), + stake: (_) => null, + unsupported: (_) => null, + ); + } + } catch (_) { + // Ignore and report unknown decimals. + } + + return null; + } + + /// Extract the mint address and decimals from a parsed token account's data. + /// + /// Handles both standard SPL Token and Token2022 account data. The decimals + /// come from 'tokenAmount.decimals' on the holding, which matches the value + /// stored on the mint account. Returns an empty mint when the data is not a + /// token account or cannot be parsed, and null decimals when unavailable. + DiscoveredSolMint _extractMintFromParsedTokenAccount(dynamic data) { + try { + if (data is ParsedAccountData) { + return data.when( + splToken: (spl) => spl.when( + account: (info, type, accountType) => DiscoveredSolMint( + mint: info.mint, + decimals: _validMintDecimals(info.tokenAmount.decimals), + rawBalance: BigInt.tryParse(info.tokenAmount.amount), + ), + mint: (info, type, accountType) => const DiscoveredSolMint(), + unknown: (type) => const DiscoveredSolMint(), + ), + token2022: (token2022data) => token2022data.when( + account: (info, type, accountType) => DiscoveredSolMint( + mint: info.mint, + decimals: _validMintDecimals(info.tokenAmount.decimals), + rawBalance: BigInt.tryParse(info.tokenAmount.amount), + ), + mint: (info, type, accountType) => const DiscoveredSolMint(), + unknown: (type) => const DiscoveredSolMint(), + ), + stake: (_) => const DiscoveredSolMint(), + unsupported: (_) => const DiscoveredSolMint(), + ); + } + + if (data is Map) { + final parsed = data['parsed']; + if (parsed is Map) { + final info = parsed['info']; + if (info is Map) { + final mint = info['mint']; + if (mint is String) { + int? decimals; + BigInt? rawBalance; + final tokenAmount = info['tokenAmount']; + if (tokenAmount is Map) { + final d = tokenAmount['decimals']; + decimals = _validMintDecimals( + d is int ? d : int.tryParse(d?.toString() ?? ''), + ); + rawBalance = BigInt.tryParse( + tokenAmount['amount']?.toString() ?? '', + ); + } + return DiscoveredSolMint( + mint: mint, + decimals: decimals, + rawBalance: rawBalance, + ); + } + } + } + } + } catch (_) { + // Ignore parsing errors and treat as no mint found. } + + return const DiscoveredSolMint(); } /// Validate if a string is a valid Solana mint address. diff --git a/lib/wallets/isar/providers/solana/discovered_sol_tokens_provider.dart b/lib/wallets/isar/providers/solana/discovered_sol_tokens_provider.dart new file mode 100644 index 0000000000..5d8ace1d08 --- /dev/null +++ b/lib/wallets/isar/providers/solana/discovered_sol_tokens_provider.dart @@ -0,0 +1,142 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../models/isar/models/solana/sol_contract.dart'; +import '../../../../providers/global/wallets_provider.dart'; +import '../../../../services/solana/solana_token_api.dart'; +import '../../../../utilities/logger.dart'; +import '../../../../wallets/wallet/impl/solana_wallet.dart'; + +/// Discovers the SPL tokens held by a wallet and resolves their metadata. +/// +/// Returns a list of [SolContract]s for the mints found in the wallet's token +/// accounts. Metadata is fetched per mint, falling back to placeholder values +/// when it cannot be resolved. +final pDiscoveredSolanaTokens = FutureProvider.autoDispose + .family, ({String walletId, String walletAddress})>(( + ref, + params, + ) async { + final wallet = ref.read(pWallets).getWallet(params.walletId); + if (wallet is! SolanaWallet) { + throw Exception("Wallet ${params.walletId} is not a Solana wallet"); + } + + final rpcClient = wallet.getRpcClient(); + if (rpcClient == null) { + throw Exception("RPC client not available for ${params.walletId}"); + } + + final api = SolanaTokenAPI(rpcClient: rpcClient); + + final mintResponse = await api.discoverTokensForWallet( + walletAddress: params.walletAddress, + ); + + if (!mintResponse.isSuccess || mintResponse.value == null) { + throw mintResponse.exception ?? + Exception("Token discovery failed for ${params.walletId}"); + } + + final mints = mintResponse.value!; + Logging.instance.i( + "Discovered ${mints.length} SPL token mint(s) for ${params.walletId}", + ); + + final tokens = await resolveDiscoveredSolanaContracts( + mints: mints, + fetchMetadata: api.fetchTokenMetadataByMint, + ); + + final unresolved = mints.length - tokens.length; + if (unresolved > 0) { + Logging.instance.w( + "Skipped $unresolved Solana token mint(s) with unknown decimals", + ); + } + + return tokens; + }); + +/// Await a [pDiscoveredSolanaTokens] run while holding a subscription to it. +/// +/// The provider is autoDispose and nothing in the widget tree watches it, so +/// riverpod schedules the element for disposal as soon as it is read without +/// a listener. Disposal completes `.future` with a [StateError] instead of the +/// discovered tokens, and it always wins the race against an RPC round trip. +/// Closing the subscription once the run finishes lets the element be disposed +/// again, so the next visit rediscovers rather than replaying a cached list. +Future> readDiscoveredSolanaTokens( + ProviderContainer container, { + required String walletId, + required String walletAddress, +}) async { + final subscription = container.listen>>( + pDiscoveredSolanaTokens(( + walletId: walletId, + walletAddress: walletAddress, + )).future, + (_, __) {}, + ); + try { + return await subscription.read(); + } finally { + subscription.close(); + } +} + +Future> resolveDiscoveredSolanaContracts({ + required Iterable mints, + required Future?>> Function( + String mint, + ) + fetchMetadata, +}) => Future.wait( + mints.where((mint) => mint.decimals != null).map((discovered) async { + final mint = discovered.mint; + final metadata = (await fetchMetadata(mint)).value; + return SolContract( + address: mint, + name: metadata?["name"] as String? ?? _placeholderName(mint), + symbol: metadata?["symbol"] as String? ?? _placeholderSymbol(mint), + decimals: discovered.decimals!, + logoUri: metadata?["logoUri"] as String?, + ); + }), +); + +List newDiscoveredSolanaContracts({ + required Iterable knownContracts, + required Iterable discoveredContracts, +}) { + final knownMints = knownContracts.map((contract) => contract.address).toSet(); + return discoveredContracts + .where((contract) => knownMints.add(contract.address)) + .toList(); +} + +/// Build a short, human readable placeholder name from a mint address when no +/// metadata could be resolved. +String _placeholderName(String mint) { + if (mint.length <= 10) { + return "Token $mint"; + } + return "Token ${mint.substring(0, 4)}...${mint.substring(mint.length - 4)}"; +} + +/// Build a short placeholder symbol from a mint address when no metadata could +/// be resolved. +String _placeholderSymbol(String mint) { + if (mint.length <= 4) { + return mint.toUpperCase(); + } + return mint.substring(0, 4).toUpperCase(); +} 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 5fb99a19a6..875f4a0f32 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -655,8 +655,7 @@ class SolanaTokenWallet extends Wallet { return; } - final tokenApi = SolanaTokenAPI(); - tokenApi.initializeRpcClient(rpcClient); + final tokenApi = SolanaTokenAPI(rpcClient: rpcClient); final balanceResponse = await tokenApi.getTokenAccountBalance( senderTokenAccount, @@ -815,9 +814,6 @@ class SolanaTokenWallet extends Wallet { final ownerPubkey = Ed25519HDPublicKey.fromBase58(ownerAddress); final mintPubkey = Ed25519HDPublicKey.fromBase58(mint); - final tokenApi = SolanaTokenAPI(); - tokenApi.initializeRpcClient(rpcClient); - String tokenProgramId; try { final mintInfo = await rpcClient.getAccountInfo( diff --git a/test/services/solana/solana_token_discovery_test.dart b/test/services/solana/solana_token_discovery_test.dart new file mode 100644 index 0000000000..0de3859183 --- /dev/null +++ b/test/services/solana/solana_token_discovery_test.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:solana/dto.dart'; +import 'package:stackwallet/models/isar/models/solana/sol_contract.dart'; +import 'package:stackwallet/services/solana/solana_token_api.dart'; +import 'package:stackwallet/wallets/isar/providers/solana/discovered_sol_tokens_provider.dart'; + +class _FakeDiscoveryClient implements SolanaTokenDiscoveryClient { + _FakeDiscoveryClient(this.responses, {this.mintAccounts = const {}}); + + final List Function()> responses; + final Map mintAccounts; + final programIds = []; + final mintLookups = []; + var _index = 0; + + @override + Future getTokenAccountsByProgram({ + required String walletAddress, + required String programId, + }) { + programIds.add(programId); + return responses[_index++](); + } + + @override + Future getAccountInfo(String mintAddress) async { + mintLookups.add(mintAddress); + return mintAccounts[mintAddress] ?? + (throw StateError("Unexpected mint lookup: $mintAddress")); + } +} + +void main() { + ProgramAccount tokenAccount({ + required String mint, + required String amount, + required int decimals, + bool token2022 = false, + }) => ProgramAccount( + pubkey: "account-$mint-$amount", + account: Account( + lamports: 1, + owner: token2022 ? "token-2022" : "spl-token", + executable: false, + rentEpoch: BigInt.zero, + data: token2022 + ? ParsedAccountData.token2022( + SplTokenProgramAccountData.account( + type: "account", + info: SplTokenAccountDataInfo( + tokenAmount: TokenAmount( + amount: amount, + decimals: decimals, + uiAmountString: null, + ), + state: "initialized", + isNative: false, + mint: mint, + owner: "owner", + ), + ), + ) + : ParsedAccountData.splToken( + SplTokenProgramAccountData.account( + type: "account", + info: SplTokenAccountDataInfo( + tokenAmount: TokenAmount( + amount: amount, + decimals: decimals, + uiAmountString: null, + ), + state: "initialized", + isNative: false, + mint: mint, + owner: "owner", + ), + ), + ), + ), + ); + + ProgramAccountsResult response(List accounts) => + ProgramAccountsResult( + context: Context(slot: BigInt.one), + value: accounts, + ); + + AccountResult mintAccount(int decimals) => AccountResult( + context: Context(slot: BigInt.one), + value: Account( + lamports: 1, + owner: "spl-token", + executable: false, + rentEpoch: BigInt.zero, + data: ParsedAccountData.splToken( + SplTokenProgramAccountData.mint( + type: "mint", + info: MintAccountDataInfo( + mintAuthority: null, + freezedAuthority: null, + isInitialized: true, + decimals: decimals, + supply: "1", + ), + ), + ), + ), + ); + + test("discovers positive SPL and Token-2022 balances only", () async { + final client = _FakeDiscoveryClient([ + () async => response([ + tokenAccount(mint: "closed", amount: "0", decimals: 6), + tokenAccount(mint: "malformed", amount: "invalid", decimals: 6), + tokenAccount(mint: "held", amount: "5", decimals: 8), + ]), + () async => response([ + tokenAccount(mint: "held", amount: "7", decimals: 8, token2022: true), + tokenAccount( + mint: "token-2022", + amount: "1", + decimals: 2, + token2022: true, + ), + ]), + ]); + + final result = await SolanaTokenAPI( + discoveryClient: client, + ).discoverTokensForWallet(walletAddress: "owner"); + + expect(result.exception, isNull); + expect(result.value!.map((mint) => mint.mint), ["held", "token-2022"]); + expect(result.value!.first.rawBalance, BigInt.from(12)); + expect(result.value!.first.decimals, 8); + expect(client.programIds, [ + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + ]); + }); + + test( + "conflicting decimals remain unresolved when mint lookup fails", + () async { + final client = _FakeDiscoveryClient([ + () async => response([ + tokenAccount(mint: "conflict", amount: "1", decimals: 6), + tokenAccount(mint: "conflict", amount: "1", decimals: 8), + tokenAccount(mint: "conflict", amount: "1", decimals: 6), + ]), + () async => response(const []), + ]); + + final result = await SolanaTokenAPI( + discoveryClient: client, + ).discoverTokensForWallet(walletAddress: "owner"); + + expect(result.value!.single.mint, "conflict"); + expect(result.value!.single.decimals, isNull); + expect(result.value!.single.hasConflictingDecimals, isTrue); + expect( + await resolveDiscoveredSolanaContracts( + mints: result.value!, + fetchMetadata: (_) async => SolanaTokenApiResponse(value: null), + ), + isEmpty, + ); + }, + ); + + test("concurrent API instances retain their own RPC clients", () async { + final releaseFirst = Completer(); + final firstClient = _FakeDiscoveryClient([ + () => releaseFirst.future, + () async => response(const []), + ]); + final secondClient = _FakeDiscoveryClient([ + () async => + response([tokenAccount(mint: "second", amount: "1", decimals: 6)]), + () async => response(const []), + ]); + + final firstFuture = SolanaTokenAPI( + discoveryClient: firstClient, + ).discoverTokensForWallet(walletAddress: "first-owner"); + final secondFuture = SolanaTokenAPI( + discoveryClient: secondClient, + ).discoverTokensForWallet(walletAddress: "second-owner"); + releaseFirst.complete( + response([tokenAccount(mint: "first", amount: "1", decimals: 4)]), + ); + + expect((await firstFuture).value!.single.mint, "first"); + expect((await secondFuture).value!.single.mint, "second"); + expect(firstClient.programIds, hasLength(2)); + expect(secondClient.programIds, hasLength(2)); + }); + + test("unknown decimals are skipped instead of becoming zero", () async { + final contracts = await resolveDiscoveredSolanaContracts( + mints: [ + DiscoveredSolMint(mint: "known", decimals: 6, rawBalance: BigInt.one), + DiscoveredSolMint(mint: "unknown", rawBalance: BigInt.one), + ], + fetchMetadata: (_) async => SolanaTokenApiResponse(value: null), + ); + + expect(contracts.single.address, "known"); + expect(contracts.single.decimals, 6); + }); + + test("unknown metadata uses a mint-derived placeholder", () async { + final contracts = await resolveDiscoveredSolanaContracts( + mints: [ + DiscoveredSolMint( + mint: "1234567890abcdef", + decimals: 9, + rawBalance: BigInt.one, + ), + ], + fetchMetadata: (_) async => SolanaTokenApiResponse(value: null), + ); + + expect(contracts.single.name, "Token 1234...cdef"); + expect(contracts.single.symbol, "1234"); + expect(contracts.single.decimals, 9); + }); + + test("RPC failure is returned without partial discovery", () async { + final client = _FakeDiscoveryClient([ + () async => throw Exception("offline"), + ]); + + final result = await SolanaTokenAPI( + discoveryClient: client, + ).discoverTokensForWallet(walletAddress: "owner"); + + expect(result.value, isNull); + expect(result.exception, isA()); + expect(result.exception.toString(), contains("offline")); + }); + + test("decimals outside the u8 range are re-read from the mint", () async { + final client = _FakeDiscoveryClient( + [ + () async => response([ + tokenAccount(mint: "hostile", amount: "5", decimals: -7), + ]), + () async => response(const []), + ], + mintAccounts: {"hostile": mintAccount(6)}, + ); + + final result = await SolanaTokenAPI( + discoveryClient: client, + ).discoverTokensForWallet(walletAddress: "owner"); + + expect(client.mintLookups, ["hostile"]); + expect(result.value!.single.decimals, 6); + }); + + test("decimals no source can vouch for stay unknown", () async { + final client = _FakeDiscoveryClient( + [ + () async => response([ + tokenAccount(mint: "hostile", amount: "5", decimals: 1000000), + ]), + () async => response(const []), + ], + mintAccounts: {"hostile": mintAccount(-3)}, + ); + + final result = await SolanaTokenAPI( + discoveryClient: client, + ).discoverTokensForWallet(walletAddress: "owner"); + + expect(result.value!.single.decimals, isNull); + expect( + await resolveDiscoveredSolanaContracts( + mints: result.value!, + fetchMetadata: (_) async => SolanaTokenApiResponse(value: null), + ), + isEmpty, + ); + }); + + group("reading the discovery provider", () { + var runs = 0; + + ProviderContainer containerWithDiscovery() { + runs = 0; + final container = ProviderContainer( + overrides: [ + pDiscoveredSolanaTokens.overrideWithProvider( + (params) => FutureProvider.autoDispose>(( + ref, + ) async { + runs++; + // Stands in for the RPC round trip; anything asynchronous at all + // outlives the autoDispose garbage collection pass. + await Future.delayed(const Duration(milliseconds: 20)); + return [ + SolContract( + address: params.walletId, + name: "name", + symbol: "sym", + decimals: 6, + ), + ]; + }), + ), + ], + ); + addTearDown(container.dispose); + return container; + } + + test("delivers the discovered tokens", () async { + final container = containerWithDiscovery(); + + final discovered = await readDiscoveredSolanaTokens( + container, + walletId: "wallet-a", + walletAddress: "address-a", + ); + + expect(discovered.single.address, "wallet-a"); + expect(runs, 1); + + // Why the helper exists: an unlistened read is disposed before the + // provider can emit. + await expectLater( + container.read( + pDiscoveredSolanaTokens(( + walletId: "wallet-b", + walletAddress: "address-b", + )).future, + ), + throwsStateError, + ); + }); + + test("rediscovers on the next visit", () async { + final container = containerWithDiscovery(); + + for (var visit = 0; visit < 2; visit++) { + await readDiscoveredSolanaTokens( + container, + walletId: "wallet-a", + walletAddress: "address-a", + ); + await pumpEventQueue(); + } + + expect(runs, 2); + }); + }); + + test("only genuinely new contracts are auto-selected", () { + SolContract contract(String mint) => + SolContract(address: mint, name: mint, symbol: mint, decimals: 6); + + final added = newDiscoveredSolanaContracts( + knownContracts: [contract("existing")], + discoveredContracts: [ + contract("existing"), + contract("new"), + contract("new"), + ], + ); + + expect(added.map((contract) => contract.address), ["new"]); + }); +}