Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ class CustomTokenStorage implements CustomTokenStore {
Future<void> storeCustomToken(Asset asset) async {
_log.fine('Storing custom token ${asset.id.id}');
final box = await _openCustomTokensBox();
await _validateCanStoreAsset(box, asset);
await box.put(asset.id.id, asset);
}

@override
Future<void> storeCustomTokens(List<Asset> assets) async {
_log.fine('Storing ${assets.length} custom tokens');
final box = await _openCustomTokensBox();
_validateBatchCollisions(assets);
for (final asset in assets) {
await _validateCanStoreAsset(box, asset);
}
final putMap = <String, Asset>{for (final a in assets) a.id.id: a};
await box.putAll(putMap);
}
Expand Down Expand Up @@ -127,7 +132,9 @@ class CustomTokenStorage implements CustomTokenStore {
@override
Future<bool> upsertCustomToken(Asset asset) async {
final box = await _openCustomTokensBox();
final existed = box.containsKey(asset.id.id);
final existingAsset = await box.get(asset.id.id);
final existed = existingAsset != null;
_assertNoConflict(existingAsset, asset);
await box.put(asset.id.id, asset);

if (existed) {
Expand All @@ -142,7 +149,9 @@ class CustomTokenStorage implements CustomTokenStore {
@override
Future<bool> addCustomTokenIfNotExists(Asset asset) async {
final box = await _openCustomTokensBox();
if (box.containsKey(asset.id.id)) {
final existingAsset = await box.get(asset.id.id);
if (existingAsset != null) {
_assertNoConflict(existingAsset, asset);
_log.fine('Custom token ${asset.id.id} already exists, skipping');
return false;
}
Expand Down Expand Up @@ -185,6 +194,72 @@ class CustomTokenStorage implements CustomTokenStore {
return _customTokensBox!;
}

Future<void> _validateCanStoreAsset(LazyBox<Asset> box, Asset asset) async {
final existingAsset = await box.get(asset.id.id);
_assertNoConflict(existingAsset, asset);
}

void _validateBatchCollisions(List<Asset> assets) {
final assetsById = <String, Asset>{};
for (final asset in assets) {
final existingAsset = assetsById[asset.id.id];
_assertNoConflict(existingAsset, asset);
assetsById[asset.id.id] = asset;
}
}

void _assertNoConflict(Asset? existingAsset, Asset requestedAsset) {
if (existingAsset == null) {
return;
}

if (_hasMatchingContract(existingAsset, requestedAsset)) {
return;
}

throw CustomTokenConflictException(
assetId: requestedAsset.id.id,
network: requestedAsset.id.subClass,
existingContractAddress: existingAsset.protocol.contractAddress ?? '',
requestedContractAddress: requestedAsset.protocol.contractAddress ?? '',
);
}

bool _hasMatchingContract(Asset existingAsset, Asset requestedAsset) {
final hasMatchingIdentity =
existingAsset.id.subClass == requestedAsset.id.subClass &&
existingAsset.id.chainId.formattedChainId ==
requestedAsset.id.chainId.formattedChainId &&
existingAsset.id.parentId == requestedAsset.id.parentId;
if (!hasMatchingIdentity) {
return false;
}

final existingContractAddress = existingAsset.protocol.contractAddress;
final requestedContractAddress = requestedAsset.protocol.contractAddress;
if (existingContractAddress == null || requestedContractAddress == null) {
return false;
}

return _normalizeContractAddress(
existingAsset.id.subClass,
existingContractAddress,
) ==
_normalizeContractAddress(
requestedAsset.id.subClass,
requestedContractAddress,
);
}

String _normalizeContractAddress(
CoinSubClass network,
String contractAddress,
) {
return network == CoinSubClass.trc20
? contractAddress
: contractAddress.toLowerCase();
}

ProtocolClass _markCustomToken(ProtocolClass protocol) {
return switch (protocol) {
final Erc20Protocol p => p.copyWith(isCustomToken: true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ abstract class CustomTokenStore {
Future<void> init();

/// Stores a single custom token.
/// If a token with the same AssetId already exists, it will be overwritten.
/// If a token with the same storage key already exists, it is overwritten
/// only when it represents the same contract on the same network.
/// Otherwise, [CustomTokenConflictException] is thrown.
Future<void> storeCustomToken(Asset asset);

/// Stores multiple custom tokens atomically (all-or-nothing).
/// Existing tokens with the same AssetIds will be overwritten.
/// Existing tokens with the same storage key are overwritten only when they
/// represent the same contract on the same network.
/// Otherwise, [CustomTokenConflictException] is thrown.
/// Implementations should throw on partial failure.
Future<void> storeCustomTokens(List<Asset> assets);

Expand Down Expand Up @@ -39,11 +43,14 @@ abstract class CustomTokenStore {
Future<bool> hasCustomTokens();

/// Upserts a custom token: updates if it exists, inserts otherwise.
/// Returns true if updated, false if inserted.
/// Returns true if updated, false if inserted. Throws
/// [CustomTokenConflictException] for same-key different-contract writes.
Future<bool> upsertCustomToken(Asset asset);

/// Adds a custom token to storage if it doesn't already exist.
/// Returns true if the token was added, false if it already existed.
/// Returns true if the token was added, false if the same contract already
/// existed. Throws [CustomTokenConflictException] for same-key
/// different-contract writes.
Future<bool> addCustomTokenIfNotExists(Asset asset);

/// Returns the number of custom tokens in storage.
Expand Down
180 changes: 180 additions & 0 deletions packages/komodo_coin_updates/test/custom_token_storage_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import 'package:komodo_coin_updates/komodo_coin_updates.dart';
import 'package:komodo_defi_types/komodo_defi_types.dart';
import 'package:test/test.dart';

import 'hive/test_harness.dart';

Map<String, dynamic> _trxConfig() => {
'coin': 'TRX',
'type': 'TRX',
'name': 'TRON',
'fname': 'TRON',
'wallet_only': true,
'mm2': 1,
'decimals': 6,
'required_confirmations': 1,
'derivation_path': "m/44'/195'",
'protocol': {
'type': 'TRX',
'protocol_data': {'network': 'Mainnet'},
},
'nodes': <Map<String, dynamic>>[],
};

Map<String, dynamic> _trc20Config({
required String coin,
required String name,
required String contractAddress,
}) => {
'coin': coin,
'type': 'TRC-20',
'name': name,
'fname': name,
'wallet_only': true,
'mm2': 1,
'decimals': 6,
'derivation_path': "m/44'/195'",
'protocol': {
'type': 'TRC20',
'protocol_data': {'platform': 'TRX', 'contract_address': contractAddress},
},
'contract_address': contractAddress,
'parent_coin': 'TRX',
'nodes': <Map<String, dynamic>>[],
};

Asset _buildTrc20Asset({
required Asset platformAsset,
required String coin,
required String name,
required String contractAddress,
}) {
return Asset.fromJson(
_trc20Config(coin: coin, name: name, contractAddress: contractAddress),
knownIds: {platformAsset.id},
);
}

void main() {
group('CustomTokenStorage', () {
late HiveTestEnv hiveEnv;
late CustomTokenStorage storage;
late Asset platformAsset;

setUp(() async {
hiveEnv = HiveTestEnv();
await hiveEnv.setup();
storage = CustomTokenStorage(customTokensBoxName: 'custom_tokens_test');
await storage.init();
platformAsset = Asset.fromJson(_trxConfig(), knownIds: const {});
});

tearDown(() async {
await storage.dispose();
await hiveEnv.dispose();
});

test(
'upsert allows replacing an existing token with the same contract',
() async {
final original = _buildTrc20Asset(
platformAsset: platformAsset,
coin: 'USDT-TRC20',
name: 'Tether USD',
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
);
final updated = _buildTrc20Asset(
platformAsset: platformAsset,
coin: 'USDT-TRC20',
name: 'Tether USD Updated',
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
);

await storage.storeCustomToken(original);
final didUpdate = await storage.upsertCustomToken(updated);
final stored = await storage.getCustomToken(updated.id);

expect(didUpdate, isTrue);
expect(stored?.id.name, updated.id.name);
expect(
stored?.protocol.contractAddress,
updated.protocol.contractAddress,
);
},
);

test('store rejects same-id different-contract collisions', () async {
final original = _buildTrc20Asset(
platformAsset: platformAsset,
coin: 'USDT-TRC20',
name: 'Tether USD',
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
);
final conflicting = _buildTrc20Asset(
platformAsset: platformAsset,
coin: 'USDT-TRC20',
name: 'Another USDT',
contractAddress: 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj',
);

await storage.storeCustomToken(original);

await expectLater(
storage.storeCustomToken(conflicting),
throwsA(isA<CustomTokenConflictException>()),
);

final stored = await storage.getCustomToken(original.id);
expect(await storage.getCustomTokenCount(), 1);
expect(
stored?.protocol.contractAddress,
original.protocol.contractAddress,
);
});

test(
'addCustomTokenIfNotExists is idempotent for the same contract',
() async {
final asset = _buildTrc20Asset(
platformAsset: platformAsset,
coin: 'USDT-TRC20',
name: 'Tether USD',
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
);

final firstInsert = await storage.addCustomTokenIfNotExists(asset);
final secondInsert = await storage.addCustomTokenIfNotExists(asset);

expect(firstInsert, isTrue);
expect(secondInsert, isFalse);
expect(await storage.getCustomTokenCount(), 1);
},
);

test(
'storeCustomTokens fails atomically for conflicting batch entries',
() async {
final first = _buildTrc20Asset(
platformAsset: platformAsset,
coin: 'USDT-TRC20',
name: 'Tether USD',
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
);
final conflicting = _buildTrc20Asset(
platformAsset: platformAsset,
coin: 'USDT-TRC20',
name: 'Another USDT',
contractAddress: 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj',
);

await expectLater(
storage.storeCustomTokens([first, conflicting]),
throwsA(isA<CustomTokenConflictException>()),
);

expect(await storage.getCustomTokenCount(), 0);
expect(await storage.getCustomToken(first.id), isNull);
},
);
});
}
2 changes: 1 addition & 1 deletion packages/komodo_defi_framework/app_build/build_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"coins": {
"fetch_at_build_enabled": true,
"update_commit_on_build": true,
"bundled_coins_repo_commit": "e027082339558cc79d653d0e871f0d211562fe2f",
"bundled_coins_repo_commit": "f74c985339ff45de8768ab6c8be02ed8e2d0f49a",
"coins_repo_api_url": "https://api.github.com/repos/GLEECBTC/coins",
"coins_repo_content_url": "https://raw.githubusercontent.com/GLEECBTC/coins",
"coins_repo_branch": "master",
Expand Down
Loading
Loading