mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
Merge branch 'dev' of https://github.com/cake-tech/cake_wallet into CW-1529-Improved-Seed-UI
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -55,6 +55,8 @@ class SignSPLTokenTransactionRentException implements Exception {}
|
||||
|
||||
class NoAssociatedTokenAccountException implements Exception {}
|
||||
|
||||
class NotAnNFTException implements Exception {}
|
||||
|
||||
class AmbiguousTokenSymbolException implements Exception {
|
||||
AmbiguousTokenSymbolException(this.symbol);
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user