Migrate EVM/Sol/TRX tokens to SQL (#3450)

* fix android CI

* feat: Migrate EVM, Sol and Tron tokens to SQL

* handle rename edgecase

* refactor: enhance token migration logic and improve database handling
This commit is contained in:
David Adegoke
2026-08-20 03:19:05 +02:00
committed by GitHub
parent 887e01a7e7
commit 4cc1495686
23 changed files with 1708 additions and 677 deletions
+74 -1
View File
@@ -63,7 +63,7 @@ Future<void> _initDb({String? pathOverride}) async {
}
}
await db?.close();
db = await openDatabase(dbFile.path, version: 9,
db = await openDatabase(dbFile.path, version: 10,
onUpgrade: (Database db, int oldVersion, int newVersion) async {
printV("migrating: $oldVersion, $newVersion");
if (oldVersion <= 1) {
@@ -147,6 +147,11 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
definition: 'BOOLEAN DEFAULT FALSE',
);
}
if (oldVersion <= 9) {
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
}
}, onCreate: (Database db, int version) async {
await db.execute('''
CREATE TABLE WalletInfo (
@@ -242,6 +247,9 @@ CREATE TABLE BalanceCardStyleSettings (
await _createBridgeTransferTable(db);
await _createNodeTable(db);
await _createTradeTable(db);
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
});
}
@@ -370,6 +378,71 @@ ON BridgeTransfer(wallet_id);
''');
}
Future<void> _createErc20TokenTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS Erc20Token (
Erc20TokenId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
walletName TEXT NOT NULL,
chainId INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '',
symbol TEXT NOT NULL DEFAULT '',
contractAddress TEXT NOT NULL,
decimal INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
iconPath TEXT,
tag TEXT,
isPotentialScam INTEGER NOT NULL DEFAULT 0
);
""");
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_erc20token_wallet_chain_contract
ON Erc20Token (walletName, chainId, contractAddress);
""");
}
Future<void> _createSplTokenTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS SPLToken (
SPLTokenId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
walletName TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
symbol TEXT NOT NULL DEFAULT '',
mintAddress TEXT NOT NULL,
decimal INTEGER NOT NULL DEFAULT 0,
mint TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
iconPath TEXT,
tag TEXT,
isPotentialScam INTEGER NOT NULL DEFAULT 0
);
""");
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_spltoken_wallet_mint
ON SPLToken (walletName, mintAddress);
""");
}
Future<void> _createTronTokenTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS TronToken (
TronTokenId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
walletName TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
symbol TEXT NOT NULL DEFAULT '',
contractAddress TEXT NOT NULL,
decimal INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
iconPath TEXT,
tag TEXT,
isPotentialScam INTEGER NOT NULL DEFAULT 0
);
""");
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_trontoken_wallet_contract
ON TronToken (walletName, contractAddress);
""");
}
Future<void> _createNodeTable(Database db) async {
db.execute("""
CREATE TABLE Node (
+153 -45
View File
@@ -1,32 +1,8 @@
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/hive_type_ids.dart';
import 'package:hive/hive.dart';
part 'erc20_token.part.dart';
// @HiveType(typeId: Erc20Token.typeId)
class Erc20Token extends CryptoCurrency with HiveObjectMixin {
// @HiveField(0)
final String name;
// @HiveField(1)
final String symbol;
// @HiveField(2)
final String contractAddress;
// @HiveField(3)
final int decimal;
// @HiveField(4, defaultValue: true)
bool _enabled;
// @HiveField(5)
String? iconPath;
// @HiveField(6)
final String? tag;
// @HiveField(7, defaultValue: false)
bool isPotentialScam;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/db/sqlite.dart";
import "package:sqflite/sqflite.dart";
class Erc20Token extends CryptoCurrency {
Erc20Token({
required this.name,
required this.symbol,
@@ -36,6 +12,9 @@ class Erc20Token extends CryptoCurrency with HiveObjectMixin {
this.iconPath,
this.tag,
this.isPotentialScam = false,
this.id = 0,
this.walletName,
this.chainId,
}) : _enabled = enabled,
super(
name: symbol.toLowerCase(),
@@ -47,32 +26,161 @@ class Erc20Token extends CryptoCurrency with HiveObjectMixin {
isPotentialScam: isPotentialScam,
);
Erc20Token.copyWith(Erc20Token other, {String? icon, String? tag, bool? enabled})
: this.name = other.name,
this.symbol = other.symbol,
this.contractAddress = other.contractAddress,
this.decimal = other.decimal,
this._enabled = enabled ?? other.enabled,
this.tag = tag ?? other.tag,
this.iconPath = icon ?? other.iconPath,
this.isPotentialScam = other.isPotentialScam,
Erc20Token.copyWith(
Erc20Token other, {
String? icon,
super.tag,
bool? enabled,
String? walletName,
int? chainId,
}) : name = other.name,
symbol = other.symbol,
contractAddress = other.contractAddress,
decimal = other.decimal,
_enabled = enabled ?? other.enabled,
tag = tag ?? other.tag,
iconPath = icon ?? other.iconPath,
isPotentialScam = other.isPotentialScam,
id = 0,
walletName = walletName ?? other.walletName,
chainId = chainId ?? other.chainId,
super(
name: other.name,
title: other.symbol.toUpperCase(),
fullName: other.name,
tag: tag,
iconPath: icon,
decimals: other.decimal,
isPotentialScam: other.isPotentialScam,
);
static const typeId = ERC20_TOKEN_TYPE_ID;
static const boxName = 'Erc20Tokens';
static const ethereumBoxName = 'EthereumErc20Tokens';
static const polygonBoxName = 'PolygonErc20Tokens';
static const baseBoxName = 'BaseErc20Tokens';
static const arbitrumBoxName = 'ArbitrumErc20Tokens';
static const bscBoxName = 'BscErc20Tokens';
Erc20Token.fromMap(Map<String, Object?> map)
: this(
name: map["name"] as String? ?? "",
symbol: map["symbol"] as String? ?? "",
contractAddress: map["contractAddress"] as String? ?? "",
decimal: (map["decimal"] ?? 0) as int,
enabled: _getBoolFromDB(map["enabled"], defaultValue: true),
iconPath: map["iconPath"] as String?,
tag: map["tag"] as String?,
isPotentialScam: _getBoolFromDB(map["isPotentialScam"]),
id: (map[selfIdColumn] ?? 0) as int,
walletName: map["walletName"] as String?,
chainId: map["chainId"] as int?,
);
@override
final String name;
@override
final String symbol;
@override
String? iconPath;
@override
final String? tag;
@override
bool isPotentialScam;
@override
bool get enabled => _enabled;
@override
set enabled(bool value) => _enabled = value;
int id;
int? chainId;
bool _enabled;
final int decimal;
String? walletName;
final String contractAddress;
static bool _getBoolFromDB(value, {bool? defaultValue}) {
if (value is bool) {
return value;
} else if (value is int) {
return value == 1;
} else {
return defaultValue ?? false;
}
}
Map<String, dynamic> toMap() => {
selfIdColumn: id,
"walletName": walletName,
"chainId": chainId,
"name": name,
"symbol": symbol,
"contractAddress": contractAddress.toLowerCase(),
"decimal": decimal,
"enabled": _enabled ? 1 : 0,
"iconPath": iconPath,
"tag": tag,
"isPotentialScam": isPotentialScam ? 1 : 0,
};
static String get tableName => "Erc20Token";
static String get selfIdColumn => "${tableName}Id";
Future<int> save() async {
if (walletName == null || chainId == null) {
throw StateError("Erc20Token.save() requires walletName and chainId to be set");
}
final json = toMap();
if (json[selfIdColumn] == 0) {
json[selfIdColumn] = null;
}
id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace);
return id;
}
static Future<List<Erc20Token>> selectList(
String where,
List<dynamic> whereArgs, {
String? orderBy,
}) async {
orderBy ??= selfIdColumn;
final list = await db!.query(
tableName,
where: where.isNotEmpty ? where : "1 = 1",
whereArgs: whereArgs.isNotEmpty ? whereArgs : null,
orderBy: orderBy,
);
return List.generate(list.length, (index) => Erc20Token.fromMap(list[index]));
}
static Future<List<Erc20Token>> getAllForWallet(String walletName, int chainId) =>
selectList("walletName = ? AND chainId = ?", [walletName, chainId]);
static Future<Erc20Token?> getByContract(
String walletName,
int chainId,
String contractAddress,
) async {
final list = await selectList(
"walletName = ? AND chainId = ? AND contractAddress = ?",
[walletName, chainId, contractAddress.toLowerCase()],
);
return list.isEmpty ? null : list.first;
}
static Future<int> deleteForWallet(String walletName, int chainId, String contractAddress) =>
db!.delete(
tableName,
where: "walletName = ? AND chainId = ? AND contractAddress = ?",
whereArgs: [walletName, chainId, contractAddress.toLowerCase()],
);
static Future<int> deleteAllForWallet(String walletName) =>
db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]);
static Future<void> renameWallet(String oldName, String newName) async {
await db!.delete(tableName, where: "walletName = ?", whereArgs: [newName]);
await db!
.update(tableName, {"walletName": newName}, where: "walletName = ?", whereArgs: [oldName]);
}
@override
bool operator ==(Object other) => other is Erc20Token && other.contractAddress == contractAddress;
+213
View File
@@ -0,0 +1,213 @@
import "package:cw_core/cake_hive.dart";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/erc20_token.dart" as erc20_new;
import "package:cw_core/hive_type_ids.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:hive/hive.dart";
import "package:sqflite/sqflite.dart";
part "erc20_token_legacy.part.dart";
Future<void> performErc20TokenHiveMigration() async {
try {
if (!CakeHive.isAdapterRegistered(Erc20Token.typeId)) {
CakeHive.registerAdapter(Erc20TokenAdapter());
}
final wallets = await WalletInfo.getAll();
await Erc20Token.migrateAllToSqlite(wallets);
} catch (e) {
printV("Error performing Erc20Token Hive migration: $e, continuing anyway");
}
}
// @HiveType(typeId: Erc20Token.typeId)
class Erc20Token extends HiveObject {
Erc20Token({
required this.name,
required this.symbol,
required this.contractAddress,
required this.decimal,
bool enabled = true,
this.iconPath,
this.tag,
this.isPotentialScam = false,
}) : _enabled = enabled;
// @HiveField(0)
final String name;
// @HiveField(1)
final String symbol;
// @HiveField(2)
final String contractAddress;
// @HiveField(3)
final int decimal;
// @HiveField(4, defaultValue: true)
bool _enabled;
// @HiveField(5)
String? iconPath;
// @HiveField(6)
final String? tag;
// @HiveField(7, defaultValue: false)
bool isPotentialScam;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
static const typeId = ERC20_TOKEN_TYPE_ID;
static const boxName = "Erc20Tokens";
static const ethereumBoxName = "EthereumErc20Tokens";
static const polygonBoxName = "PolygonErc20Tokens";
static const baseBoxName = "BaseErc20Tokens";
static const arbitrumBoxName = "ArbitrumErc20Tokens";
static const bscBoxName = "BscErc20Tokens";
static const chainIdToBoxSuffix = {
1: ethereumBoxName,
137: polygonBoxName,
8453: baseBoxName,
42161: arbitrumBoxName,
56: bscBoxName,
};
static const evmWalletTypes = [
WalletType.ethereum,
WalletType.polygon,
WalletType.base,
WalletType.arbitrum,
WalletType.bsc,
];
static Future<void> migrateAllToSqlite(List<WalletInfo> wallets) async {
await _migrateLegacyGlobalBox(wallets);
final sanitizedToRawNames = <String, Set<String>>{};
for (final wallet in wallets) {
if (!evmWalletTypes.contains(wallet.type)) {
continue;
}
sanitizedToRawNames
.putIfAbsent(wallet.name.replaceAll(" ", "_"), () => <String>{})
.add(wallet.name);
}
for (final entry in sanitizedToRawNames.entries) {
for (final chainEntry in chainIdToBoxSuffix.entries) {
final tokenBoxName = "${entry.key}_${chainEntry.value}";
try {
if (!await CakeHive.boxExists(tokenBoxName)) {
continue;
}
final box = await CakeHive.openBox<Erc20Token>(tokenBoxName);
for (final group in _mergeByLowercaseContract(box)) {
for (final rawName in entry.value) {
await group.token.migrateToSqlite(walletName: rawName, chainId: chainEntry.key);
}
await box.deleteAll(group.sourceKeys);
}
await box.deleteFromDisk();
} catch (e) {
printV("Error migrating erc20 token box $tokenBoxName: $e, continuing anyway");
}
}
}
}
static Future<void> _migrateLegacyGlobalBox(List<WalletInfo> wallets) async {
try {
if (!await CakeHive.boxExists(boxName)) {
return;
}
if (!wallets.any((wallet) => wallet.type == WalletType.ethereum)) {
return;
}
final box = await CakeHive.openBox<Erc20Token>(boxName);
final ethereumWallets =
wallets.where((wallet) => wallet.type == WalletType.ethereum).toList();
for (final group in _mergeByLowercaseContract(box)) {
for (final wallet in ethereumWallets) {
await group.token.migrateToSqlite(walletName: wallet.name, chainId: 1);
}
await box.deleteAll(group.sourceKeys);
}
await box.deleteFromDisk();
} catch (e) {
printV("Error migrating legacy global erc20 token box: $e, continuing anyway");
}
}
static List<_MergedTokenGroup> _mergeByLowercaseContract(Box<Erc20Token> box) {
final groups = <String, _MergedTokenGroup>{};
for (final key in box.keys) {
final token = box.get(key);
if (token == null) {
continue;
}
final lowerKey = token.contractAddress.toLowerCase();
final existing = groups[lowerKey];
if (existing == null) {
groups[lowerKey] = _MergedTokenGroup(token: token, sourceKeys: [key]);
continue;
}
groups[lowerKey] = _MergedTokenGroup(
token: Erc20Token(
name: token.name,
symbol: token.symbol,
contractAddress: lowerKey,
decimal: token.decimal,
enabled: token.enabled || existing.token.enabled,
iconPath:
(token.iconPath?.isNotEmpty ?? false) ? token.iconPath : existing.token.iconPath,
tag: token.tag ?? existing.token.tag,
isPotentialScam: token.isPotentialScam || existing.token.isPotentialScam,
),
sourceKeys: [...existing.sourceKeys, key],
);
}
return groups.values.toList();
}
Future<void> migrateToSqlite({required String walletName, required int chainId}) async {
final row = erc20_new.Erc20Token(
name: name,
symbol: symbol,
contractAddress: contractAddress,
decimal: decimal,
enabled: _enabled,
iconPath: iconPath,
tag: tag,
isPotentialScam: isPotentialScam,
walletName: walletName,
chainId: chainId,
).toMap();
row[erc20_new.Erc20Token.selfIdColumn] = null;
await db!.insert(
erc20_new.Erc20Token.tableName,
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
class _MergedTokenGroup {
_MergedTokenGroup({required this.token, required this.sourceKeys});
final Erc20Token token;
final List<dynamic> sourceKeys;
}
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'erc20_token.dart';
part of "erc20_token_legacy.dart";
// **************************************************************************
// TypeAdapterGenerator
+130 -52
View File
@@ -1,41 +1,8 @@
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/hive_type_ids.dart';
import 'package:hive/hive.dart';
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/db/sqlite.dart";
import "package:sqflite/sqflite.dart";
part 'spl_token.part.dart';
// @HiveType(typeId: SPLToken.typeId)
class SPLToken extends CryptoCurrency with HiveObjectMixin {
@override
// @HiveField(0)
final String name;
// @HiveField(1)
final String symbol;
// @HiveField(2)
final String mintAddress;
// @HiveField(3)
final int decimal;
// @HiveField(4, defaultValue: true)
bool _enabled;
// @HiveField(5)
final String mint;
@override
// @HiveField(6)
final String? iconPath;
@override
// @HiveField(7)
final String? tag;
@override
// @HiveField(8, defaultValue: false)
bool isPotentialScam;
class SPLToken extends CryptoCurrency {
SPLToken({
required this.name,
@@ -44,10 +11,12 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin {
required this.decimal,
required this.mint,
this.iconPath,
this.tag = 'SOL',
this.tag = "SOL",
bool enabled = true,
this.isPotentialScam = false,
Set<String> groups = const {},
super.groups,
this.id = 0,
this.walletName,
}) : _enabled = enabled,
super(
name: mint.toLowerCase(),
@@ -57,7 +26,6 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin {
iconPath: iconPath,
decimals: decimal,
isPotentialScam: isPotentialScam,
groups: groups,
);
factory SPLToken.fromMetadata({
@@ -67,8 +35,7 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin {
required String mintAddress,
String? iconPath,
bool isPotentialScam = false,
}) {
return SPLToken(
}) => SPLToken(
name: name,
symbol: symbol,
mintAddress: mintAddress,
@@ -77,15 +44,8 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin {
iconPath: iconPath,
isPotentialScam: isPotentialScam,
);
}
@override
bool get enabled => _enabled;
@override
set enabled(bool value) => _enabled = value;
SPLToken.copyWith(SPLToken other, {String? icon, String? tag, bool? enabled})
SPLToken.copyWith(SPLToken other, {String? icon, String? tag, bool? enabled, String? walletName})
: name = other.name,
symbol = other.symbol,
mintAddress = other.mintAddress,
@@ -95,6 +55,8 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin {
tag = tag ?? other.tag,
iconPath = icon ?? other.iconPath,
isPotentialScam = other.isPotentialScam,
id = 0,
walletName = walletName ?? other.walletName,
super(
title: other.symbol.toUpperCase(),
name: other.symbol.toLowerCase(),
@@ -106,8 +68,124 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin {
groups: other.groups,
);
static const typeId = SPL_TOKEN_TYPE_ID;
static const boxName = 'SPLTokens';
SPLToken.fromMap(Map<String, Object?> map)
: this(
name: map["name"] as String? ?? "",
symbol: map["symbol"] as String? ?? "",
mintAddress: map["mintAddress"] as String? ?? "",
decimal: (map["decimal"] ?? 0) as int,
mint: map["mint"] as String? ?? "",
enabled: _getBoolFromDB(map["enabled"], defaultValue: true),
iconPath: map["iconPath"] as String?,
tag: map["tag"] as String?,
isPotentialScam: _getBoolFromDB(map["isPotentialScam"]),
id: (map[selfIdColumn] ?? 0) as int,
walletName: map["walletName"] as String?,
);
@override
final String name;
@override
final String symbol;
final String mintAddress;
final int decimal;
bool _enabled;
final String mint;
@override
final String? iconPath;
@override
final String? tag;
@override
bool isPotentialScam;
int id;
String? walletName;
@override
bool get enabled => _enabled;
@override
set enabled(bool value) => _enabled = value;
static bool _getBoolFromDB(value, {bool? defaultValue}) {
if (value is bool) {
return value;
} else if (value is int) {
return value == 1;
} else {
return defaultValue ?? false;
}
}
Map<String, dynamic> toMap() => {
selfIdColumn: id,
"walletName": walletName,
"name": name,
"symbol": symbol,
"mintAddress": mintAddress,
"decimal": decimal,
"mint": mint,
"enabled": _enabled ? 1 : 0,
"iconPath": iconPath,
"tag": tag,
"isPotentialScam": isPotentialScam ? 1 : 0,
};
static String get tableName => "SPLToken";
static String get selfIdColumn => "${tableName}Id";
Future<int> save() async {
if (walletName == null) {
throw StateError("SPLToken.save() requires walletName to be set");
}
final json = toMap();
if (json[selfIdColumn] == 0) {
json[selfIdColumn] = null;
}
id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace);
return id;
}
static Future<List<SPLToken>> selectList(String where, List<dynamic> whereArgs,
{String? orderBy}) async {
orderBy ??= selfIdColumn;
final list = await db!.query(
tableName,
where: where.isNotEmpty ? where : "1 = 1",
whereArgs: whereArgs.isNotEmpty ? whereArgs : null,
orderBy: orderBy,
);
return List.generate(list.length, (index) => SPLToken.fromMap(list[index]));
}
static Future<List<SPLToken>> getAllForWallet(String walletName) async => selectList("walletName = ?", [walletName]);
static Future<SPLToken?> getByMint(String walletName, String mintAddress) async {
final list = await selectList("walletName = ? AND mintAddress = ?", [walletName, mintAddress]);
return list.isEmpty ? null : list.first;
}
static Future<int> deleteForWallet(String walletName, String mintAddress) => db!.delete(
tableName,
where: "walletName = ? AND mintAddress = ?",
whereArgs: [walletName, mintAddress],
);
static Future<int> deleteAllForWallet(String walletName) => db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]);
static Future<void> renameWallet(String oldName, String newName) async {
await db!.delete(tableName, where: "walletName = ?", whereArgs: [newName]);
await db!
.update(tableName, {"walletName": newName}, where: "walletName = ?", whereArgs: [oldName]);
}
@override
bool operator ==(other) =>
+134
View File
@@ -0,0 +1,134 @@
import "package:cw_core/cake_hive.dart";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/hive_type_ids.dart";
import "package:cw_core/spl_token.dart" as spl_new;
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:hive/hive.dart";
import "package:sqflite/sqflite.dart";
part "spl_token_legacy.part.dart";
Future<void> performSplTokenHiveMigration() async {
try {
if (!CakeHive.isAdapterRegistered(SPLToken.typeId)) {
CakeHive.registerAdapter(SPLTokenAdapter());
}
final wallets = await WalletInfo.getAll();
await SPLToken.migrateAllToSqlite(wallets);
} catch (e) {
printV("Error performing SPLToken Hive migration: $e, continuing anyway");
}
}
// @HiveType(typeId: SPLToken.typeId)
class SPLToken extends HiveObject {
SPLToken({
required this.name,
required this.symbol,
required this.mintAddress,
required this.decimal,
required this.mint,
this.iconPath,
this.tag = "SOL",
bool enabled = true,
this.isPotentialScam = false,
}) : _enabled = enabled;
// @HiveField(0)
final String name;
// @HiveField(1)
final String symbol;
// @HiveField(2)
final String mintAddress;
// @HiveField(3)
final int decimal;
// @HiveField(4, defaultValue: true)
bool _enabled;
// @HiveField(5)
final String mint;
// @HiveField(6)
final String? iconPath;
// @HiveField(7)
final String? tag;
// @HiveField(8, defaultValue: false)
bool isPotentialScam;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
static const typeId = SPL_TOKEN_TYPE_ID;
static const boxName = "SPLTokens";
static Future<void> migrateAllToSqlite(List<WalletInfo> wallets) async {
final sanitizedToRawNames = <String, Set<String>>{};
for (final wallet in wallets) {
if (wallet.type != WalletType.solana) {
continue;
}
sanitizedToRawNames
.putIfAbsent(wallet.name.replaceAll(" ", "_"), () => <String>{})
.add(wallet.name);
}
for (final entry in sanitizedToRawNames.entries) {
final tokenBoxName = "${entry.key}_$boxName";
try {
if (!await CakeHive.boxExists(tokenBoxName)) {
continue;
}
final box = await CakeHive.openBox<SPLToken>(tokenBoxName);
for (final key in box.keys.toList()) {
final token = box.get(key);
if (token == null) {
continue;
}
for (final rawName in entry.value) {
await token.migrateToSqlite(walletName: rawName);
}
await box.delete(key);
}
await box.deleteFromDisk();
} catch (e) {
printV("Error migrating spl token box $tokenBoxName: $e, continuing anyway");
}
}
}
Future<void> migrateToSqlite({required String walletName}) async {
final row = spl_new.SPLToken(
name: name,
symbol: symbol,
mintAddress: mintAddress,
decimal: decimal,
mint: mint,
enabled: _enabled,
iconPath: iconPath,
tag: tag,
isPotentialScam: isPotentialScam,
walletName: walletName,
).toMap();
row[spl_new.SPLToken.selfIdColumn] = null;
await db!.insert(
spl_new.SPLToken.tableName,
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'spl_token.dart';
part of "spl_token_legacy.dart";
// **************************************************************************
// TypeAdapterGenerator
+132 -42
View File
@@ -1,41 +1,8 @@
// ignore_for_file: annotate_overrides, overridden_fields
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/hive_type_ids.dart';
import 'package:hive/hive.dart';
part 'tron_token.part.dart';
// @HiveType(typeId: TronToken.typeId)
class TronToken extends CryptoCurrency with HiveObjectMixin {
// @HiveField(0)
final String name;
// @HiveField(1)
final String symbol;
// @HiveField(2)
final String contractAddress;
// @HiveField(3)
final int decimal;
// @HiveField(4, defaultValue: true)
bool _enabled;
// @HiveField(5)
final String? iconPath;
// @HiveField(6)
final String? tag;
// @HiveField(7, defaultValue: false)
final bool isPotentialScam;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/db/sqlite.dart";
import "package:sqflite/sqflite.dart";
class TronToken extends CryptoCurrency {
TronToken({
required this.name,
required this.symbol,
@@ -43,8 +10,10 @@ class TronToken extends CryptoCurrency with HiveObjectMixin {
required this.decimal,
bool enabled = true,
this.iconPath,
this.tag = 'TRX',
this.tag = "TRX",
this.isPotentialScam = false,
this.id = 0,
this.walletName,
}) : _enabled = enabled,
super(
name: symbol.toLowerCase(),
@@ -55,9 +24,27 @@ class TronToken extends CryptoCurrency with HiveObjectMixin {
decimals: decimal,
isPotentialScam: isPotentialScam,
);
TronToken.fromMap(Map<String, Object?> map)
: this(
name: map["name"] as String? ?? "",
symbol: map["symbol"] as String? ?? "",
contractAddress: map["contractAddress"] as String? ?? "",
decimal: (map["decimal"] ?? 0) as int,
enabled: _getBoolFromDB(map["enabled"], defaultValue: true),
iconPath: map["iconPath"] as String?,
tag: map["tag"] as String?,
isPotentialScam: _getBoolFromDB(map["isPotentialScam"]),
id: (map[selfIdColumn] ?? 0) as int,
walletName: map["walletName"] as String?,
);
TronToken.copyWith(TronToken other, {String? icon, String? tag, bool? enabled})
: name = other.name,
TronToken.copyWith(
TronToken other, {
String? icon,
String? tag,
bool? enabled,
String? walletName,
}) : name = other.name,
symbol = other.symbol,
contractAddress = other.contractAddress,
decimal = other.decimal,
@@ -65,6 +52,8 @@ class TronToken extends CryptoCurrency with HiveObjectMixin {
tag = tag ?? other.tag,
iconPath = icon ?? other.iconPath,
isPotentialScam = other.isPotentialScam,
id = 0,
walletName = walletName ?? other.walletName,
super(
name: other.name,
title: other.symbol.toUpperCase(),
@@ -74,9 +63,110 @@ class TronToken extends CryptoCurrency with HiveObjectMixin {
decimals: other.decimal,
isPotentialScam: other.isPotentialScam,
);
@override
final String name;
static const typeId = TRON_TOKEN_TYPE_ID;
static const boxName = 'TronTokens';
@override
final String symbol;
@override
final String? iconPath;
@override
final String? tag;
@override
final bool isPotentialScam;
@override
bool get enabled => _enabled;
@override
set enabled(bool value) => _enabled = value;
int id;
bool _enabled;
final int decimal;
String? walletName;
final String contractAddress;
static bool _getBoolFromDB(value, {bool? defaultValue}) {
if (value is bool) {
return value;
} else if (value is int) {
return value == 1;
} else {
return defaultValue ?? false;
}
}
Map<String, dynamic> toMap() => {
selfIdColumn: id,
"walletName": walletName,
"name": name,
"symbol": symbol,
"contractAddress": contractAddress,
"decimal": decimal,
"enabled": _enabled ? 1 : 0,
"iconPath": iconPath,
"tag": tag,
"isPotentialScam": isPotentialScam ? 1 : 0,
};
static String get tableName => "TronToken";
static String get selfIdColumn => "${tableName}Id";
Future<int> save() async {
if (walletName == null) {
throw StateError("TronToken.save() requires walletName to be set");
}
final json = toMap();
if (json[selfIdColumn] == 0) {
json[selfIdColumn] = null;
}
id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace);
return id;
}
static Future<List<TronToken>> selectList(
String where,
List<dynamic> whereArgs, {
String? orderBy,
}) async {
orderBy ??= selfIdColumn;
final list = await db!.query(
tableName,
where: where.isNotEmpty ? where : "1 = 1",
whereArgs: whereArgs.isNotEmpty ? whereArgs : null,
orderBy: orderBy,
);
return List.generate(list.length, (index) => TronToken.fromMap(list[index]));
}
static Future<List<TronToken>> getAllForWallet(String walletName) =>
selectList("walletName = ?", [walletName]);
static Future<TronToken?> getByContract(String walletName, String contractAddress) async {
final list =
await selectList("walletName = ? AND contractAddress = ?", [walletName, contractAddress]);
return list.isEmpty ? null : list.first;
}
static Future<int> deleteForWallet(String walletName, String contractAddress) => db!.delete(
tableName,
where: "walletName = ? AND contractAddress = ?",
whereArgs: [walletName, contractAddress],
);
static Future<int> deleteAllForWallet(String walletName) =>
db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]);
static Future<void> renameWallet(String oldName, String newName) async {
await db!.delete(tableName, where: "walletName = ?", whereArgs: [newName]);
await db!
.update(tableName, {"walletName": newName}, where: "walletName = ?", whereArgs: [oldName]);
}
@override
bool operator ==(other) =>
+129
View File
@@ -0,0 +1,129 @@
import "package:cw_core/cake_hive.dart";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/hive_type_ids.dart";
import "package:cw_core/tron_token.dart" as tron_new;
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:hive/hive.dart";
import "package:sqflite/sqflite.dart";
part "tron_token_legacy.part.dart";
Future<void> performTronTokenHiveMigration() async {
try {
if (!CakeHive.isAdapterRegistered(TronToken.typeId)) {
CakeHive.registerAdapter(TronTokenAdapter());
}
final wallets = await WalletInfo.getAll();
await TronToken.migrateAllToSqlite(wallets);
} catch (e) {
printV("Error performing TronToken Hive migration: $e, continuing anyway");
}
}
// @HiveType(typeId: TronToken.typeId)
class TronToken extends HiveObject {
TronToken({
required this.name,
required this.symbol,
required this.contractAddress,
required this.decimal,
bool enabled = true,
this.iconPath,
this.tag = "TRX",
this.isPotentialScam = false,
}) : _enabled = enabled;
// @HiveField(0)
final String name;
// @HiveField(1)
final String symbol;
// @HiveField(2)
final String contractAddress;
// @HiveField(3)
final int decimal;
// @HiveField(4, defaultValue: true)
bool _enabled;
// @HiveField(5)
final String? iconPath;
// @HiveField(6)
final String? tag;
// @HiveField(7, defaultValue: false)
final bool isPotentialScam;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
static const typeId = TRON_TOKEN_TYPE_ID;
static const boxName = "TronTokens";
static Future<void> migrateAllToSqlite(List<WalletInfo> wallets) async {
final sanitizedToRawNames = <String, Set<String>>{};
for (final wallet in wallets) {
if (wallet.type != WalletType.tron) {
continue;
}
sanitizedToRawNames
.putIfAbsent(wallet.name.replaceAll(" ", "_"), () => <String>{})
.add(wallet.name);
}
for (final entry in sanitizedToRawNames.entries) {
final tokenBoxName = "${entry.key}_$boxName";
try {
if (!await CakeHive.boxExists(tokenBoxName)) {
continue;
}
final box = await CakeHive.openBox<TronToken>(tokenBoxName);
for (final key in box.keys.toList()) {
final token = box.get(key);
if (token == null) {
continue;
}
for (final rawName in entry.value) {
await token.migrateToSqlite(walletName: rawName);
}
await box.delete(key);
}
await box.deleteFromDisk();
} catch (e) {
printV("Error migrating tron token box $tokenBoxName: $e, continuing anyway");
}
}
}
Future<void> migrateToSqlite({required String walletName}) async {
final row = tron_new.TronToken(
name: name,
symbol: symbol,
contractAddress: contractAddress,
decimal: decimal,
enabled: _enabled,
iconPath: iconPath,
tag: tag,
isPotentialScam: isPotentialScam,
walletName: walletName,
).toMap();
row[tron_new.TronToken.selfIdColumn] = null;
await db!.insert(
tron_new.TronToken.tableName,
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tron_token.dart';
part of "tron_token_legacy.dart";
// **************************************************************************
// TypeAdapterGenerator
+30 -14
View File
@@ -1,14 +1,16 @@
import 'dart:convert';
import 'dart:io';
import "dart:convert";
import "dart:io";
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/utils/file.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_credentials.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:path/path.dart' as p;
import "package:cw_core/pathForWallet.dart";
import "package:cw_core/spl_token.dart";
import "package:cw_core/tron_token.dart";
import "package:cw_core/utils/file.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_base.dart";
import "package:cw_core/wallet_credentials.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:path/path.dart" as p;
abstract class WalletService<N extends WalletCredentials, RFS extends WalletCredentials,
RFK extends WalletCredentials, RFH extends WalletCredentials> {
@@ -29,11 +31,13 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
Future<void> remove(String wallet);
Future<void> rename(String currentName, String password, String newName) async {
if (currentName == newName) return;
if (currentName == newName) {
return;
}
final currentWalletInfo = await WalletInfo.get(currentName, getType());
if (currentWalletInfo == null) {
throw Exception('Wallet not found');
throw Exception("Wallet not found");
}
await copyWalletFilesTo(fromName: currentName, toName: newName, type: getType());
@@ -43,6 +47,8 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
currentWalletInfo.name = newName;
await currentWalletInfo.save();
await _renameTokenRows(currentName, newName);
final oldDir = Directory(p.join(await pathForWalletTypeDir(type: getType()), currentName));
if (oldDir.existsSync()) {
try {
@@ -53,6 +59,16 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
}
}
Future<void> _renameTokenRows(String currentName, String newName) async {
if (getType() == WalletType.solana) {
await SPLToken.renameWallet(currentName, newName);
}
if (getType() == WalletType.tron) {
await TronToken.renameWallet(currentName, newName);
}
}
Future<void> restoreWalletFilesFromBackup(String name) async {
final backupWalletDirPath = await pathForWalletDir(name: "$name.backup", type: getType());
final walletDirPath = await pathForWalletDir(name: name, type: getType());
@@ -77,14 +93,14 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
final jsonSource = await read(path: path, password: password);
try {
final data = json.decode(jsonSource) as Map;
return data['mnemonic'] as String? ?? '';
return data["mnemonic"] as String? ?? "";
} catch (_) {
// if not a valid json
return jsonSource.substring(0, 200);
}
} catch (_) {
// if the file couldn't be opened or read
return '';
return "";
}
}
@@ -0,0 +1,365 @@
import "dart:io";
import "package:cw_core/cake_hive.dart";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/erc20_token.dart" as erc20_sql;
import "package:cw_core/erc20_token_legacy.dart" as erc20_legacy;
import "package:cw_core/root_dir.dart";
import "package:cw_core/spl_token.dart" as spl_sql;
import "package:cw_core/spl_token_legacy.dart" as spl_legacy;
import "package:cw_core/tron_token.dart" as tron_sql;
import "package:cw_core/tron_token_legacy.dart" as tron_legacy;
import "package:cw_core/wallet_type.dart";
import "package:flutter_test/flutter_test.dart";
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
import "package:sqflite_common_ffi/sqflite_ffi.dart";
// Faking the documents dir keeps getAppDir() off the platform channel, so the
// test runs with a plain `flutter test` on any host and in CI.
class _FakePathProviderPlatform extends PathProviderPlatform {
_FakePathProviderPlatform(this.root);
final String root;
@override
Future<String?> getApplicationDocumentsPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
}
Future<void> main() async {
final dataRoot = Directory("./test/data/token_migration");
Future<void> insertWalletInfoRow(String name, WalletType type) async {
await db!.insert("WalletInfo", {
"id": "${walletTypeToString(type).toLowerCase()}_$name",
"name": name,
"type": type.index,
"isRecovery": 0,
"restoreHeight": 0,
"timestamp": 0,
"dirPath": "",
"path": "",
"address": "",
"showIntroCakePayCard": 0,
"walletInfoDerivationInfoId": 0,
"isNonSeedWallet": 0,
"sortOrder": 0,
"receiveInfoboxDismissed": 0,
"showCombinedBalance": 1,
});
}
group(
"token sqlite migration",
() {
setUpAll(() async {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
dataRoot.createSync(recursive: true);
PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
// On linux getAppDir() appends /cake_wallet to the documents dir and picks the
// first existing candidate, so create it up front to keep CI on the faked path.
Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
await initDb();
// Everything must share the dir initDb resolved so boxExists finds the boxes
final appDir = await getAppDir();
CakeHive.init(appDir.path);
if (!CakeHive.isAdapterRegistered(erc20_legacy.Erc20Token.typeId)) {
CakeHive.registerAdapter(erc20_legacy.Erc20TokenAdapter());
}
if (!CakeHive.isAdapterRegistered(spl_legacy.SPLToken.typeId)) {
CakeHive.registerAdapter(spl_legacy.SPLTokenAdapter());
}
if (!CakeHive.isAdapterRegistered(tron_legacy.TronToken.typeId)) {
CakeHive.registerAdapter(tron_legacy.TronTokenAdapter());
}
// Two ethereum wallets whose names sanitize to the same box name, plus sol and tron
await insertWalletInfoRow("My Wallet", WalletType.ethereum);
await insertWalletInfoRow("My_Wallet", WalletType.ethereum);
await insertWalletInfoRow("sol wallet", WalletType.solana);
await insertWalletInfoRow("tron1", WalletType.tron);
// Legacy global box shared by every ethereum wallet in the pre per-wallet era
final globalBox = await CakeHive.openBox<erc20_legacy.Erc20Token>("Erc20Tokens");
await globalBox.put(
"0xGlobalTokenAAA",
erc20_legacy.Erc20Token(
name: "Global Legacy",
symbol: "GLB",
contractAddress: "0xGlobalTokenAAA",
decimal: 18,
enabled: true,
),
);
// Per-wallet ethereum box with a duplicate contract in two casings and a disabled token
final ethBox =
await CakeHive.openBox<erc20_legacy.Erc20Token>("My_Wallet_EthereumErc20Tokens");
await ethBox.put(
"0xDupCASE01",
erc20_legacy.Erc20Token(
name: "Dup Token",
symbol: "DUP",
contractAddress: "0xDupCASE01",
decimal: 18,
enabled: false,
iconPath: "",
),
);
await ethBox.put(
"0xdupcase01",
erc20_legacy.Erc20Token(
name: "Dup Token",
symbol: "DUP",
contractAddress: "0xdupcase01",
decimal: 18,
enabled: true,
iconPath: "assets/images/dup.png",
),
);
await ethBox.put(
"0xkeepmedisabled",
erc20_legacy.Erc20Token(
name: "Keep Me",
symbol: "KEEP",
contractAddress: "0xkeepmedisabled",
decimal: 6,
enabled: false,
),
);
// A polygon chain box for the same wallet name
final polyBox =
await CakeHive.openBox<erc20_legacy.Erc20Token>("My_Wallet_PolygonErc20Tokens");
await polyBox.put(
"0xpolytoken01",
erc20_legacy.Erc20Token(
name: "Poly Token",
symbol: "PLY",
contractAddress: "0xpolytoken01",
decimal: 18,
enabled: true,
tag: "POL",
),
);
// SPL and Tron boxes, base58 keys must keep their exact casing
final splBox = await CakeHive.openBox<spl_legacy.SPLToken>("sol_wallet_SPLTokens");
await splBox.put(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
spl_legacy.SPLToken(
name: "USD Coin",
symbol: "USDC",
mintAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
decimal: 6,
mint: "usdc",
enabled: false,
),
);
final tronBox = await CakeHive.openBox<tron_legacy.TronToken>("tron1_TronTokens");
await tronBox.put(
"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
tron_legacy.TronToken(
name: "Tether USD",
symbol: "USDT",
contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
decimal: 6,
enabled: true,
),
);
await globalBox.close();
await ethBox.close();
await polyBox.close();
await splBox.close();
await tronBox.close();
});
tearDownAll(() async {
await db?.close();
db = null;
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
});
test("migrates every token box into sqlite", () async {
await erc20_legacy.performErc20TokenHiveMigration();
await spl_legacy.performSplTokenHiveMigration();
await tron_legacy.performTronTokenHiveMigration();
// Both ethereum wallets share the sanitized box name, so each gets the rows
for (final walletName in ["My Wallet", "My_Wallet"]) {
final ethTokens = await erc20_sql.Erc20Token.getAllForWallet(walletName, 1);
final addresses = ethTokens.map((t) => t.contractAddress).toSet();
expect(
addresses,
{"0xglobaltokenaaa", "0xdupcase01", "0xkeepmedisabled"},
reason: "wallet $walletName should have the global, merged dup and disabled tokens",
);
final dup = ethTokens.firstWhere((t) => t.contractAddress == "0xdupcase01");
expect(dup.enabled, true, reason: "dup merge ORs the enabled flags");
expect(dup.iconPath, "assets/images/dup.png", reason: "dup merge prefers non-empty icon");
final keep = ethTokens.firstWhere((t) => t.contractAddress == "0xkeepmedisabled");
expect(keep.enabled, false, reason: "disabled toggle must survive the migration");
final polyTokens = await erc20_sql.Erc20Token.getAllForWallet(walletName, 137);
expect(polyTokens.map((t) => t.contractAddress), ["0xpolytoken01"]);
}
final splTokens = await spl_sql.SPLToken.getAllForWallet("sol wallet");
expect(splTokens.length, 1);
expect(
splTokens.first.mintAddress,
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
reason: "mint address casing must be preserved",
);
expect(splTokens.first.enabled, false);
final tronTokens = await tron_sql.TronToken.getAllForWallet("tron1");
expect(tronTokens.length, 1);
expect(
tronTokens.first.contractAddress,
"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
reason: "tron contract address casing must be preserved",
);
// Drained boxes are removed from disk
expect(await CakeHive.boxExists("Erc20Tokens"), false);
expect(await CakeHive.boxExists("My_Wallet_EthereumErc20Tokens"), false);
expect(await CakeHive.boxExists("My_Wallet_PolygonErc20Tokens"), false);
expect(await CakeHive.boxExists("sol_wallet_SPLTokens"), false);
expect(await CakeHive.boxExists("tron1_TronTokens"), false);
});
test("re-running the migrations is a no-op", () async {
final before = (await erc20_sql.Erc20Token.selectList("", [])).length +
(await spl_sql.SPLToken.selectList("", [])).length +
(await tron_sql.TronToken.selectList("", [])).length;
await erc20_legacy.performErc20TokenHiveMigration();
await spl_legacy.performSplTokenHiveMigration();
await tron_legacy.performTronTokenHiveMigration();
final after = (await erc20_sql.Erc20Token.selectList("", [])).length +
(await spl_sql.SPLToken.selectList("", [])).length +
(await tron_sql.TronToken.selectList("", [])).length;
expect(after, before);
});
test("an interrupted migration re-drains over existing rows without duplicating", () async {
// Simulates a run that inserted rows into sqlite but died before the box was
// emptied: the box reappears holding a token that already has a row (USDC,
// re-enabled in the box copy) plus one the interrupted run never reached (BONK)
final box = await CakeHive.openBox<spl_legacy.SPLToken>("sol_wallet_SPLTokens");
await box.put(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
spl_legacy.SPLToken(
name: "USD Coin",
symbol: "USDC",
mintAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
decimal: 6,
mint: "usdc",
enabled: true,
),
);
await box.put(
"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
spl_legacy.SPLToken(
name: "Bonk",
symbol: "BONK",
mintAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
decimal: 5,
mint: "bonk",
enabled: true,
),
);
await box.close();
await spl_legacy.performSplTokenHiveMigration();
final tokens = await spl_sql.SPLToken.getAllForWallet("sol wallet");
expect(tokens.length, 2, reason: "the re-drain must not duplicate the existing USDC row");
final usdc = tokens.firstWhere((t) => t.symbol == "USDC");
expect(usdc.enabled, true, reason: "the box copy wins over the stale row on re-drain");
expect(await CakeHive.boxExists("sol_wallet_SPLTokens"), false);
});
test("save without wallet context throws", () {
final token = erc20_sql.Erc20Token(
name: "No Context",
symbol: "NOC",
contractAddress: "0xnocontext",
decimal: 18,
);
expect(token.save, throwsStateError);
});
test("seeding preserves the enabled toggle without clobbering", () async {
// Same shape addInitialTokens uses: read existing, copyWith preserving enabled
final existing =
await erc20_sql.Erc20Token.getByContract("My Wallet", 1, "0xKEEPMEDISABLED");
expect(existing, isNotNull);
expect(existing!.enabled, false);
final refreshedDefault = erc20_sql.Erc20Token(
name: "Keep Me Renamed By Defaults",
symbol: "KEEP",
contractAddress: "0xkeepmedisabled",
decimal: 6,
enabled: true,
);
final toSave = erc20_sql.Erc20Token.copyWith(
refreshedDefault,
enabled: existing.enabled,
walletName: "My Wallet",
chainId: 1,
);
await toSave.save();
final reloaded =
await erc20_sql.Erc20Token.getByContract("My Wallet", 1, "0xkeepmedisabled");
expect(reloaded!.enabled, false, reason: "metadata refresh must not re-enable the token");
expect(reloaded.name, "Keep Me Renamed By Defaults");
final rowCount = (await erc20_sql.Erc20Token.getAllForWallet("My Wallet", 1)).length;
expect(rowCount, 3, reason: "upsert must replace, not duplicate");
});
test("rename moves rows and delete removes them", () async {
await erc20_sql.Erc20Token.renameWallet("My Wallet", "Renamed Wallet");
expect(await erc20_sql.Erc20Token.getAllForWallet("My Wallet", 1), isEmpty);
expect((await erc20_sql.Erc20Token.getAllForWallet("Renamed Wallet", 1)).length, 3);
expect((await erc20_sql.Erc20Token.getAllForWallet("Renamed Wallet", 137)).length, 1);
await erc20_sql.Erc20Token.deleteAllForWallet("My_Wallet");
expect(await erc20_sql.Erc20Token.getAllForWallet("My_Wallet", 1), isEmpty);
expect(await erc20_sql.Erc20Token.getAllForWallet("My_Wallet", 137), isEmpty);
// Rename into a name that has orphaned rows must not trip the unique index
await erc20_sql.Erc20Token.renameWallet("Renamed Wallet", "My_Wallet");
expect((await erc20_sql.Erc20Token.getAllForWallet("My_Wallet", 1)).length, 3);
});
},
);
}