Skip to content
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import 'package:hive_ce/hive.dart';
import 'package:komodo_coin_updates/src/coins_config/coin_config_provider.dart';
import 'package:komodo_coin_updates/src/coins_config/coin_config_storage.dart';
import 'package:komodo_coin_updates/src/coins_config/config_transform.dart';
import 'package:komodo_coin_updates/src/coins_config/github_coin_config_provider.dart';
import 'package:komodo_coin_updates/src/coins_config/_coins_config_index.dart';
import 'package:komodo_defi_types/komodo_defi_types.dart';
import 'package:logging/logging.dart';

Expand All @@ -19,7 +16,8 @@ class CoinConfigRepository implements CoinConfigStorage {
this.assetsBoxName = 'assets',
this.settingsBoxName = 'coins_settings',
this.coinsCommitKey = 'coins_commit',
});
AssetParser assetParser = const AssetParser(),
}) : _assetParser = assetParser;

/// Convenience factory that derives a provider from a runtime config and
/// uses default Hive boxes (`assets`, `coins_settings`).
Expand All @@ -30,11 +28,13 @@ class CoinConfigRepository implements CoinConfigStorage {
this.assetsBoxName = 'assets',
this.settingsBoxName = 'coins_settings',
this.coinsCommitKey = 'coins_commit',
AssetParser assetParser = const AssetParser(),
}) : coinConfigProvider = GithubCoinConfigProvider.fromConfig(
config,
githubToken: githubToken,
transformer: transformer,
);
),
_assetParser = assetParser;
static final Logger _log = Logger('CoinConfigRepository');

