Merge branch 'dev' of https://github.com/cake-tech/cake_wallet into CW-1529-Improved-Seed-UI

This commit is contained in:
Blazebrain
2026-09-04 11:30:51 +01:00
100 changed files with 3636 additions and 788 deletions
+25 -1
View File
@@ -63,7 +63,7 @@ Future<void> _initDb({String? pathOverride}) async {
}
}
await db?.close();
db = await openDatabase(dbFile.path, version: 11,
db = await openDatabase(dbFile.path, version: 12,
onUpgrade: (Database db, int oldVersion, int newVersion) async {
printV("migrating: $oldVersion, $newVersion");
if (oldVersion <= 1) {
@@ -153,6 +153,9 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
await _createTronTokenTable(db);
}
if (oldVersion <= 10) {
await _createImportedNFTTable(db);
}
if (oldVersion <= 11) {
await _addColumnIfNotExists(
db,
table: 'WalletInfo',
@@ -259,6 +262,7 @@ CREATE TABLE BalanceCardStyleSettings (
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
await _createImportedNFTTable(db);
});
}
@@ -431,6 +435,26 @@ ON SPLToken (walletName, mintAddress);
""");
}
Future<void> _createImportedNFTTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS ImportedNFT (
ImportedNFTId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
walletName TEXT NOT NULL,
chain TEXT NOT NULL,
identifier TEXT NOT NULL,
name TEXT,
symbol TEXT,
description TEXT,
imageUrl TEXT,
isOwned INTEGER
);
""");
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_importednft_wallet_chain_identifier
ON ImportedNFT (walletName, chain, identifier);
""");
}
Future<void> _createTronTokenTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS TronToken (
+2
View File
@@ -55,6 +55,8 @@ class SignSPLTokenTransactionRentException implements Exception {}
class NoAssociatedTokenAccountException implements Exception {}
class NotAnNFTException implements Exception {}
class AmbiguousTokenSymbolException implements Exception {
AmbiguousTokenSymbolException(this.symbol);
+138
View File
@@ -0,0 +1,138 @@
import "package:cw_core/db/sqlite.dart";
import "package:sqflite/sqflite.dart";
class ImportedNFT {
ImportedNFT({
required this.walletName,
required this.chain,
required this.identifier,
this.name,
this.symbol,
this.description,
this.imageUrl,
this.isOwned,
this.id = 0,
});
ImportedNFT.copyWith(ImportedNFT other, {String? walletName})
: id = 0,
walletName = walletName ?? other.walletName,
chain = other.chain,
identifier = other.identifier,
name = other.name,
symbol = other.symbol,
description = other.description,
imageUrl = other.imageUrl,
isOwned = other.isOwned;
factory ImportedNFT.fromMap(Map<String, Object?> map) => ImportedNFT(
id: map[selfIdColumn] as int? ?? 0,
walletName: map["walletName"]! as String,
chain: map["chain"]! as String,
identifier: map["identifier"]! as String,
name: map["name"] as String?,
symbol: map["symbol"] as String?,
description: map["description"] as String?,
imageUrl: map["imageUrl"] as String?,
isOwned: map["isOwned"] == null ? null : map["isOwned"] == 1,
);
int id;
final String walletName;
final String chain;
final String identifier;
final String? name;
final String? symbol;
final String? description;
final String? imageUrl;
final bool? isOwned;
static String get tableName => "ImportedNFT";
static String get selfIdColumn => "${tableName}Id";
static const solanaChain = "solana";
Map<String, Object?> toMap() => {
selfIdColumn: id,
"walletName": walletName,
"chain": chain,
"identifier": identifier,
"name": name,
"symbol": symbol,
"description": description,
"imageUrl": imageUrl,
"isOwned": isOwned == null ? null : (isOwned! ? 1 : 0),
};
Future<int> save() async {
final row = toMap();
if (row[selfIdColumn] == 0) {
row[selfIdColumn] = null;
}
id = await db!.insert(tableName, row, conflictAlgorithm: ConflictAlgorithm.replace);
return id;
}
static Future<int> updateMetadata(ImportedNFT nft) => db!.update(
tableName,
nft.toMap()..remove(selfIdColumn),
where: "walletName = ? AND chain = ? AND identifier = ?",
whereArgs: [nft.walletName, nft.chain, nft.identifier],
);
static Future<List<ImportedNFT>> getAllForWallet(String walletName, [String? chain]) async {
final rows = await db!.query(
tableName,
where: chain == null ? "walletName = ?" : "walletName = ? AND chain = ?",
whereArgs: chain == null ? [walletName] : [walletName, chain],
orderBy: selfIdColumn,
);
return rows.map(ImportedNFT.fromMap).toList();
}
static Future<int> deleteOne(String walletName, String chain, String identifier) => db!.delete(
tableName,
where: "walletName = ? AND chain = ? AND identifier = ?",
whereArgs: [walletName, chain, identifier],
);
static String _scope(List<String>? chains) =>
chains == null ? "" : " AND chain IN (${List.filled(chains.length, "?").join(", ")})";
static Future<int> deleteAllForWallet(String walletName, {List<String>? chains}) async {
if (chains != null && chains.isEmpty) {
return 0;
}
return db!.delete(
tableName,
where: "walletName = ?${_scope(chains)}",
whereArgs: [walletName, ...?chains],
);
}
static Future<void> renameWallet(
String oldName,
String newName, {
List<String>? chains,
}) async {
if (chains != null && chains.isEmpty) {
return;
}
await db!.delete(
tableName,
where: "walletName = ?${_scope(chains)}",
whereArgs: [newName, ...?chains],
);
await db!.update(
tableName,
{"walletName": newName},
where: "walletName = ?${_scope(chains)}",
whereArgs: [oldName, ...?chains],
);
}
}
+25
View File
@@ -0,0 +1,25 @@
const _ipfsGateway = "https://ipfs.io/ipfs/";
const _ipfsScheme = "ipfs://";
String? tryNormalizeIpfsUrl(String? url) {
if (url == null) {
return null;
}
if (!url.toLowerCase().startsWith(_ipfsScheme)) {
return url.toLowerCase().startsWith("https://") ? url : null;
}
var cid = url.substring(_ipfsScheme.length);
// Some minters write ipfs://ipfs/<cid>, which would double the gateway path.
if (cid.toLowerCase().startsWith("ipfs/")) {
cid = cid.substring(5);
}
if (cid.isEmpty) {
return null;
}
return "$_ipfsGateway$cid";
}
+25
View File
@@ -0,0 +1,25 @@
// Control characters and bidi overrides let a mint's own metadata fake extra
// lines or reverse the text around it wherever the name is shown.
final _unsafeDisplayCharacters = RegExp(
"[\u0000-\u001F\u007F\u00AD\u034F\u061C\u115F\u1160\u180E\u200B\u200E\u200F"
"\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\u3164\uFEFF\uFFA0]"
"|[\u{E0000}-\u{E007F}]",
unicode: true,
);
const _maxDisplayTextLength = 512;
String? sanitizeNFTText(String? text) {
if (text == null) {
return null;
}
final cleaned = text.replaceAll(_unsafeDisplayCharacters, " ").trim();
final runes = cleaned.runes;
if (runes.length <= _maxDisplayTextLength) {
return cleaned;
}
return "${String.fromCharCodes(runes.take(_maxDisplayTextLength))}\u2026";
}
+6
View File
@@ -1,6 +1,7 @@
import "dart:convert";
import "dart:io";
import "package:cw_core/imported_nft.dart";
import "package:cw_core/pathForWallet.dart";
import "package:cw_core/spl_token.dart";
import "package:cw_core/tron_token.dart";
@@ -62,6 +63,11 @@ 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);
await ImportedNFT.renameWallet(
currentName,
newName,
chains: const [ImportedNFT.solanaChain],
);
}
if (getType() == WalletType.tron) {
+176
View File
@@ -0,0 +1,176 @@
import "dart:io";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/imported_nft.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/imported_nft");
Future<void> insert(String walletName, String chain, String identifier) =>
ImportedNFT(walletName: walletName, chain: chain, identifier: identifier, name: identifier)
.save();
Future<List<String>> identifiersFor(String walletName, [String? chain]) async =>
(await ImportedNFT.getAllForWallet(walletName, chain)).map((nft) => nft.identifier).toList();
setUpAll(() async {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
dataRoot.createSync(recursive: true);
Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
await initDb();
});
setUp(() async {
await db!.delete(ImportedNFT.tableName);
});
tearDownAll(() {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
});
group("ImportedNFT wallet lifecycle", () {
test("reading without a chain returns every chain the wallet imported on", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "polygon", "0xother:7");
expect(await identifiersFor("Main", "eth"), ["0xcontract:1"]);
expect(
(await identifiersFor("Main"))..sort(),
["0xcontract:1", "0xother:7", "SolMint"],
);
});
test("rename moves every chain and leaves other wallets alone", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Sidekick", "eth", "0xkeep:9");
await ImportedNFT.renameWallet("Main", "Renamed");
expect(await identifiersFor("Main"), isEmpty);
expect((await identifiersFor("Renamed"))..sort(), ["0xcontract:1", "SolMint"]);
expect(await identifiersFor("Sidekick"), ["0xkeep:9"]);
});
test("renaming into a name that still has orphan rows does not trip the unique index",
() async {
await insert("Orphaned", "eth", "0xcontract:1");
await insert("Orphaned", "polygon", "0xsurvivor:5");
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "eth", "0xsecond:2");
await ImportedNFT.renameWallet("Main", "Orphaned", chains: const ["eth"]);
// Only the colliding chain is cleared. A row the destination holds on
// another chain is not ours to delete.
expect(
(await identifiersFor("Orphaned"))..sort(),
["0xcontract:1", "0xsecond:2", "0xsurvivor:5"],
);
});
test("renaming a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
await insert("Sol", ImportedNFT.solanaChain, "SolMint");
await insert("Vault", "eth", "0xEthNFT:1");
await ImportedNFT.renameWallet(
"Sol",
"Vault",
chains: const [ImportedNFT.solanaChain],
);
expect((await identifiersFor("Vault"))..sort(), ["0xEthNFT:1", "SolMint"]);
});
test("deleting a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
await insert("Vault", ImportedNFT.solanaChain, "SolMint");
await insert("Vault", "eth", "0xEthNFT:1");
await ImportedNFT.deleteAllForWallet(
"Vault",
chains: const [ImportedNFT.solanaChain],
);
expect(await identifiersFor("Vault"), ["0xEthNFT:1"]);
});
test("an empty chain scope deletes nothing rather than everything", () async {
await insert("Main", "eth", "0xcontract:1");
await ImportedNFT.deleteAllForWallet("Main", chains: const []);
expect(await identifiersFor("Main"), ["0xcontract:1"]);
});
test("delete removes every chain for that wallet only", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Sidekick", ImportedNFT.solanaChain, "OtherMint");
await ImportedNFT.deleteAllForWallet("Main");
expect(await identifiersFor("Main"), isEmpty);
expect(await identifiersFor("Sidekick"), ["OtherMint"]);
});
test("deleting one leaves the wallet's other imports in place", () async {
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "eth", "0xsecond:2");
await insert("Main", "polygon", "0xcontract:1");
await ImportedNFT.deleteOne("Main", "eth", "0xcontract:1");
expect(await identifiersFor("Main", "eth"), ["0xsecond:2"]);
expect(await identifiersFor("Main", "polygon"), ["0xcontract:1"]);
});
test("copying to a new name keeps the rows under the old one", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
for (final nft in await ImportedNFT.getAllForWallet("Main")) {
await ImportedNFT.copyWith(nft, walletName: "Copy").save();
}
expect((await identifiersFor("Main"))..sort(), ["0xcontract:1", "SolMint"]);
expect((await identifiersFor("Copy"))..sort(), ["0xcontract:1", "SolMint"]);
});
test("a copy is a new row, so saving it does not overwrite the original", () async {
await insert("Main", "eth", "0xcontract:1");
final original = (await ImportedNFT.getAllForWallet("Main")).single;
final copy = ImportedNFT.copyWith(original, walletName: "Copy");
await copy.save();
expect(copy.id, isNot(original.id));
expect((await ImportedNFT.getAllForWallet("Main")).single.id, original.id);
});
});
}
+55
View File
@@ -0,0 +1,55 @@
import "package:cw_core/utils/ipfs_url.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("tryNormalizeIpfsUrl", () {
test("rewrites an ipfs uri to the gateway", () {
expect(tryNormalizeIpfsUrl("ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
});
test("does not double the gateway path when the uri already says ipfs/", () {
expect(tryNormalizeIpfsUrl("ipfs://ipfs/QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
});
test("matches the scheme whatever its case", () {
expect(tryNormalizeIpfsUrl("IPFS://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
expect(tryNormalizeIpfsUrl("Ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
});
test("returns null for an ipfs uri with no cid, rather than a bare gateway url", () {
expect(tryNormalizeIpfsUrl("ipfs://"), isNull);
expect(tryNormalizeIpfsUrl("ipfs://ipfs/"), isNull);
});
test("passes through an https url that is not ipfs", () {
expect(tryNormalizeIpfsUrl("https://arweave.net/abc"), "https://arweave.net/abc");
});
test("drops a scheme nothing can render, rather than handing it to the image widget", () {
expect(tryNormalizeIpfsUrl("ar://abc123"), isNull);
expect(tryNormalizeIpfsUrl(""), isNull);
});
test("returns null for null", () {
expect(tryNormalizeIpfsUrl(null), isNull);
});
test("is idempotent, so a value read back from storage stays stable", () {
final once = tryNormalizeIpfsUrl("ipfs://QmAbC123");
expect(tryNormalizeIpfsUrl(once), once);
});
test("drops any scheme that is not https", () {
// These render through Image.network, which does not go through the proxy.
expect(tryNormalizeIpfsUrl("http://127.0.0.1:8080/x.png"), isNull);
expect(tryNormalizeIpfsUrl("file:///etc/passwd"), isNull);
expect(tryNormalizeIpfsUrl("javascript:alert(1)"), isNull);
expect(tryNormalizeIpfsUrl("data:text/html,x"), isNull);
});
test("keeps an https url whatever its casing", () {
expect(tryNormalizeIpfsUrl("https://ok.example/a.png"), "https://ok.example/a.png");
expect(tryNormalizeIpfsUrl("HTTPS://Up.example/a.png"), "HTTPS://Up.example/a.png");
});
});
}
+89
View File
@@ -0,0 +1,89 @@
import "package:cw_core/utils/nft_text.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("sanitizeNFTText", () {
test("removes the separators that would fake extra lines", () {
expect(sanitizeNFTText("Ape\u2028Address: 9xQeW"), "Ape Address: 9xQeW");
expect(sanitizeNFTText("Ape\u2029Address: 9xQeW"), "Ape Address: 9xQeW");
expect(sanitizeNFTText("Ape\nAddress: 9xQeW"), "Ape Address: 9xQeW");
});
test("removes bidi overrides and isolates that reorder what follows", () {
expect(sanitizeNFTText("evil\u202Etxen"), "evil txen");
expect(sanitizeNFTText("a\u2066b\u2069c"), "a b c");
});
test("removes invisibles that hide or reorder characters", () {
expect(sanitizeNFTText("A\u200BB"), "A B");
expect(sanitizeNFTText("A\u200FB"), "A B");
expect(sanitizeNFTText("A\u061CB"), "A B");
expect(sanitizeNFTText("A\u2060B"), "A B");
});
test("removes control characters and delete", () {
expect(sanitizeNFTText("A\u0000B"), "A B");
expect(sanitizeNFTText("A\u001FB"), "A B");
expect(sanitizeNFTText("A\u007FB"), "A B");
});
test("leaves ordinary text alone", () {
expect(sanitizeNFTText("DeGod #1234"), "DeGod #1234");
expect(sanitizeNFTText(" padded "), "padded");
});
test("returns null for null", () {
expect(sanitizeNFTText(null), isNull);
});
test("counts the cap in characters, so emoji are not cut in half", () {
final long = sanitizeNFTText("\u{1F600}" * 600)!;
expect(long.runes.length, 513);
expect(long.runes.last, 0x2026);
final units = long.codeUnits;
final endsOnHighSurrogate = units.isNotEmpty && units.last >= 0xD800 && units.last <= 0xDBFF;
expect(endsOnHighSurrogate, isFalse);
});
test("does not truncate a name that is long in code units but short in characters", () {
expect(sanitizeNFTText("\u{1F600}" * 400)!.runes.length, 400);
});
test("is idempotent, so re-reading a saved value does not change it", () {
const raw = "Ape\u2028Name\u202E with \u{1F600}";
final once = sanitizeNFTText(raw);
expect(sanitizeNFTText(once), once);
});
test("keeps the joiners that carry meaning in persian and emoji", () {
// Replacing these put a visible space inside a Persian word and split
// a two-person emoji into two glyphs.
expect(sanitizeNFTText("\u{1F468}\u200D\u{1F4BB}"), "\u{1F468}\u200D\u{1F4BB}");
expect(
sanitizeNFTText("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645"),
"\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645",
);
});
test("strips the invisibles a mint can hide text behind", () {
for (final invisible in [
"\u00AD", // soft hyphen
"\u034F", // combining grapheme joiner
"\u180E", // mongolian vowel separator
"\u3164", // hangul filler
"\uFEFF", // zero width no-break space
"\uFFA0", // halfwidth hangul filler
"\u{E0041}", // tag latin capital A
]) {
expect(
sanitizeNFTText("A${invisible}B"),
"A B",
reason: "U+${invisible.runes.first.toRadixString(16).toUpperCase()} survived",
);
}
});
});
}