dev
dart 365 lines 13.7 KB
Raw
1 import "dart:io";
2
3 import "package:cw_core/cake_hive.dart";
4 import "package:cw_core/db/sqlite.dart";
5 import "package:cw_core/erc20_token.dart" as erc20_sql;
6 import "package:cw_core/erc20_token_legacy.dart" as erc20_legacy;
7 import "package:cw_core/root_dir.dart";
8 import "package:cw_core/spl_token.dart" as spl_sql;
9 import "package:cw_core/spl_token_legacy.dart" as spl_legacy;
10 import "package:cw_core/tron_token.dart" as tron_sql;
11 import "package:cw_core/tron_token_legacy.dart" as tron_legacy;
12 import "package:cw_core/wallet_type.dart";
13 import "package:flutter_test/flutter_test.dart";
14 import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
15 import "package:sqflite_common_ffi/sqflite_ffi.dart";
16
17 // Faking the documents dir keeps getAppDir() off the platform channel, so the
18 // test runs with a plain `flutter test` on any host and in CI.
19 class _FakePathProviderPlatform extends PathProviderPlatform {
20 _FakePathProviderPlatform(this.root);
21
22 final String root;
23
24 @override
25 Future<String?> getApplicationDocumentsPath() async => root;
26
27 @override
28 Future<String?> getApplicationSupportPath() async => root;
29 }
30
31 Future<void> main() async {
32 final dataRoot = Directory("./test/data/token_migration");
33
34 Future<void> insertWalletInfoRow(String name, WalletType type) async {
35 await db!.insert("WalletInfo", {
36 "id": "${walletTypeToString(type).toLowerCase()}_$name",
37 "name": name,
38 "type": type.index,
39 "isRecovery": 0,
40 "restoreHeight": 0,
41 "timestamp": 0,
42 "dirPath": "",
43 "path": "",
44 "address": "",
45 "showIntroCakePayCard": 0,
46 "walletInfoDerivationInfoId": 0,
47 "isNonSeedWallet": 0,
48 "sortOrder": 0,
49 "receiveInfoboxDismissed": 0,
50 "showCombinedBalance": 1,
51 });
52 }
53
54 group(
55 "token sqlite migration",
56 () {
57 setUpAll(() async {
58 if (dataRoot.existsSync()) {
59 dataRoot.deleteSync(recursive: true);
60 }
61 dataRoot.createSync(recursive: true);
62
63 PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
64
65 // On linux getAppDir() appends /cake_wallet to the documents dir and picks the
66 // first existing candidate, so create it up front to keep CI on the faked path.
67 Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
68
69 sqfliteFfiInit();
70 databaseFactory = databaseFactoryFfi;
71 await initDb();
72
73 // Everything must share the dir initDb resolved so boxExists finds the boxes
74 final appDir = await getAppDir();
75 CakeHive.init(appDir.path);
76
77 if (!CakeHive.isAdapterRegistered(erc20_legacy.Erc20Token.typeId)) {
78 CakeHive.registerAdapter(erc20_legacy.Erc20TokenAdapter());
79 }
80 if (!CakeHive.isAdapterRegistered(spl_legacy.SPLToken.typeId)) {
81 CakeHive.registerAdapter(spl_legacy.SPLTokenAdapter());
82 }
83 if (!CakeHive.isAdapterRegistered(tron_legacy.TronToken.typeId)) {
84 CakeHive.registerAdapter(tron_legacy.TronTokenAdapter());
85 }
86
87 // Two ethereum wallets whose names sanitize to the same box name, plus sol and tron
88 await insertWalletInfoRow("My Wallet", WalletType.ethereum);
89 await insertWalletInfoRow("My_Wallet", WalletType.ethereum);
90 await insertWalletInfoRow("sol wallet", WalletType.solana);
91 await insertWalletInfoRow("tron1", WalletType.tron);
92
93 // Legacy global box shared by every ethereum wallet in the pre per-wallet era
94 final globalBox = await CakeHive.openBox<erc20_legacy.Erc20Token>("Erc20Tokens");
95 await globalBox.put(
96 "0xGlobalTokenAAA",
97 erc20_legacy.Erc20Token(
98 name: "Global Legacy",
99 symbol: "GLB",
100 contractAddress: "0xGlobalTokenAAA",
101 decimal: 18,
102 enabled: true,
103 ),
104 );
105
106 // Per-wallet ethereum box with a duplicate contract in two casings and a disabled token
107 final ethBox =
108 await CakeHive.openBox<erc20_legacy.Erc20Token>("My_Wallet_EthereumErc20Tokens");
109 await ethBox.put(
110 "0xDupCASE01",
111 erc20_legacy.Erc20Token(
112 name: "Dup Token",
113 symbol: "DUP",
114 contractAddress: "0xDupCASE01",
115 decimal: 18,
116 enabled: false,
117 iconPath: "",
118 ),
119 );
120 await ethBox.put(
121 "0xdupcase01",
122 erc20_legacy.Erc20Token(
123 name: "Dup Token",
124 symbol: "DUP",
125 contractAddress: "0xdupcase01",
126 decimal: 18,
127 enabled: true,
128 iconPath: "assets/images/dup.png",
129 ),
130 );
131 await ethBox.put(
132 "0xkeepmedisabled",
133 erc20_legacy.Erc20Token(
134 name: "Keep Me",
135 symbol: "KEEP",
136 contractAddress: "0xkeepmedisabled",
137 decimal: 6,
138 enabled: false,
139 ),
140 );
141
142 // A polygon chain box for the same wallet name
143 final polyBox =
144 await CakeHive.openBox<erc20_legacy.Erc20Token>("My_Wallet_PolygonErc20Tokens");
145 await polyBox.put(
146 "0xpolytoken01",
147 erc20_legacy.Erc20Token(
148 name: "Poly Token",
149 symbol: "PLY",
150 contractAddress: "0xpolytoken01",
151 decimal: 18,
152 enabled: true,
153 tag: "POL",
154 ),
155 );
156
157 // SPL and Tron boxes, base58 keys must keep their exact casing
158 final splBox = await CakeHive.openBox<spl_legacy.SPLToken>("sol_wallet_SPLTokens");
159 await splBox.put(
160 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
161 spl_legacy.SPLToken(
162 name: "USD Coin",
163 symbol: "USDC",
164 mintAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
165 decimal: 6,
166 mint: "usdc",
167 enabled: false,
168 ),
169 );
170
171 final tronBox = await CakeHive.openBox<tron_legacy.TronToken>("tron1_TronTokens");
172 await tronBox.put(
173 "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
174 tron_legacy.TronToken(
175 name: "Tether USD",
176 symbol: "USDT",
177 contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
178 decimal: 6,
179 enabled: true,
180 ),
181 );
182
183 await globalBox.close();
184 await ethBox.close();
185 await polyBox.close();
186 await splBox.close();
187 await tronBox.close();
188 });
189
190 tearDownAll(() async {
191 await db?.close();
192 db = null;
193 if (dataRoot.existsSync()) {
194 dataRoot.deleteSync(recursive: true);
195 }
196 });
197
198 test("migrates every token box into sqlite", () async {
199 await erc20_legacy.performErc20TokenHiveMigration();
200 await spl_legacy.performSplTokenHiveMigration();
201 await tron_legacy.performTronTokenHiveMigration();
202
203 // Both ethereum wallets share the sanitized box name, so each gets the rows
204 for (final walletName in ["My Wallet", "My_Wallet"]) {
205 final ethTokens = await erc20_sql.Erc20Token.getAllForWallet(walletName, 1);
206 final addresses = ethTokens.map((t) => t.contractAddress).toSet();
207
208 expect(
209 addresses,
210 {"0xglobaltokenaaa", "0xdupcase01", "0xkeepmedisabled"},
211 reason: "wallet $walletName should have the global, merged dup and disabled tokens",
212 );
213
214 final dup = ethTokens.firstWhere((t) => t.contractAddress == "0xdupcase01");
215 expect(dup.enabled, true, reason: "dup merge ORs the enabled flags");
216 expect(dup.iconPath, "assets/images/dup.png", reason: "dup merge prefers non-empty icon");
217
218 final keep = ethTokens.firstWhere((t) => t.contractAddress == "0xkeepmedisabled");
219 expect(keep.enabled, false, reason: "disabled toggle must survive the migration");
220
221 final polyTokens = await erc20_sql.Erc20Token.getAllForWallet(walletName, 137);
222 expect(polyTokens.map((t) => t.contractAddress), ["0xpolytoken01"]);
223 }
224
225 final splTokens = await spl_sql.SPLToken.getAllForWallet("sol wallet");
226 expect(splTokens.length, 1);
227 expect(
228 splTokens.first.mintAddress,
229 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
230 reason: "mint address casing must be preserved",
231 );
232 expect(splTokens.first.enabled, false);
233
234 final tronTokens = await tron_sql.TronToken.getAllForWallet("tron1");
235 expect(tronTokens.length, 1);
236 expect(
237 tronTokens.first.contractAddress,
238 "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
239 reason: "tron contract address casing must be preserved",
240 );
241
242 // Drained boxes are removed from disk
243 expect(await CakeHive.boxExists("Erc20Tokens"), false);
244 expect(await CakeHive.boxExists("My_Wallet_EthereumErc20Tokens"), false);
245 expect(await CakeHive.boxExists("My_Wallet_PolygonErc20Tokens"), false);
246 expect(await CakeHive.boxExists("sol_wallet_SPLTokens"), false);
247 expect(await CakeHive.boxExists("tron1_TronTokens"), false);
248 });
249
250 test("re-running the migrations is a no-op", () async {
251 final before = (await erc20_sql.Erc20Token.selectList("", [])).length +
252 (await spl_sql.SPLToken.selectList("", [])).length +
253 (await tron_sql.TronToken.selectList("", [])).length;
254
255 await erc20_legacy.performErc20TokenHiveMigration();
256 await spl_legacy.performSplTokenHiveMigration();
257 await tron_legacy.performTronTokenHiveMigration();
258
259 final after = (await erc20_sql.Erc20Token.selectList("", [])).length +
260 (await spl_sql.SPLToken.selectList("", [])).length +
261 (await tron_sql.TronToken.selectList("", [])).length;
262
263 expect(after, before);
264 });
265
266 test("an interrupted migration re-drains over existing rows without duplicating", () async {
267 // Simulates a run that inserted rows into sqlite but died before the box was
268 // emptied: the box reappears holding a token that already has a row (USDC,
269 // re-enabled in the box copy) plus one the interrupted run never reached (BONK)
270 final box = await CakeHive.openBox<spl_legacy.SPLToken>("sol_wallet_SPLTokens");
271 await box.put(
272 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
273 spl_legacy.SPLToken(
274 name: "USD Coin",
275 symbol: "USDC",
276 mintAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
277 decimal: 6,
278 mint: "usdc",
279 enabled: true,
280 ),
281 );
282 await box.put(
283 "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
284 spl_legacy.SPLToken(
285 name: "Bonk",
286 symbol: "BONK",
287 mintAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
288 decimal: 5,
289 mint: "bonk",
290 enabled: true,
291 ),
292 );
293 await box.close();
294
295 await spl_legacy.performSplTokenHiveMigration();
296
297 final tokens = await spl_sql.SPLToken.getAllForWallet("sol wallet");
298 expect(tokens.length, 2, reason: "the re-drain must not duplicate the existing USDC row");
299
300 final usdc = tokens.firstWhere((t) => t.symbol == "USDC");
301 expect(usdc.enabled, true, reason: "the box copy wins over the stale row on re-drain");
302
303 expect(await CakeHive.boxExists("sol_wallet_SPLTokens"), false);
304 });
305
306 test("save without wallet context throws", () {
307 final token = erc20_sql.Erc20Token(
308 name: "No Context",
309 symbol: "NOC",
310 contractAddress: "0xnocontext",
311 decimal: 18,
312 );
313
314 expect(token.save, throwsStateError);
315 });
316
317 test("seeding preserves the enabled toggle without clobbering", () async {
318 // Same shape addInitialTokens uses: read existing, copyWith preserving enabled
319 final existing =
320 await erc20_sql.Erc20Token.getByContract("My Wallet", 1, "0xKEEPMEDISABLED");
321 expect(existing, isNotNull);
322 expect(existing!.enabled, false);
323
324 final refreshedDefault = erc20_sql.Erc20Token(
325 name: "Keep Me Renamed By Defaults",
326 symbol: "KEEP",
327 contractAddress: "0xkeepmedisabled",
328 decimal: 6,
329 enabled: true,
330 );
331 final toSave = erc20_sql.Erc20Token.copyWith(
332 refreshedDefault,
333 enabled: existing.enabled,
334 walletName: "My Wallet",
335 chainId: 1,
336 );
337 await toSave.save();
338
339 final reloaded =
340 await erc20_sql.Erc20Token.getByContract("My Wallet", 1, "0xkeepmedisabled");
341 expect(reloaded!.enabled, false, reason: "metadata refresh must not re-enable the token");
342 expect(reloaded.name, "Keep Me Renamed By Defaults");
343
344 final rowCount = (await erc20_sql.Erc20Token.getAllForWallet("My Wallet", 1)).length;
345 expect(rowCount, 3, reason: "upsert must replace, not duplicate");
346 });
347
348 test("rename moves rows and delete removes them", () async {
349 await erc20_sql.Erc20Token.renameWallet("My Wallet", "Renamed Wallet");
350
351 expect(await erc20_sql.Erc20Token.getAllForWallet("My Wallet", 1), isEmpty);
352 expect((await erc20_sql.Erc20Token.getAllForWallet("Renamed Wallet", 1)).length, 3);
353 expect((await erc20_sql.Erc20Token.getAllForWallet("Renamed Wallet", 137)).length, 1);
354
355 await erc20_sql.Erc20Token.deleteAllForWallet("My_Wallet");
356 expect(await erc20_sql.Erc20Token.getAllForWallet("My_Wallet", 1), isEmpty);
357 expect(await erc20_sql.Erc20Token.getAllForWallet("My_Wallet", 137), isEmpty);
358
359 // Rename into a name that has orphaned rows must not trip the unique index
360 await erc20_sql.Erc20Token.renameWallet("Renamed Wallet", "My_Wallet");
361 expect((await erc20_sql.Erc20Token.getAllForWallet("My_Wallet", 1)).length, 3);
362 });
363 },
364 );
365 }