/// The provider that fetches the coins and coin configs.
Expand All @@ -52,6 +52,8 @@ class CoinConfigRepository implements CoinConfigStorage {
/// The key for the coins commit. The value is the commit hash.
final String coinsCommitKey;

final AssetParser _assetParser;

/// Fetches the latest commit from the provider, downloads assets for that
/// commit, and upserts them in local storage along with the commit hash.
/// Throws an [Exception] if the request fails at any step.
Expand Down Expand Up @@ -101,6 +103,11 @@ class CoinConfigRepository implements CoinConfigStorage {
@override
/// Retrieves all assets from storage, excluding any whose symbol appears
/// in [excludedAssets]. Returns an empty list if storage is empty.
///
/// This method implements a two-pass parsing strategy to rebuild parent-child
/// relationships between assets, similar to AssetParser:
/// 1. First pass: Parse platform assets (no parent) to get their AssetIds
/// 2. Second pass: Reparse child assets using known platform AssetIds
Future<List<Asset>> getAssets({
List<String> excludedAssets = const <String>[],
}) async {
Expand All @@ -112,16 +119,25 @@ class CoinConfigRepository implements CoinConfigStorage {
final values = await Future.wait(
keys.map((dynamic key) => box.get(key as String)),
);
final result = values
final allAssetConfigs = values
.whereType<Asset>()
.where((a) => !excludedAssets.contains(a.id.id))
.toList();
_log.fine('Retrieved ${result.length} assets');
return result;
.map(
(asset) =>
MapEntry(asset.id.symbol.assetConfigId, asset.protocol.config),
);

final transformedConfigs = Map<String, Map<String, dynamic>>.fromEntries(
allAssetConfigs,
);
return _assetParser.parseAssetsFromConfig(transformedConfigs);
}

@override
/// Retrieves a single [Asset] by its [assetId] from storage.
/// NOTE: Parent/child relationships are not rebuilt for single asset retrieval.
/// Use [getAssets] if you need proper parent relationships.
Comment on lines +138 to +139
Copy link

Copilot AI Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This limitation could lead to inconsistent behavior when retrieving individual assets vs collections. Consider adding a warning log or throwing an exception when parent/child relationships are expected but not available in single asset retrieval.

Copilot uses AI. Check for mistakes.
/// Returns `null` if the asset is not found.
Future<Asset?> getAsset(AssetId assetId) async {
_log.fine('Retrieving asset ${assetId.id}');
final a = await (await _openAssetsBox()).get(assetId.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,15 @@ class StartupCoinsProvider {
await manager.init();

final assets = manager.all;
final configs = <JsonMap>[
for (final asset in assets.values) asset.protocol.config,
];
// Sort to avoid random ordering of params that causes segfault on linux
final configs =
<JsonMap>[for (final asset in assets.values) asset.protocol.config]
..sort((a, b) {
final aId = a['coin'] as String? ?? '';
final bId = b['coin'] as String? ?? '';
return aId.compareTo(bId);
});

return JsonList.of(configs);
} finally {
try {
Expand Down
39 changes: 29 additions & 10 deletions packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -192,24 +192,43 @@ class ActivationManager {
if (progress.isSuccess) {
final user = await _auth.currentUser;
if (user != null) {
await _assetHistory.addAssetToWallet(
user.walletId,
group.primary.id.id,
);
// TODO: consider abstracting this and other custom token operations out
// of the activation manager
if (group.primary.protocol.isCustomToken) {
await _customTokenHistory.addAssetToWallet(
user.walletId,
group.primary,
_assetLookup.available.keys.toSet(),
);
} else {
await _assetHistory.addAssetToWallet(
user.walletId,
group.primary.id.id,
);
}

final allAssets = [group.primary, ...(group.children?.toList() ?? [])];

// Wait for asset refresh to complete before precaching balances to ensure
// custom token is available for balance precaching. This prevents race
// conditions where balance precaching fails because the custom token
// isn't yet available in the asset lookup.
if (allAssets.any((asset) => asset.protocol.isCustomToken)) {
await _assetRefreshNotifier.notifyAndWaitForCustomTokensRefresh();
}

for (final asset in allAssets) {
if (asset.protocol.isCustomToken) {
await _customTokenHistory.addAssetToWallet(user.walletId, asset);
await _customTokenHistory.addAssetToWallet(
user.walletId,
asset,
_assetLookup.available.keys.toSet(),
);
Comment thread
takenagain marked this conversation as resolved.
Comment thread
takenagain marked this conversation as resolved.
}

// Pre-cache balance for the activated asset
await _balanceManager.precacheBalance(asset);
}

// Notify asset manager to refresh custom tokens if any were activated
if (allAssets.any((asset) => asset.protocol.isCustomToken)) {
_assetRefreshNotifier.notifyCustomTokensChanged();
}
}

if (!completer.isCompleted) {
Expand Down
4 changes: 3 additions & 1 deletion packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ abstract class IAssetProvider extends IAssetLookup {
}

/// Interface for notifying about asset changes that require UI refresh
// ignore: one_member_abstracts
abstract interface class IAssetRefreshNotifier {
/// Notifies that custom tokens have changed and should be refreshed
void notifyCustomTokensChanged();

/// Notifies that custom tokens have changed and waits for refresh to complete
Future<void> notifyAndWaitForCustomTokensRefresh();
}
11 changes: 11 additions & 0 deletions packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier {

final customTokens = await _customAssetHistory.getWalletAssets(
user.walletId,
_orderedCoins.keys.toSet(),
);

final filteredCustomTokens = _filterCustomTokens(customTokens);
Expand Down Expand Up @@ -283,6 +284,16 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier {
);
}

@override
Future<void> notifyAndWaitForCustomTokensRefresh() async {
try {
await _refreshCustomTokens();
} catch (e) {
debugPrint('Custom token refresh failed: $e');
rethrow;
}
}

/// Filters custom tokens based on the current asset filtering strategy.
///
/// Custom tokens don't have traditional coin configs, so we create a minimal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@ class CustomAssetHistoryStorage {
}

/// Add a single asset to wallet's history
Future<void> addAssetToWallet(WalletId walletId, Asset asset) async {
final assets = await getWalletAssets(walletId);
// Equatable operators not working as expected, so we need to check manually
///
/// [walletId] is the wallet to add the asset to.
/// [asset] is the asset to add to the wallet.
/// [knownAssets] is used to find the parent asset for child assets.
Future<void> addAssetToWallet(
WalletId walletId,
Asset asset,
Set<AssetId> knownAssets,
) async {
final assets = await getWalletAssets(walletId, knownAssets);
if (assets.any((historicalAsset) => historicalAsset.id.id == asset.id.id)) {
return;
}
Expand All @@ -32,15 +39,25 @@ class CustomAssetHistoryStorage {
}

/// Get all assets previously used by a wallet
Future<Set<Asset>> getWalletAssets(WalletId walletId) async {
///
/// [walletId] is the wallet to get the assets from.
/// [knownAssets] is used to find the parent asset for child assets.
Future<Set<Asset>> getWalletAssets(
WalletId walletId,
Set<AssetId> knownAssets,
) async {
final key = _getStorageKey(walletId);
final value = await _storage.read(key: key);
if (value == null || value.isEmpty) return {};
final assetsJsonArray = jsonListFromString(value);
return assetsJsonArray.map(Asset.fromJson).toSet();
return assetsJsonArray
.map((json) => Asset.fromJson(json, knownIds: knownAssets))
.toSet();
}

/// Clear wallet's custom token history
///
/// [walletId] is the wallet to clear the assets from.
Future<void> clearWalletAssets(WalletId walletId) async {
final key = _getStorageKey(walletId);
await _storage.delete(key: key);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:http/http.dart' as http;
import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart';
import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart';
Expand Down Expand Up @@ -63,8 +63,9 @@ class EtherscanTransactionStrategy extends TransactionHistoryStrategy {
for (final address in addresses) {
final uri = url.replace(
pathSegments: [...url.pathSegments, address.address],
queryParameters:
asset.protocol.isTestnet ? {'testnet': 'true'} : null,
queryParameters: asset.protocol.isTestnet
? {'testnet': 'true'}
: null,
);
// Add the address as the next path segment

Expand All @@ -88,14 +89,14 @@ class EtherscanTransactionStrategy extends TransactionHistoryStrategy {
t.fromId,
t.itemCount,
),
_ =>
throw UnsupportedError(
'Unsupported pagination type: ${pagination.runtimeType}',
),
_ => throw UnsupportedError(
'Unsupported pagination type: ${pagination.runtimeType}',
),
};

final currentBlock =
allTransactions.isNotEmpty ? allTransactions.first.blockHeight : 0;
final currentBlock = allTransactions.isNotEmpty
? allTransactions.first.blockHeight
: 0;

return MyTxHistoryResponse(
mmrpc: RpcVersion.v2_0,
Expand Down Expand Up @@ -152,13 +153,12 @@ class EtherscanTransactionStrategy extends TransactionHistoryStrategy {
blockHeight: tx.value<int>('block_height'),
confirmations: tx.value<int>('confirmations'),
timestamp: tx.value<int>('timestamp'),
feeDetails:
tx.valueOrNull<JsonMap>('fee_details') != null
? FeeInfo.fromJson(
tx.value<JsonMap>('fee_details')
..setIfAbsentOrEmpty('type', 'EthGas'),
)
: null,
feeDetails: tx.valueOrNull<JsonMap>('fee_details') != null
? FeeInfo.fromJson(
tx.value<JsonMap>('fee_details')
..setIfAbsentOrEmpty('type', 'EthGas'),
)
: null,
coin: coinId,
internalId: tx.value<String>('internal_id'),
memo: tx.valueOrNull<String>('memo'),
Expand Down Expand Up @@ -252,7 +252,12 @@ class EtherscanProtocolHelper {
}

final protocol = asset.protocol as Erc20Protocol;
return '$baseEndpoint/${protocol.swapContractAddress}';
final tokenContractAddress = protocol.contractAddress;
if (tokenContractAddress == null || tokenContractAddress.isEmpty) {
return null;
}

return '$baseEndpoint/$tokenContractAddress';
}

String? _getBaseEndpoint(AssetId id) {
Expand Down
Loading