solana nft improvements and other fixes (#3581)
What changed, and why it matters
This commit is a feature update for Cake Wallet that improves Solana NFT support (sending, ownership badges, persistent imports) and adds several security hardening measures. It does not appear to be a patch for an active exploit. The security-relevant changes include sanitizing NFT names/descriptions to stop malicious metadata from hiding or reordering text, restricting image URLs to HTTPS/IPFS gateways to avoid unsafe schemes, verifying an asset really is an NFT before sending, and adding safety checks when fetching off-chain metadata. There are also unrelated Monero background-sync and dependency changes.
Reviewers should verify that sanitizeNFTText and tryNormalizeIpfsUrl are applied consistently wherever user-facing NFT metadata is rendered or stored, confirm that isSupplyOfOne cannot be bypassed by malformed RPC responses, and ensure the off-chain metadata size/redirect guards are enforced on all code paths. Because this is a large feature commit, treat it as hardening rather than an urgent vulnerability fix unless additional incident references are provided.
Security signals we found
New sanitizeNFTText utility strips control characters, bidi overrides, and invisible characters from NFT metadata to prevent display spoofing
New tryNormalizeIpfsUrl utility rejects non-HTTPS image/URL schemes (http, file, javascript, data, ar) before rendering
Solana NFT send path verifies token supply == 1 and decimals == 0 before allowing transfer, raising NotAnNFTException otherwise
Off-chain metadata fetch enforces HTTPS-only redirects, 256 KiB size cap, 30-second timeout, and force-closes the HTTP client
Fungible token balance parsing now detects and withholds NFT candidates (decimals 0, amountRaw 1) from the token list
Imported NFT rows are scoped by wallet name and chain during rename/delete/copy operations to avoid cross-wallet data leakage
New Monero background-sync abstraction methods added; no direct security fix visible in supplied diff
Evidence from the diff
The diff adds Solana NFT send functionality, persistent ImportedNFT SQLite storage, IPFS URL normalization, and NFT text sanitization. Security signals: (1) sanitizeNFTText strips control/bidi/invisible characters and caps length to mitigate malicious metadata spoofing; (2) tryNormalizeIpfsUrl rejects non-HTTPS schemes (http, file, javascript, data, ar) to prevent unsafe image/URL handling; (3) sendNFT enforces isSupplyOfOne(mintAddress, throwOnError: true) so only genuine supply-1/decimals-0 tokens are sent as NFTs; (4) off-chain metadata fetch limits redirects to HTTPS, caps response size, and times out; (5) token list parsing now withholds NFT candidates from the fungible token list. The commit also includes database migration, wallet rename/delete lifecycle handling for imported NFTs, and UI updates. No CVE or vendor security disclosure is present in the supplied materials.
Changed components
cw_core/lib/db/sqlite.dartcw_core/lib/imported_nft.dartcw_core/lib/utils/ipfs_url.dartcw_core/lib/utils/nft_text.dartcw_core/lib/wallet_service.dartcw_evm/lib/evm_chain_wallet_service.dartcw_solana/lib/solana_client.dartcw_solana/lib/solana_wallet.dartcw_solana/lib/solana_wallet_service.dartlib/entities/saved_nfts.dartlib/entities/solana_nft_asset_model.dartlib/entities/wallet_nft_response.dartlib/view_model/dashboard/nft_send_view_model.dartlib/view_model/dashboard/nft_view_model.dartlib/src/screens/dashboard/pages/nft_send_page.dartlib/src/screens/dashboard/pages/nft_details_page.dartlib/monero/cw_monero.dartlib/view_model/dev/monero_background_sync.dartlib/view_model/wallet_keys_view_model.dartpubspec_base.yamlInspect captured patch +2827 / −393
diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart
index 0deffab8..1c8ade03 100644
--- a/cw_core/lib/db/sqlite.dart
+++ b/cw_core/lib/db/sqlite.dart
@@ -63,7 +63,7 @@ Future<void> _initDb({String? pathOverride}) async {
}
}
await db?.close();
- db = await openDatabase(dbFile.path, version: 10,
+ db = await openDatabase(dbFile.path, version: 11,
onUpgrade: (Database db, int oldVersion, int newVersion) async {
printV("migrating: $oldVersion, $newVersion");
if (oldVersion <= 1) {
@@ -152,6 +152,10 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
await _createSplTokenTable(db);
await _createTronTokenTable(db);
}
+
+ if (oldVersion <= 10) {
+ await _createImportedNFTTable(db);
+ }
}, onCreate: (Database db, int version) async {
await db.execute('''
CREATE TABLE WalletInfo (
@@ -250,6 +254,7 @@ CREATE TABLE BalanceCardStyleSettings (
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
+ await _createImportedNFTTable(db);
});
}
@@ -422,6 +427,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 (
diff --git a/cw_core/lib/exceptions.dart b/cw_core/lib/exceptions.dart
index 76d4782a..b64a4115 100644
--- a/cw_core/lib/exceptions.dart
+++ b/cw_core/lib/exceptions.dart
@@ -55,6 +55,8 @@ class SignSPLTokenTransactionRentException implements Exception {}
class NoAssociatedTokenAccountException implements Exception {}
+class NotAnNFTException implements Exception {}
+
class AmbiguousTokenSymbolException implements Exception {
AmbiguousTokenSymbolException(this.symbol);
diff --git a/cw_core/lib/imported_nft.dart b/cw_core/lib/imported_nft.dart
new file mode 100644
index 00000000..2284f680
--- /dev/null
+++ b/cw_core/lib/imported_nft.dart
@@ -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],
+ );
+ }
+}
diff --git a/cw_core/lib/utils/ipfs_url.dart b/cw_core/lib/utils/ipfs_url.dart
new file mode 100644
index 00000000..f5c320ae
--- /dev/null
+++ b/cw_core/lib/utils/ipfs_url.dart
@@ -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";
+}
diff --git a/cw_core/lib/utils/nft_text.dart b/cw_core/lib/utils/nft_text.dart
new file mode 100644
index 00000000..1cf0f6f8
--- /dev/null
+++ b/cw_core/lib/utils/nft_text.dart
@@ -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";
+}
diff --git a/cw_core/lib/wallet_service.dart b/cw_core/lib/wallet_service.dart
index 12219364..72ea98a9 100644
--- a/cw_core/lib/wallet_service.dart
+++ b/cw_core/lib/wallet_service.dart
@@ -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) {
diff --git a/cw_core/test/imported_nft_test.dart b/cw_core/test/imported_nft_test.dart
new file mode 100644
index 00000000..2bc14b54
--- /dev/null
+++ b/cw_core/test/imported_nft_test.dart
@@ -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);
+ });
+ });
+}
diff --git a/cw_core/test/ipfs_url_test.dart b/cw_core/test/ipfs_url_test.dart
new file mode 100644
index 00000000..14ab8439
--- /dev/null
+++ b/cw_core/test/ipfs_url_test.dart
@@ -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");
+ });
+ });
+}
diff --git a/cw_core/test/nft_text_test.dart b/cw_core/test/nft_text_test.dart
new file mode 100644
index 00000000..420562e4
--- /dev/null
+++ b/cw_core/test/nft_text_test.dart
@@ -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",
+ );
+ }
+ });
+ });
+}
diff --git a/cw_evm/lib/evm_chain_wallet_service.dart b/cw_evm/lib/evm_chain_wallet_service.dart
index ec52c519..a1217a36 100644
--- a/cw_evm/lib/evm_chain_wallet_service.dart
+++ b/cw_evm/lib/evm_chain_wallet_service.dart
@@ -3,6 +3,7 @@ import "dart:io";
import 'package:bip39/bip39.dart' as bip39;
import 'package:cw_core/encryption_file_utils.dart';
import "package:cw_core/erc20_token.dart";
+import "package:cw_core/imported_nft.dart";
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
@@ -36,6 +37,9 @@ class EVMChainWalletService extends WalletService<
return _registry.getRegisteredWalletTypes();
}
+ List<String> get _importedNFTChains =>
+ _registry.getAllChains().map((chain) => chain.shortCode).toList();
+
Future<WalletInfo?> _findWalletByName(String name) async {
for (final type in _evmWalletTypes) {
final walletInfo = await WalletInfo.get(name, type);
@@ -192,8 +196,15 @@ class EVMChainWalletService extends WalletService<
final copiedToken = Erc20Token.copyWith(token, walletName: newName);
await copiedToken.save();
}
+
+ final ownNFTs = (await ImportedNFT.getAllForWallet(currentName))
+ .where((nft) => _importedNFTChains.contains(nft.chain));
+ for (final nft in ownNFTs) {
+ await ImportedNFT.copyWith(nft, walletName: newName).save();
+ }
} else {
await Erc20Token.renameWallet(currentName, newName);
+ await ImportedNFT.renameWallet(currentName, newName, chains: _importedNFTChains);
}
final oldDir = Directory(p.join(await pathForWalletTypeDir(type: type), currentName));
@@ -351,6 +362,7 @@ class EVMChainWalletService extends WalletService<
final nameStillUsed = (await _findWalletByName(wallet)) != null;
if (!nameStillUsed) {
await Erc20Token.deleteAllForWallet(wallet);
+ await ImportedNFT.deleteAllForWallet(wallet, chains: _importedNFTChains);
}
}
diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart
index 7e218179..c69ec838 100644
--- a/cw_solana/lib/solana_client.dart
+++ b/cw_solana/lib/solana_client.dart
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
+import "dart:typed_data";
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_core/amount/money.dart';
@@ -7,6 +8,7 @@ import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/solana_rpc_http_service.dart';
+import "package:cw_core/utils/ipfs_url.dart";
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_solana/pending_solana_transaction.dart';
import 'package:cw_solana/solana_balance.dart';
@@ -110,6 +112,62 @@ class SolanaWalletClient {
}
}
+ Future<Set<String>> _mintsToWithholdFromTokenList(Set<String> mints) async {
+ const batchSize = 10;
+ final ordered = mints.toList();
+ final withheld = <String>{};
+
+ Future<bool> isUnconfirmedFungible(String mint) async {
+ try {
+ return await isSupplyOfOne(mint, throwOnError: true);
+ } catch (_) {
+ return true;
+ }
+ }
+
+ for (var i = 0; i < ordered.length; i += batchSize) {
+ final batch = ordered.skip(i).take(batchSize).toList();
+ final results = await Future.wait(batch.map(isUnconfirmedFungible));
+
+ for (var j = 0; j < batch.length; j++) {
+ if (results[j]) {
+ withheld.add(batch[j]);
+ }
+ }
+ }
+
+ return withheld;
+ }
+
+ Future<Set<String>> fetchHeldTokenMints(String walletAddress) async {
+ final owner = SolAddress(walletAddress);
+
+ final responses = await Future.wait([
+ _provider!.request(
+ SolanaRPCGetTokenAccountsByOwner(
+ account: owner,
+ programId: SPLTokenProgramConst.tokenProgramId,
+ commitment: Commitment.confirmed,
+ encoding: SolanaRPCEncoding.base64,
+ ),
+ ),
+ _provider!.request(
+ SolanaRPCGetTokenAccountsByOwner(
+ account: owner,
+ programId: SPLTokenProgramConst.token2022ProgramId,
+ commitment: Commitment.confirmed,
+ encoding: SolanaRPCEncoding.base64,
+ ),
+ ),
+ ]);
+
+ return {
+ for (final accounts in responses)
+ for (final account in accounts)
+ if (account.tokenAccount.amount > BigInt.zero) account.tokenAccount.mint.address,
+ };
+ }
+
Future<SolanaBalance?> getSplTokenBalance(SPLToken token, String walletAddress,
{bool throwOnError = false}) async {
try {
@@ -1162,6 +1220,43 @@ class SolanaWalletClient {
}
}
+ int _parseMoralisDecimals(Object? raw) {
+ if (raw == null) {
+ return 0;
+ }
+
+ if (raw is num) {
+ return raw.toInt();
+ }
+
+ final parsed = int.tryParse(raw.toString());
+
+ if (parsed == null) {
+ printV("Unexpected decimals value from Moralis: $raw");
+ return 0;
+ }
+
+ return parsed;
+ }
+
+ Future<bool> isSupplyOfOne(String mintAddress, {bool throwOnError = false}) async {
+ try {
+ final supply = await _provider!.request(
+ SolanaRPCGetTokenSupply(account: SolAddress(mintAddress)),
+ );
+
+ return supply.amount == "1" && supply.decimals == 0;
+ } catch (e) {
+ printV("Could not read supply for mint $mintAddress: ${e.toString()}");
+
+ if (throwOnError) {
+ rethrow;
+ }
+
+ return false;
+ }
+ }
+
Future<SPLToken?> fetchSPLTokenInfo(String mintAddress) async {
try {
final uri = Uri.https(
@@ -1252,6 +1347,101 @@ class SolanaWalletClient {
}
}
+ // This is the exact size for an spl token, anything larger is Token-2022
+ // carrying extensions
+ static const _baseTokenAccountSize = 165;
+
+ static const _maxOffChainMetadataBytes = 256 * 1024;
+
+ Future<String?> _readOffChainMetadata(Uri uri) async {
+ final httpClient = ProxyWrapper().getHttpClient();
+
+ try {
+ final request = await httpClient.getUrl(uri);
+ request.maxRedirects = 3;
+
+ final response = await request.close().timeout(const Duration(seconds: 30));
+
+ if (response.redirects.any((redirect) => redirect.location.scheme != "https")) {
+ return null;
+ }
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ return null;
+ }
+
+ if (response.contentLength > _maxOffChainMetadataBytes) {
+ return null;
+ }
+
+ final bytes = BytesBuilder(copy: false);
+
+ await for (final chunk in response) {
+ bytes.add(chunk);
+
+ if (bytes.length > _maxOffChainMetadataBytes) {
+ return null;
+ }
+ }
+
+ return utf8.decode(bytes.takeBytes(), allowMalformed: true);
+ } finally {
+ httpClient.close(force: true);
+ }
+ }
+
+ Future<NFTOnChainMetadata?> getNFTOnChainMetadata(String mintAddress) async {
+ try {
+ final programAddress =
+ MetaplexTokenMetaDataProgramUtils.findMetadataPda(mint: SolAddress(mintAddress));
+
+ final token = await _provider!.request(
+ SolanaRPCGetMetadataAccount(
+ account: programAddress.address,
+ commitment: Commitment.confirmed,
+ ),
+ );
+
+ if (token == null) {
+ return null;
+ }
+
+ final metadata = token.data;
+
+ String stripNulls(String value) => value.replaceAll("\u0000", "").trim();
+ String? stringOrNull(Object? value) => value is String ? value : null;
+
+ final metadataUri = stripNulls(metadata.uri);
+ final metadataUrl = tryNormalizeIpfsUrl(metadataUri);
+ final offChainUri = metadataUrl == null ? null : Uri.tryParse(metadataUrl);
+
+ String? imageUrl;
+
+ if (offChainUri != null && offChainUri.scheme == "https") {
+ try {
+ final body = await _readOffChainMetadata(offChainUri);
+
+ if (body != null) {
+ imageUrl = stringOrNull((jsonDecode(body) as Map<String, dynamic>)["image"]);
+ }
+ } catch (e) {
+ printV("Off-chain NFT metadata fetch failed: ${e.toString()}");
+ }
+ }
+
+ return NFTOnChainMetadata(
+ mint: mintAddress,
+ name: stripNulls(metadata.name),
+ symbol: stripNulls(metadata.symbol),
+ metadataUri: metadataUri,
+ imageUrl: imageUrl,
+ );
+ } catch (e) {
+ printV("Error fetching on-chain NFT metadata: $e");
+ return null;
+ }
+ }
+
void stop() => _isStopped = true;
SolanaRPC? get getSolanaProvider => _provider;
@@ -1263,6 +1453,7 @@ class SolanaWalletClient {
required bool isSendAll,
required Money solBalance,
String? tokenMint,
+ bool closeSenderAccountWhenEmptied = false,
List<String> references = const [],
}) async {
const commitment = Commitment.confirmed;
@@ -1285,6 +1476,7 @@ class SolanaWalletClient {
destinationAddress: destinationAddress,
commitment: commitment,
solBalance: solBalance,
+ closeSenderAccountWhenEmptied: closeSenderAccountWhenEmptied,
);
}
}
@@ -1589,6 +1781,7 @@ class SolanaWalletClient {
required SolanaPrivateKey ownerPrivateKey,
required Commitment commitment,
required Money solBalance,
+ bool closeSenderAccountWhenEmptied = false,
}) async {
final mintAddress = SolAddress(tokenMint);
final tokenProgramId = await _getTokenProgramId(mintAddress);
@@ -1716,6 +1909,30 @@ class SolanaWalletClient {
transferInstructions,
];
+ final senderAccountHasExtensions = senderAccountSpace != _baseTokenAccountSize;
+
+ if (closeSenderAccountWhenEmptied && !senderAccountHasExtensions) {
+ try {
+ final senderBalance = await _provider!.request(
+ SolanaRPCGetTokenAccountBalance(
+ account: associatedSenderAccount.address,
+ commitment: commitment,
+ ),
+ );
+
+ if (BigInt.tryParse(senderBalance.amount) == inputAmount.amount) {
+ instructions.add(SPLTokenProgram.closeAccount(
+ account: associatedSenderAccount.address,
+ destination: ownerPrivateKey.publicKey().toAddress(),
+ authority: ownerPrivateKey.publicKey().toAddress(),
+ programId: senderTokenProgramId,
+ ));
+ }
+ } catch (e) {
+ printV("Skipping rent reclaim, could not check sender balance: ${e.toString()}");
+ }
+ }
+
final latestBlockHash = await _getLatestBlockhash(commitment);
final transaction = SolanaTransaction(
@@ -1838,24 +2055,42 @@ class SolanaWalletClient {
final decodedResponse = jsonDecode(response.body) as List;
final List<MoralisSolanaTokenBalance> tokens = [];
+ final nftCandidates = <String>{};
for (final item in decodedResponse) {
- final tokenData = item as Map<String, dynamic>;
+ try {
+ final tokenData = item as Map<String, dynamic>;
- final amountStr = tokenData['amount'] as String? ?? '0';
- final amount = double.tryParse(amountStr) ?? 0.0;
+ final amountStr = tokenData["amount"] as String? ?? "0";
+ final amount = double.tryParse(amountStr) ?? 0.0;
- if (amount <= 0) continue;
+ if (amount <= 0) continue;
- final mint = tokenData['mint'] as String? ?? '';
- if (mint.isEmpty) continue;
+ final mint = tokenData["mint"] as String? ?? "";
+ if (mint.isEmpty) continue;
- tokens.add(
- MoralisSolanaTokenBalance(
- mint: mint,
- amount: amount,
- ),
- );
+ final amountRaw = tokenData["amountRaw"]?.toString() ?? "0";
+
+ final decimals = _parseMoralisDecimals(tokenData["decimals"]);
+
+ if (decimals == 0 && amountRaw == "1") {
+ nftCandidates.add(mint);
+ }
+
+ tokens.add(
+ MoralisSolanaTokenBalance(
+ mint: mint,
+ amount: amount,
+ ),
+ );
+ } catch (e) {
+ printV("Skipping malformed Moralis token entry: ${e.toString()}");
+ }
+ }
+
+ if (nftCandidates.isNotEmpty) {
+ final withheld = await _mintsToWithholdFromTokenList(nftCandidates);
+ tokens.removeWhere((token) => withheld.contains(token.mint));
}
return tokens;
@@ -1908,6 +2143,22 @@ class SolanaWalletClient {
}
}
+class NFTOnChainMetadata {
+ const NFTOnChainMetadata({
+ required this.mint,
+ required this.name,
+ required this.symbol,
+ required this.metadataUri,
+ this.imageUrl,
+ });
+
+ final String mint;
+ final String name;
+ final String symbol;
+ final String metadataUri;
+ final String? imageUrl;
+}
+
class MoralisSolanaTokenBalance {
final String mint;
final double amount;
diff --git a/cw_solana/lib/solana_exceptions.dart b/cw_solana/lib/solana_exceptions.dart
index a3873a37..fd009cb7 100644
--- a/cw_solana/lib/solana_exceptions.dart
+++ b/cw_solana/lib/solana_exceptions.dart
@@ -30,6 +30,8 @@ class SolanaCreateAssociatedTokenAccountException extends CreateAssociatedTokenA
class SolanaSignSPLTokenTransactionRentException extends SignSPLTokenTransactionRentException {}
+class SolanaNotAnNFTException extends NotAnNFTException {}
+
class SolanaNoAssociatedTokenAccountException extends NoAssociatedTokenAccountException {
SolanaNoAssociatedTokenAccountException(this.account, this.mint);
diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart
index 55ad9f9f..8253b589 100644
--- a/cw_solana/lib/solana_wallet.dart
+++ b/cw_solana/lib/solana_wallet.dart
@@ -323,6 +323,38 @@ abstract class SolanaWalletBase
return matches.first;
}
+ Future<Set<String>> heldTokenMints() => _client.fetchHeldTokenMints(walletAddresses.address);
+
+ Future<PendingTransaction> sendNFT({
+ required String mintAddress,
+ required String destinationAddress,
+ String? name,
+ }) async {
+ if (!await _client.isSupplyOfOne(mintAddress, throwOnError: true)) {
+ throw SolanaNotAnNFTException();
+ }
+
+ final nftCurrency = SPLToken(
+ name: name ?? "NFT",
+ symbol: "NFT",
+ mintAddress: mintAddress,
+ decimal: 0,
+ mint: mintAddress,
+ );
+
+ final solBalance = await _client.getBalance(walletAddresses.address, throwOnError: true);
+
+ return _client.signSolanaTransaction(
+ tokenMint: mintAddress,
+ inputAmount: Money(BigInt.one, nftCurrency),
+ ownerPrivateKey: _solanaPrivateKey,
+ destinationAddress: destinationAddress,
+ isSendAll: false,
+ solBalance: solBalance,
+ closeSenderAccountWhenEmptied: true,
+ );
+ }
+
@override
Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
diff --git a/cw_solana/lib/solana_wallet_service.dart b/cw_solana/lib/solana_wallet_service.dart
index 85eae602..0828d5f7 100644
--- a/cw_solana/lib/solana_wallet_service.dart
+++ b/cw_solana/lib/solana_wallet_service.dart
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:bip39/bip39.dart' as bip39;
import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/balance.dart';
+import "package:cw_core/imported_nft.dart";
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/spl_token.dart';
import 'package:cw_core/transaction_history.dart';
@@ -101,6 +102,7 @@ class SolanaWalletService extends WalletService<
final nameStillUsed = await WalletInfo.get(wallet, getType()) != null;
if (!nameStillUsed) {
await SPLToken.deleteAllForWallet(wallet);
+ await ImportedNFT.deleteAllForWallet(wallet, chains: const [ImportedNFT.solanaChain]);
}
final prefs = await SharedPreferences.getInstance();
diff --git a/lib/di.dart b/lib/di.dart
index ee904e21..a4f69170 100644
--- a/lib/di.dart
+++ b/lib/di.dart
@@ -223,6 +223,7 @@ import 'package:cake_wallet/view_model/dashboard/cake_features_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/desktop_sidebar_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/home_settings_view_model.dart';
+import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart";
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/receive_option_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
@@ -1700,6 +1701,9 @@ Future<void> setup({
getIt.registerFactory(() => NFTViewModel(appStore, getIt.get<BottomSheetService>()));
+ getIt.registerFactory(() =>
+ NFTSendViewModel(appStore, getIt.get<ContactListViewModel>(param1: CryptoCurrency.sol)));
+
getIt.registerFactory(() => SignViewModel(getIt.get<AppStore>().wallet!));
getIt.registerFactory(() => SeedVerificationPage(getIt.get<WalletSeedViewModel>()));
diff --git a/lib/entities/saved_nfts.dart b/lib/entities/saved_nfts.dart
new file mode 100644
index 00000000..9a76499a
--- /dev/null
+++ b/lib/entities/saved_nfts.dart
@@ -0,0 +1,116 @@
+import "package:cake_wallet/entities/solana_nft_asset_model.dart";
+import "package:cake_wallet/entities/wallet_nft_response.dart";
+import "package:cw_core/imported_nft.dart";
+
+class SavedNFTs {
+ Future<Map<String, SolanaNFTAssetModel>> solanaNFTs(String walletName) async {
+ final rows = await ImportedNFT.getAllForWallet(walletName, ImportedNFT.solanaChain);
+
+ return {
+ for (final row in rows)
+ row.identifier: SolanaNFTAssetModel(
+ address: row.identifier,
+ mint: row.identifier,
+ name: row.name,
+ symbol: row.symbol,
+ description: row.description,
+ imageOriginalUrl: row.imageUrl,
+ isOwned: row.isOwned,
+ ),
+ };
+ }
+
+ Future<void> addSolana(String walletName, SolanaNFTAssetModel asset) async {
+ final mint = asset.mint;
+
+ if (mint == null || mint.isEmpty) {
+ return;
+ }
+
+ await _rowForSolana(walletName, mint, asset).save();
+ }
+
+ Future<void> refreshSolana(String walletName, List<SolanaNFTAssetModel> assets) async {
+ for (final asset in assets) {
+ final mint = asset.mint;
+
+ if (mint == null) {
+ continue;
+ }
+
+ await ImportedNFT.updateMetadata(_rowForSolana(walletName, mint, asset));
+ }
+ }
+
+ Future<List<NFTAssetModel>> evmNFTs(String walletName, String chain) async {
+ final rows = await ImportedNFT.getAllForWallet(walletName, chain);
+
+ return rows
+ .map(
+ (row) => NFTAssetModel(
+ tokenAddress: _tokenAddressOf(row.identifier),
+ tokenId: _tokenIdOf(row.identifier),
+ name: row.name,
+ symbol: row.symbol,
+ normalizedMetadata: NormalizedMetadata(
+ name: row.name,
+ description: row.description,
+ image: row.imageUrl,
+ ),
+ ),
+ )
+ .toList();
+ }
+
+ Future<void> addEvm(String walletName, String chain, NFTAssetModel asset) async {
+ final identifier = evmIdentifier(asset);
+
+ if (identifier == null) {
+ return;
+ }
+
+ await ImportedNFT(
+ walletName: walletName,
+ chain: chain,
+ identifier: identifier,
+ name: asset.name,
+ symbol: asset.symbol,
+ description: asset.normalizedMetadata?.description,
+ imageUrl: asset.normalizedMetadata?.imageUrl,
+ ).save();
+ }
+
+ Future<void> removeSolana(String walletName, String mint) =>
+ ImportedNFT.deleteOne(walletName, ImportedNFT.solanaChain, mint);
+
+ static String? evmIdentifier(NFTAssetModel asset) {
+ final address = asset.tokenAddress;
+ final tokenId = asset.tokenId;
+
+ if (address == null || address.isEmpty) {
+ return null;
+ }
+
+ return "$address:${tokenId ?? ""}";
+ }
+
+ ImportedNFT _rowForSolana(String walletName, String mint, SolanaNFTAssetModel asset) =>
+ ImportedNFT(
+ walletName: walletName,
+ chain: ImportedNFT.solanaChain,
+ identifier: mint,
+ name: asset.name,
+ symbol: asset.symbol,
+ description: asset.description,
+ imageUrl: asset.imageOriginalUrl,
+ isOwned: asset.isOwned,
+ );
+
+ String _tokenAddressOf(String identifier) => identifier.split(":").first;
+
+ String _tokenIdOf(String identifier) {
+ final parts = identifier.split(":");
+
+ return parts.length > 1 ? parts.sublist(1).join(":") : "";
+ }
+}
diff --git a/lib/entities/solana_nft_asset_model.dart b/lib/entities/solana_nft_asset_model.dart
index 51f43c58..62f3df5c 100644
--- a/lib/entities/solana_nft_asset_model.dart
+++ b/lib/entities/solana_nft_asset_model.dart
@@ -1,3 +1,6 @@
+import "package:cw_core/utils/ipfs_url.dart";
+import "package:cw_core/utils/nft_text.dart";
+
class SolanaNFTAssetModel {
String? address;
String? mint;
@@ -12,46 +15,50 @@ class SolanaNFTAssetModel {
Metaplex? metaplex;
Collection? collection;
Contract? contract;
+ bool? isOwned;
SolanaNFTAssetModel({
this.address,
this.mint,
this.standard,
- this.name,
- this.symbol,
- this.description,
- this.imageOriginalUrl,
+ String? name,
+ String? symbol,
+ String? description,
+ String? imageOriginalUrl,
this.externalUrl,
this.metadataOriginalUrl,
this.totalSupply,
this.metaplex,
this.collection,
this.contract,
- });
+ this.isOwned,
+ }) : name = sanitizeNFTText(name),
+ symbol = sanitizeNFTText(symbol),
+ description = sanitizeNFTText(description),
+ imageOriginalUrl = tryNormalizeIpfsUrl(imageOriginalUrl);
- factory SolanaNFTAssetModel.fromJson(Map<String, dynamic> json) {
- return SolanaNFTAssetModel(
- address: json['address'] as String?,
- mint: json['mint'] as String?,
- standard: json['standard'] as String?,
- name: json['name'] as String?,
- symbol: json['symbol'] as String?,
- description: json['description'] as String?,
- imageOriginalUrl: json['imageOriginalUrl'] as String?,
- externalUrl: json['externalUrl'] as String?,
- metadataOriginalUrl: json['metadataOriginalUrl'] as String?,
- totalSupply: json['totalSupply'] as String?,
- metaplex: json['metaplex'] != null
- ? Metaplex.fromJson(json['metaplex'] as Map<String, dynamic>)
- : null,
- collection: json['collection'] != null
- ? Collection.fromJson(json['collection'] as Map<String, dynamic>)
- : null,
- contract: json['contract'] != null
- ? Contract.fromJson(json['contract'] as Map<String, dynamic>)
- : null,
- );
- }
+ factory SolanaNFTAssetModel.fromJson(Map<String, dynamic> json) => SolanaNFTAssetModel(
+ address: json["address"] as String?,
+ mint: json["mint"] as String?,
+ standard: json["standard"] as String?,
+ name: json["name"] as String?,
+ symbol: json["symbol"] as String?,
+ description: json["description"] as String?,
+ imageOriginalUrl: json["imageOriginalUrl"] as String?,
+ externalUrl: json["externalUrl"] as String?,
+ metadataOriginalUrl: json["metadataOriginalUrl"] as String?,
+ totalSupply: json["totalSupply"] as String?,
+ metaplex: json["metaplex"] != null
+ ? Metaplex.fromJson(json["metaplex"] as Map<String, dynamic>)
+ : null,
+ collection: json["collection"] != null
+ ? Collection.fromJson(json["collection"] as Map<String, dynamic>)
+ : null,
+ contract: json["contract"] != null
+ ? Contract.fromJson(json["contract"] as Map<String, dynamic>)
+ : null,
+ isOwned: json["isOwned"] as bool?,
+ );
}
class Metaplex {
@@ -100,17 +107,15 @@ class Collection {
this.metaplexMint,
this.sellerFeeBasisPoints});
- factory Collection.fromJson(Map<String, dynamic> json) {
- return Collection(
- collectionAddress: json['collectionAddress'] as String?,
- name: json['name'] as String?,
- description: json['description'] as String?,
- imageOriginalUrl: json['imageOriginalUrl'] as String?,
- externalUrl: json['externalUrl'] as String?,
- metaplexMint: json['metaplexMint'] as String?,
- sellerFeeBasisPoints: json['sellerFeeBasisPoints'] as int?,
- );
- }
+ factory Collection.fromJson(Map<String, dynamic> json) => Collection(
+ collectionAddress: json["collectionAddress"] as String?,
+ name: sanitizeNFTText(json["name"] as String?),
+ description: sanitizeNFTText(json["description"] as String?),
+ imageOriginalUrl: tryNormalizeIpfsUrl(json["imageOriginalUrl"] as String?),
+ externalUrl: json["externalUrl"] as String?,
+ metaplexMint: json["metaplexMint"] as String?,
+ sellerFeeBasisPoints: json["sellerFeeBasisPoints"] as int?,
+ );
}
class Contract {
@@ -123,8 +128,8 @@ class Contract {
factory Contract.fromJson(Map<String, dynamic> json) {
return Contract(
type: json['type'] as String?,
- name: json['name'] as String?,
- symbol: json['symbol'] as String?,
+ name: sanitizeNFTText(json["name"] as String?),
+ symbol: sanitizeNFTText(json["symbol"] as String?),
);
}
}
diff --git a/lib/entities/wallet_nft_response.dart b/lib/entities/wallet_nft_response.dart
index b35df93b..558b285c 100644
--- a/lib/entities/wallet_nft_response.dart
+++ b/lib/entities/wallet_nft_response.dart
@@ -1,3 +1,6 @@
+import "package:cw_core/utils/ipfs_url.dart";
+import "package:cw_core/utils/nft_text.dart";
+
class WalletNFTsResponseModel {
final int? page;
final int? pageSize;
@@ -40,8 +43,8 @@ class NFTAssetModel {
tokenAddress: json['token_address'] as String?,
tokenId: json['token_id'] as String?,
contractType: json['contract_type'] as String?,
- name: json['name'] as String?,
- symbol: json['symbol'] as String?,
+ name: sanitizeNFTText(json["name"] as String?),
+ symbol: sanitizeNFTText(json["symbol"] as String?),
normalizedMetadata: json['normalized_metadata'] != null
? new NormalizedMetadata.fromJson(json['normalized_metadata'] as Map<String, dynamic>)
: null,
@@ -61,33 +64,11 @@ class NormalizedMetadata {
factory NormalizedMetadata.fromJson(Map<String, dynamic> json) {
return NormalizedMetadata(
- name: json['name'] as String?,
- description: json['description'] as String?,
- image: json['image'] as String?,
+ name: sanitizeNFTText(json["name"] as String?),
+ description: sanitizeNFTText(json["description"] as String?),
+ image: json["image"] as String?,
);
}
- String? get imageUrl {
- if (image == null) return image;
-
- if (image!.contains('ipfs.io')) return image;
-
- if (!image!.contains('ipfs')) return image;
-
- // IPFS public gateway provided by Cloudflare is https://cloudflare-ipfs.com/ipfs/
- //
- // Here is an example of an ipfs image link:
- //
- // [ipfs://bafkreia2i2ctfexpovgzfff66wqhbmwwpvqjvozan7ioifzcnq76jharwu]
-
- //https://ipfs.io/ipfs/QmTRcRXo6cXByjHYHTVxGpag6vpocrG3rxjPC9PxKAArR9/1620.png
-
- const String ipfsPublicGateway = 'https://cloudflare-ipfs.com/ipfs/';
-
- final ipfsPath = image?.split('//')[1];
-
- final imageLink = '$ipfsPublicGateway$ipfsPath';
-
- return imageLink;
- }
+ String? get imageUrl => tryNormalizeIpfsUrl(image);
}
diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart
index d1e421d3..05450ca7 100644
--- a/lib/monero/cw_monero.dart
+++ b/lib/monero/cw_monero.dart
@@ -294,6 +294,23 @@ class CWMonero extends Monero {
};
}
+ @override
+ Future<int> getNodeHeight(Object wallet) => (wallet as MoneroWallet).getNodeHeight();
+
+ @override
+ String getLegacySeed(Object wallet, String langName) =>
+ (wallet as MoneroWallet).seedLegacy(langName);
+
+ @override
+ bool isBackgroundSyncRunning(Object wallet) => (wallet as MoneroWallet).isBackgroundSyncRunning;
+
+ @override
+ Future<void> startBackgroundSync(Object wallet) => (wallet as MoneroWallet).startBackgroundSync();
+
+ @override
+ Future<void> stopBackgroundSync(Object wallet, String password) =>
+ (wallet as MoneroWallet).stopBackgroundSync(password);
+
@override
int? getRestoreHeight(Object wallet) {
final moneroWallet = wallet as MoneroWallet;
diff --git a/lib/new-ui/pages/home_page.dart b/lib/new-ui/pages/home_page.dart
index f6244095..a7ba2694 100644
--- a/lib/new-ui/pages/home_page.dart
+++ b/lib/new-ui/pages/home_page.dart
@@ -1,3 +1,5 @@
+import "dart:async";
+
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/generated/i18n.dart';
@@ -18,9 +20,6 @@ import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart';
import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
-import "package:cw_core/amount/money.dart";
-import "package:cw_core/crypto_currency.dart";
-import "package:cw_core/transaction_direction.dart";
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -53,9 +52,9 @@ class _NewHomePageState extends State<NewHomePage> {
});
});
- reaction((_) => widget.dashboardViewModel.isMigratingToIronwood, (val) {
+ reaction((_) => widget.dashboardViewModel.isMigratingToIronwood, (val) {
if (val && !widget.dashboardViewModel.settingsStore.zcashMigrationModalViewed) {
- if(!context.mounted) {
+ if (!context.mounted) {
return;
}
widget.dashboardViewModel.settingsStore.zcashMigrationModalViewed = true;
@@ -102,7 +101,11 @@ class _NewHomePageState extends State<NewHomePage> {
sliver: CupertinoSliverRefreshControl(
refreshTriggerPullDistance: 160,
refreshIndicatorExtent: 90,
- onRefresh: () => widget.dashboardViewModel.refreshDashboard(),
+ onRefresh: () {
+ unawaited(widget.nftViewModel.getNFTAssetByWallet());
+
+ return widget.dashboardViewModel.refreshDashboard();
+ },
),
),
SliverToBoxAdapter(
diff --git a/lib/router.dart b/lib/router.dart
index 643a03cd..a583e587 100644
--- a/lib/router.dart
+++ b/lib/router.dart
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart';
+import "package:cake_wallet/core/auth_service.dart";
import 'package:cake_wallet/core/new_wallet_arguments.dart';
import 'package:cake_wallet/new-ui/new_dashboard.dart';
import 'package:cake_wallet/new-ui/pages/about_page.dart';
@@ -44,6 +45,7 @@ import 'package:cake_wallet/src/screens/dashboard/edit_token_page.dart';
import 'package:cake_wallet/src/screens/dashboard/home_settings_page.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/address_page.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart';
+import "package:cake_wallet/src/screens/dashboard/pages/nft_send_page.dart";
import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart';
import 'package:cake_wallet/src/screens/dashboard/sign_page.dart';
import 'package:cake_wallet/src/screens/dev/exchange_provider_logs_page.dart';
@@ -143,6 +145,7 @@ import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cake_wallet/view_model/bridge/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
+import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart";
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart';
@@ -962,7 +965,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
));
case Routes.nftDetailsPage:
- return MaterialPageRoute<void>(
+ return MaterialPageRoute<bool>(
builder: (_) => NFTDetailsPage(
arguments: settings.arguments as NFTDetailsPageArguments,
dashboardViewModel: getIt.get<DashboardViewModel>(),
@@ -976,6 +979,15 @@ Route<dynamic> createRoute(RouteSettings settings) {
),
);
+ case Routes.nftSendPage:
+ return MaterialPageRoute<bool>(
+ builder: (_) => NFTSendPage(
+ nftSendViewModel: getIt.get<NFTSendViewModel>(),
+ authService: getIt.get<AuthService>(),
+ arguments: settings.arguments as NFTSendPageArguments,
+ ),
+ );
+
case Routes.signPage:
final vm = getIt.get<SignViewModel>();
return MaterialPageRoute<void>(
diff --git a/lib/routes.dart b/lib/routes.dart
index b21e2bc3..9417a3e5 100644
--- a/lib/routes.dart
+++ b/lib/routes.dart
@@ -115,6 +115,7 @@ class Routes {
static const walletConnectConnectionsListing = '/wallet-connect-connections-listing';
static const nftDetailsPage = '/nft_details_page';
static const importNFTPage = '/import_nft_page';
+ static const nftSendPage = "/nft_send_page";
static const backgroundSync = '/background_sync';
static const startTor = '/start_tor';
static const receiveAddresses = "/receive_addresses";
diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart
index 46edfca2..d235fc8a 100644
--- a/lib/solana/cw_solana.dart
+++ b/lib/solana/cw_solana.dart
@@ -384,6 +384,40 @@ class CWSolana extends Solana {
}
}
+ @override
+ Future<PendingTransaction> sendNFT(
+ WalletBase wallet, {
+ required String mintAddress,
+ required String destinationAddress,
+ String? name,
+ }) =>
+ (wallet as SolanaWallet).sendNFT(
+ mintAddress: mintAddress,
+ destinationAddress: destinationAddress,
+ name: name,
+ );
+
+ @override
+ Future<SolanaNFTMetadata?> getNFTOnChainMetadata(WalletBase wallet, String mintAddress) async {
+ final metadata = await (wallet as SolanaWallet).client.getNFTOnChainMetadata(mintAddress);
+
+ if (metadata == null) {
+ return null;
+ }
+
+ return SolanaNFTMetadata(
+ mint: metadata.mint,
+ name: metadata.name,
+ symbol: metadata.symbol,
+ metadataUri: metadata.metadataUri,
+ imageUrl: metadata.imageUrl,
+ );
+ }
+
+ @override
+ Future<Set<String>> getHeldTokenMints(WalletBase wallet) =>
+ (wallet as SolanaWallet).heldTokenMints();
+
@override
Future<void> discoverAndAddWalletTokens(WalletBase wallet) async {
if (wallet is! SolanaWallet) return;
diff --git a/lib/src/screens/dashboard/dashboard_page.dart b/lib/src/screens/dashboard/dashboard_page.dart
index e2ed2808..f4f15173 100644
--- a/lib/src/screens/dashboard/dashboard_page.dart
+++ b/lib/src/screens/dashboard/dashboard_page.dart
@@ -87,7 +87,11 @@ class _DashboardPageState extends State<DashboardPage> {
final dashboardPageView = RefreshIndicator(
displacement: responsiveLayoutUtil.screenHeight * 0.1,
- onRefresh: () async => await widget.dashboardViewModel.refreshDashboard(),
+ onRefresh: () async {
+ unawaited(widget.balancePage.nftViewModel.getNFTAssetByWallet());
+
+ await widget.dashboardViewModel.refreshDashboard();
+ },
child: SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
child: Container(
diff --git a/lib/src/screens/dashboard/pages/nft_details_page.dart b/lib/src/screens/dashboard/pages/nft_details_page.dart
index f7076c48..86f28187 100644
--- a/lib/src/screens/dashboard/pages/nft_details_page.dart
+++ b/lib/src/screens/dashboard/pages/nft_details_page.dart
@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import 'package:cake_wallet/entities/wallet_nft_response.dart';
import 'package:cake_wallet/generated/i18n.dart';
+import "package:cake_wallet/routes.dart";
import 'package:cake_wallet/src/screens/base_page.dart';
+import "package:cake_wallet/src/screens/dashboard/pages/nft_send_page.dart";
import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/src/widgets/gradient_background.dart';
+import "package:cake_wallet/src/widgets/primary_button.dart";
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
class NFTDetailsPage extends BasePage {
@@ -56,30 +59,56 @@ class NFTDetailsPage extends BasePage {
// );
}
+ bool get _canSend =>
+ arguments.isSolanaNFT &&
+ (arguments.solanaNFTAssetModel?.mint?.isNotEmpty ?? false) &&
+ arguments.solanaNFTAssetModel?.isOwned != false;
+
@override
- Widget body(BuildContext context) {
- return SingleChildScrollView(
- child: Container(
- width: double.infinity,
- margin: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(24.0),
- border: Border.all(
- color: Theme.of(context).colorScheme.outline,
- width: 1,
- ),
- color: Theme.of(context).colorScheme.surface,
- ),
- child: arguments.isSolanaNFT
- ? SolanaNFTDetailsWidget(
- solanaNftAsset: arguments.solanaNFTAssetModel,
- )
- : EVMChainNFTDetailsWidget(
- nftAsset: arguments.nftAsset,
+ Widget body(BuildContext context) => SingleChildScrollView(
+ child: Column(
+ children: [
+ Container(
+ width: double.infinity,
+ margin: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24.0),
+ border: Border.all(
+ color: Theme.of(context).colorScheme.outline,
+ width: 1,
+ ),
+ color: Theme.of(context).colorScheme.surface,
),
- ),
- );
- }
+ child: arguments.isSolanaNFT
+ ? SolanaNFTDetailsWidget(
+ solanaNftAsset: arguments.solanaNFTAssetModel,
+ )
+ : EVMChainNFTDetailsWidget(
+ nftAsset: arguments.nftAsset,
+ ),
+ ),
+ if (_canSend)
+ Padding(
+ padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24),
+ child: PrimaryButton(
+ text: S.of(context).send,
+ color: Theme.of(context).colorScheme.primary,
+ textColor: Theme.of(context).colorScheme.onPrimary,
+ onPressed: () async {
+ final sent = await Navigator.of(context).pushNamed(
+ Routes.nftSendPage,
+ arguments: NFTSendPageArguments(asset: arguments.solanaNFTAssetModel!),
+ );
+
+ if (sent == true && context.mounted) {
+ Navigator.of(context).pop(true);
+ }
+ },
+ ),
+ ),
+ ],
+ ),
+ );
}
class _NFTImageWidget extends StatelessWidget {
@@ -162,6 +191,15 @@ class SolanaNFTDetailsWidget extends StatelessWidget {
return Center(child: Text(S.current.no_extra_detail));
}
+ final moreInfoEntries = <MapEntry<String, String>>[
+ MapEntry(S.current.mint_address, solanaNftAsset?.mint ?? ""),
+ MapEntry(S.current.contractName, solanaNftAsset?.contract?.name ?? ""),
+ MapEntry(S.current.contractSymbol, solanaNftAsset?.contract?.symbol ?? ""),
+ MapEntry(S.current.collection_name, solanaNftAsset?.collection?.name ?? ""),
+ MapEntry(S.current.collection_description, solanaNftAsset?.collection?.description ?? ""),
+ MapEntry(S.current.collection_address, solanaNftAsset?.collection?.collectionAddress ?? ""),
+ ].where((entry) => entry.value.isNotEmpty).toList();
+
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -171,49 +209,75 @@ class SolanaNFTDetailsWidget extends StatelessWidget {
infoType: S.current.name,
infoValue: solanaNftAsset?.name ?? '---',
),
- if (solanaNftAsset?.description != null) ...[
+ if (solanaNftAsset?.isOwned == false) ...[
+ const SizedBox(height: 16),
+ _NFTSingleInfoTile(
+ infoType: S.current.watching,
+ infoValue: S.current.nft_not_held_by_wallet,
+ ),
+ ],
+ if (solanaNftAsset?.description?.isNotEmpty ?? false) ...[
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.description,
- infoValue: solanaNftAsset!.description ?? '---',
+ infoValue: solanaNftAsset!.description!,
),
],
- const SizedBox(height: 16),
- _NFTSingleInfoTile(
- infoType: S.current.mint_address,
- infoValue: solanaNftAsset?.mint ?? '---',
- ),
- const SizedBox(height: 16),
- _NFTSingleInfoTile(
- infoType: S.current.contractName,
- infoValue: solanaNftAsset?.contract?.name ?? '---',
- ),
- const SizedBox(height: 16),
- _NFTSingleInfoTile(
- infoType: S.current.contractSymbol,
- infoValue: solanaNftAsset?.contract?.symbol ?? '---',
- ),
- const SizedBox(height: 16),
- _NFTSingleInfoTile(
- infoType: S.current.collection_name,
- infoValue: solanaNftAsset?.collection?.name ?? '---',
- ),
- const SizedBox(height: 16),
- _NFTSingleInfoTile(
- infoType: S.current.collection_description,
- infoValue: solanaNftAsset?.collection?.description ?? '---',
- ),
- const SizedBox(height: 16),
- _NFTSingleInfoTile(
- infoType: S.current.collection_address,
- infoValue: solanaNftAsset?.collection?.collectionAddress ?? '---',
- ),
+ if (moreInfoEntries.isNotEmpty) _NFTMoreInfoSection(entries: moreInfoEntries),
const SizedBox(height: 16),
],
);
}
}
+class _NFTMoreInfoSection extends StatefulWidget {
+ const _NFTMoreInfoSection({required this.entries});
+
+ final List<MapEntry<String, String>> entries;
+
+ @override
+ State<_NFTMoreInfoSection> createState() => _NFTMoreInfoSectionState();
+}
+
+class _NFTMoreInfoSectionState extends State<_NFTMoreInfoSection> {
+ bool isExpanded = false;
+
+ @override
+ Widget build(BuildContext context) => Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SizedBox(height: 8),
+ InkWell(
+ onTap: () => setState(() => isExpanded = !isExpanded),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ isExpanded ? S.current.hide_details : S.current.show_details,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ Icon(
+ isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (isExpanded)
+ for (final entry in widget.entries) ...[
+ const SizedBox(height: 16),
+ _NFTSingleInfoTile(infoType: entry.key, infoValue: entry.value),
+ ],
+ ],
+ );
+}
+
class _NFTSingleInfoTile extends StatelessWidget {
final String infoType;
final String infoValue;
diff --git a/lib/src/screens/dashboard/pages/nft_import_page.dart b/lib/src/screens/dashboard/pages/nft_import_page.dart
index 7efa7bc2..cb78cdb4 100644
--- a/lib/src/screens/dashboard/pages/nft_import_page.dart
+++ b/lib/src/screens/dashboard/pages/nft_import_page.dart
@@ -89,7 +89,7 @@ class _ImportNFTPage extends BasePage {
},
fillColor: Theme.of(context).colorScheme.surfaceContainer,
iconColor: Theme.of(context).colorScheme.primary,
- placeholder: '0x...',
+ placeholder: nftViewModel.isSolanaWallet ? S.current.mint_address : "0x...",
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
diff --git a/lib/src/screens/dashboard/pages/nft_listing_page.dart b/lib/src/screens/dashboard/pages/nft_listing_page.dart
index 2eaf01e7..5bf624dd 100644
--- a/lib/src/screens/dashboard/pages/nft_listing_page.dart
+++ b/lib/src/screens/dashboard/pages/nft_listing_page.dart
@@ -7,6 +7,7 @@ import 'package:cake_wallet/src/screens/dashboard/widgets/solana_nft_tile_widget
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cw_core/wallet_type.dart';
+import "package:mobx/mobx.dart";
class NFTListingPage extends StatefulWidget {
final NFTViewModel nftViewModel;
@@ -18,23 +19,34 @@ class NFTListingPage extends StatefulWidget {
}
class _NFTListingPageState extends State<NFTListingPage> {
+ late final ReactionDisposer _walletDisposer;
+
@override
void initState() {
super.initState();
+ _walletDisposer = reaction(
+ (_) => widget.nftViewModel.appStore.wallet,
+ (_) => widget.nftViewModel.onWalletChanged(),
+ );
+
fetchNFTsForWallet();
}
+ @override
+ void dispose() {
+ _walletDisposer();
+ super.dispose();
+ }
+
Future<void> fetchNFTsForWallet() async {
await widget.nftViewModel.getNFTAssetByWallet();
}
@override
- Widget build(BuildContext context) {
- return SliverToBoxAdapter(
- child: Observer(
- builder: (context) {
- return Column(
+ Widget build(BuildContext context) => SliverToBoxAdapter(
+ child: Observer(
+ builder: (context) => Column(
children: [
const SizedBox(height: 16),
Padding(
@@ -50,6 +62,17 @@ class _NFTListingPageState extends State<NFTListingPage> {
),
),
),
+ if (widget.nftViewModel.unresolvedNFTCount > 0)
+ Padding(
+ padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
+ child: Text(
+ S.current.nft_some_not_loaded,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
if (widget.nftViewModel.isLoading)
Center(
child: CircularProgressIndicator(
@@ -62,11 +85,9 @@ class _NFTListingPageState extends State<NFTListingPage> {
else
NFTListWidget(nftViewModel: widget.nftViewModel)
],
- );
- },
- ),
- );
- }
+ ),
+ ),
+ );
}
class NFTListWidget extends StatelessWidget {
@@ -103,7 +124,7 @@ class NFTListWidget extends StatelessWidget {
itemCount: nftViewModel.solanaNftAssetModels.length,
itemBuilder: (context, index) {
final nftAsset = nftViewModel.solanaNftAssetModels[index];
- return SolanaNFTTileWidget(nftAsset: nftAsset);
+ return SolanaNFTTileWidget(nftAsset: nftAsset, nftViewModel: nftViewModel);
},
),
);
diff --git a/lib/src/screens/dashboard/pages/nft_send_page.dart b/lib/src/screens/dashboard/pages/nft_send_page.dart
new file mode 100644
index 00000000..55897405
--- /dev/null
+++ b/lib/src/screens/dashboard/pages/nft_send_page.dart
@@ -0,0 +1,375 @@
+import "package:cake_wallet/core/address_validator.dart";
+import "package:cake_wallet/core/auth_service.dart";
+import "package:cake_wallet/core/execution_state.dart";
+import "package:cake_wallet/entities/solana_nft_asset_model.dart";
+import "package:cake_wallet/generated/i18n.dart";
+import "package:cake_wallet/src/screens/base_page.dart";
+import "package:cake_wallet/src/widgets/address_text_field.dart";
+import "package:cake_wallet/src/widgets/alert_with_one_action.dart";
+import "package:cake_wallet/src/widgets/alert_with_two_actions.dart";
+import "package:cake_wallet/src/widgets/cake_image_widget.dart";
+import "package:cake_wallet/src/widgets/gradient_background.dart";
+import "package:cake_wallet/src/widgets/primary_button.dart";
+import "package:cake_wallet/utils/show_pop_up.dart";
+import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart";
+import "package:cw_core/crypto_currency.dart";
+import "package:cw_core/pending_transaction.dart";
+import "package:flutter/material.dart";
+import "package:flutter_mobx/flutter_mobx.dart";
+
+class NFTSendPage extends StatefulWidget {
+ const NFTSendPage({
+ required this.nftSendViewModel,
+ required this.authService,
+ required this.arguments,
+ super.key,
+ });
+
+ final NFTSendViewModel nftSendViewModel;
+ final AuthService authService;
+ final NFTSendPageArguments arguments;
+
+ @override
+ State<NFTSendPage> createState() => _NFTSendPageState();
+}
+
+class _NFTSendPageState extends State<NFTSendPage> {
+ final GlobalKey<FormState> formKey = GlobalKey<FormState>();
+ late TextEditingController addressController;
+
+ @override
+ void initState() {
+ super.initState();
+ addressController = TextEditingController();
+ }
+
+ @override
+ void dispose() {
+ addressController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => _NFTSendPage(
+ nftSendViewModel: widget.nftSendViewModel,
+ authService: widget.authService,
+ asset: widget.arguments.asset,
+ formKey: formKey,
+ addressController: addressController,
+ );
+}
+
+class _NFTSendPage extends BasePage {
+ _NFTSendPage({
+ required this.nftSendViewModel,
+ required this.authService,
+ required this.asset,
+ required this.formKey,
+ required this.addressController,
+ });
+
+ final NFTSendViewModel nftSendViewModel;
+ final AuthService authService;
+ final SolanaNFTAssetModel asset;
+ final GlobalKey<FormState> formKey;
+ final TextEditingController addressController;
+
+ @override
+ String get title => S.current.send;
+
+ @override
+ bool get gradientBackground => true;
+
+ @override
+ Widget Function(BuildContext, Widget) get rootWrapper =>
+ (context, scaffold) => GradientBackground(scaffold: scaffold);
+
+ @override
+ Widget body(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Form(
+ key: formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Container(
+ height: MediaQuery.sizeOf(context).height / 3.5,
+ clipBehavior: Clip.hardEdge,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(color: colorScheme.outline),
+ color: colorScheme.surface,
+ ),
+ child: CakeImageWidget(imageUrl: asset.imageOriginalUrl),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ (asset.name?.isNotEmpty ?? false) ? asset.name! : (asset.symbol ?? "---"),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ const SizedBox(height: 24),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: colorScheme.surfaceContainer,
+ ),
+ child: AddressTextField(
+ controller: addressController,
+ placeholder: S.of(context).recipient_address,
+ options: const [
+ AddressTextFieldOption.paste,
+ AddressTextFieldOption.qrCode,
+ ],
+ buttonColor: colorScheme.surfaceContainerHighest,
+ validator: AddressValidator(type: CryptoCurrency.sol).call,
+ ),
+ ),
+ const SizedBox(height: 24),
+ Observer(
+ builder: (_) => LoadingPrimaryButton(
+ text: S.of(context).send,
+ color: colorScheme.primary,
+ textColor: colorScheme.onPrimary,
+ isLoading: nftSendViewModel.state is IsExecutingState,
+ onPressed: () => _onSendPressed(context),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future<void> _onSendPressed(BuildContext context) async {
+ if (nftSendViewModel.state is IsExecutingState) {
+ return;
+ }
+
+ if (!(formKey.currentState?.validate() ?? false)) {
+ return;
+ }
+
+ nftSendViewModel.markSendStarted();
+
+ final destinationAddress = addressController.text.trim();
+
+ await authService.authenticateAction(
+ context,
+ conditionToDetermineIfToUse2FA: nftSendViewModel.shouldRequireTOTP2FAFor(destinationAddress),
+ onAuthSuccess: (isAuthenticatedSuccessfully) async {
+ if (!isAuthenticatedSuccessfully || !context.mounted) {
+ nftSendViewModel.reset();
+ return;
+ }
+
+ await _createAndConfirm(context, destinationAddress);
+ },
+ );
+ }
+
+ Future<void> _createAndConfirm(BuildContext context, String destinationAddress) async {
+ await nftSendViewModel.createTransaction(asset, destinationAddress);
+
+ if (!context.mounted) {
+ return;
+ }
+
+ final state = nftSendViewModel.state;
+
+ if (state is FailureState) {
+ await _showError(context, state.error);
+ return;
+ }
+
+ final pendingTransaction = nftSendViewModel.pendingTransaction;
+
+ if (pendingTransaction == null) {
+ return;
+ }
+
+ final confirmed = await _confirm(context, destinationAddress, pendingTransaction);
+
+ if (!context.mounted) {
+ return;
+ }
+
+ if (!confirmed) {
+ nftSendViewModel.reset();
+ return;
+ }
+
+ await _commit(context);
+ }
+
+ Future<bool> _confirm(
+ BuildContext context,
+ String destinationAddress,
+ PendingTransaction pendingTransaction,
+ ) async {
+ final additionalCost = pendingTransaction.additionalCost;
+
+ var confirmed = false;
+
+ await showPopUp<void>(
+ context: context,
+ builder: (dialogContext) => AlertWithTwoActions(
+ alertTitle: S.of(context).confirm_sending,
+ alertContent: "",
+ alertContentTextWidget: _NFTSendConfirmation(
+ name: asset.name,
+ mint: asset.mint,
+ destinationAddress: destinationAddress,
+ fee: pendingTransaction.feeFormatted,
+ accountCreationFee: additionalCost?.toStringWithSymbol(fractionalDigits: 8),
+ ),
+ leftButtonText: S.of(context).cancel,
+ rightButtonText: S.of(context).send,
+ actionLeftButton: () => Navigator.of(dialogContext).pop(),
+ actionRightButton: () {
+ confirmed = true;
+ Navigator.of(dialogContext).pop();
+ },
+ ),
+ );
+
+ return confirmed;
+ }
+
+ Future<void> _commit(BuildContext context) async {
+ await nftSendViewModel.commitTransaction();
+
+ if (!context.mounted) {
+ return;
+ }
+
+ final state = nftSendViewModel.state;
+ if (state is FailureState) {
+ await _showError(context, state.error);
+ return;
+ }
+
+ await showPopUp<void>(
+ context: context,
+ builder: (dialogContext) => AlertWithOneAction(
+ alertTitle: "",
+ alertContent: S.of(context).transaction_sent,
+ buttonText: S.of(context).ok,
+ buttonAction: () => Navigator.of(dialogContext).pop(),
+ ),
+ );
+
+ if (!context.mounted) {
+ return;
+ }
+
+ Navigator.of(context).pop(true);
+ }
+
+ Future<void> _showError(BuildContext context, String error) => showPopUp<void>(
+ context: context,
+ builder: (dialogContext) => AlertWithOneAction(
+ alertTitle: S.of(context).error,
+ alertContent: error,
+ buttonText: S.of(context).ok,
+ buttonAction: () => Navigator.of(dialogContext).pop(),
+ ),
+ );
+}
+
+class NFTSendPageArguments {
+ NFTSendPageArguments({required this.asset});
+
+ final SolanaNFTAssetModel asset;
+}
+
+class _NFTSendConfirmation extends StatelessWidget {
+ const _NFTSendConfirmation({
+ required this.name,
+ required this.mint,
+ required this.destinationAddress,
+ required this.fee,
+ required this.accountCreationFee,
+ });
+
+ final String? name;
+ final String? mint;
+ final String destinationAddress;
+ final String fee;
+ final String? accountCreationFee;
+
+ @override
+ Widget build(BuildContext context) {
+ final creationFee = accountCreationFee;
+
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 12,
+ children: [
+ if (name?.isNotEmpty ?? false) _ConfirmationRow(label: S.of(context).name, value: name!),
+ if (mint?.isNotEmpty ?? false)
+ _ConfirmationRow(label: S.of(context).mint_address, value: mint!),
+ _ConfirmationRow(label: S.of(context).address, value: destinationAddress),
+ _ConfirmationRow(label: S.of(context).send_estimated_fee, value: fee),
+ if (creationFee != null)
+ Text(
+ S.of(context).recipient_account_creation_fee(creationFee),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ decoration: TextDecoration.none,
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _ConfirmationRow extends StatelessWidget {
+ const _ConfirmationRow({required this.label, required this.value});
+
+ final String label;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ final textTheme = Theme.of(context).textTheme;
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ label,
+ textAlign: TextAlign.center,
+ style: textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ decoration: TextDecoration.none,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ value,
+ textAlign: TextAlign.center,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: colorScheme.onSurface,
+ decoration: TextDecoration.none,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart b/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart
index 6cf31173..1609c46c 100644
--- a/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart
+++ b/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart
@@ -1,87 +1,127 @@
import 'package:flutter/material.dart';
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
+import "package:cake_wallet/generated/i18n.dart";
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
+import "package:cake_wallet/view_model/dashboard/nft_view_model.dart";
class SolanaNFTTileWidget extends StatelessWidget {
- const SolanaNFTTileWidget({super.key, required this.nftAsset});
+ const SolanaNFTTileWidget({
+ required this.nftAsset,
+ required this.nftViewModel,
+ super.key,
+ });
final SolanaNFTAssetModel nftAsset;
+ final NFTViewModel nftViewModel;
@override
- Widget build(BuildContext context) {
- return InkWell(
- onTap: () {
- Navigator.pushNamed(
- context,
- Routes.nftDetailsPage,
- arguments: NFTDetailsPageArguments(
- isSolanaNFT: true,
- solanaNFTAssetModel: nftAsset,
- ),
- );
- },
- child: Container(
- width: double.infinity,
- margin: const EdgeInsets.symmetric(horizontal: 16),
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(16.0),
- border: Border.all(
- color: Theme.of(context).colorScheme.outline,
- width: 0.0,
+ Widget build(BuildContext context) => InkWell(
+ onTap: () async {
+ final walletName = nftViewModel.currentWalletName;
+
+ final sent = await Navigator.of(context).pushNamed(
+ Routes.nftDetailsPage,
+ arguments: NFTDetailsPageArguments(
+ isSolanaNFT: true,
+ solanaNFTAssetModel: nftAsset,
+ ),
+ );
+
+ if (sent != true) {
+ return;
+ }
+
+ final mint = nftAsset.mint;
+ if (walletName != null && mint != null && mint.isNotEmpty) {
+ await nftViewModel.onNFTSent(walletName, mint);
+ }
+ },
+ child: Container(
+ width: double.infinity,
+ margin: const EdgeInsets.symmetric(horizontal: 16),
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16.0),
+ border: Border.all(
+ color: Theme.of(context).colorScheme.outline,
+ width: 0.0,
+ ),
+ color: Theme.of(context).colorScheme.surfaceContainer,
),
- color: Theme.of(context).colorScheme.surfaceContainer,
- ),
- child: Row(
- children: [
- Container(
- height: 100,
- width: 100,
- margin: const EdgeInsets.all(8),
- clipBehavior: Clip.hardEdge,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(24.0),
- border: Border.all(
- color: Theme.of(context).colorScheme.outline,
- width: 0.0,
+ child: Row(
+ children: [
+ Container(
+ height: 100,
+ width: 100,
+ margin: const EdgeInsets.all(8),
+ clipBehavior: Clip.hardEdge,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24.0),
+ border: Border.all(
+ color: Theme.of(context).colorScheme.outline,
+ width: 0.0,
+ ),
+ color: Theme.of(context).colorScheme.surface,
+ ),
+ child: CakeImageWidget(
+ imageUrl: nftAsset.imageOriginalUrl,
),
- color: Theme.of(context).colorScheme.surface,
- ),
- child: CakeImageWidget(
- imageUrl: nftAsset.imageOriginalUrl,
),
- ),
- Expanded(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Symbol: ${nftAsset.symbol ?? '---'}',
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: Theme.of(context).colorScheme.onSurfaceVariant,
- height: 1,
- ),
- ),
- const SizedBox(height: 8),
- Text(
- (nftAsset.name?.isNotEmpty ?? false)
- ? nftAsset.name!
- : (nftAsset.symbol ?? '---'),
- style: Theme.of(context).textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.w900,
- color: Theme.of(context).colorScheme.onSurface,
- height: 1,
- ),
- ),
- ],
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Symbol: ${nftAsset.symbol ?? "---"}",
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ height: 1,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ (nftAsset.name?.isNotEmpty ?? false)
+ ? nftAsset.name!
+ : (nftAsset.symbol ?? "---"),
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w900,
+ color: Theme.of(context).colorScheme.onSurface,
+ height: 1,
+ ),
+ ),
+ if (nftAsset.isOwned == false) ...[
+ const SizedBox(height: 8),
+ const _WatchingBadge(),
+ ],
+ ],
+ ),
+ )
+ ],
+ ),
+ ),
+ );
+}
+
+class _WatchingBadge extends StatelessWidget {
+ const _WatchingBadge();
+
+ @override
+ Widget build(BuildContext context) => Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8.0),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ ),
+ child: Text(
+ S.current.watching,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
+ height: 1,
),
- )
- ],
),
- ),
- );
- }
+ );
}
diff --git a/lib/view_model/dashboard/nft_send_view_model.dart b/lib/view_model/dashboard/nft_send_view_model.dart
new file mode 100644
index 00000000..2ebb46a9
--- /dev/null
+++ b/lib/view_model/dashboard/nft_send_view_model.dart
@@ -0,0 +1,132 @@
+import "dart:async";
+
+import "package:cake_wallet/core/execution_state.dart";
+import "package:cake_wallet/entities/solana_nft_asset_model.dart";
+import "package:cake_wallet/generated/i18n.dart";
+import "package:cake_wallet/solana/solana.dart";
+import "package:cake_wallet/store/app_store.dart";
+import "package:cake_wallet/view_model/contact_list/contact_list_view_model.dart";
+import "package:cw_core/exceptions.dart";
+import "package:cw_core/pending_transaction.dart";
+import "package:cw_core/utils/print_verbose.dart";
+import "package:cw_core/wallet_base.dart";
+import "package:mobx/mobx.dart";
+
+part "nft_send_view_model.g.dart";
+
+class NFTSendViewModel = NFTSendViewModelBase with _$NFTSendViewModel;
+
+abstract class NFTSendViewModelBase with Store {
+ NFTSendViewModelBase(this._appStore, this._contactListViewModel);
+
+ final AppStore _appStore;
+ final ContactListViewModel _contactListViewModel;
+
+ @observable
+ ExecutionState state = InitialExecutionState();
+
+ @observable
+ PendingTransaction? pendingTransaction;
+
+ bool shouldRequireTOTP2FAFor(String destinationAddress) {
+ final settingsStore = _appStore.settingsStore;
+
+ final isContact = _contactListViewModel.contactsToShow
+ .any((contact) => contact.address == destinationAddress);
+
+ if (isContact) {
+ return settingsStore.shouldRequireTOTP2FAForSendsToContact;
+ }
+
+ final isInternalWallet = _contactListViewModel.walletContactsToShow
+ .any((contact) => contact.address == destinationAddress);
+
+ if (isInternalWallet) {
+ return settingsStore.shouldRequireTOTP2FAForSendsToInternalWallets;
+ }
+
+ return settingsStore.shouldRequireTOTP2FAForSendsToNonContact;
+ }
+
+ @action
+ Future<void> createTransaction(SolanaNFTAssetModel asset, String destinationAddress) async {
+ state = IsExecutingState();
+
+ try {
+ pendingTransaction = await solana!.sendNFT(
+ _appStore.wallet!,
+ mintAddress: asset.mint!,
+ destinationAddress: destinationAddress,
+ name: asset.name,
+ );
+
+ state = ExecutedSuccessfullyState();
+ } catch (e) {
+ pendingTransaction = null;
+ state = FailureState(_errorMessage(e));
+ }
+ }
+
+ @action
+ Future<void> commitTransaction() async {
+ state = IsExecutingState();
+
+ try {
+ final wallet = _appStore.wallet!;
+ final transaction = pendingTransaction!;
+
+ await transaction.commit();
+
+ final signature = transaction.id;
+
+ pendingTransaction = null;
+ state = ExecutedSuccessfullyState();
+
+ if (signature.isNotEmpty) {
+ unawaited(_pollForTransaction(wallet, signature));
+ }
+ } catch (e) {
+ state = FailureState(_errorMessage(e));
+ }
+ }
+
+ @action
+ void markSendStarted() => state = IsExecutingState();
+
+ @action
+ void reset() {
+ pendingTransaction = null;
+ state = InitialExecutionState();
+ }
+
+ Future<void> _pollForTransaction(WalletBase wallet, String signature) async {
+ try {
+ await solana!.pollForTransaction(wallet, signature);
+ } catch (e) {
+ printV("Failed to poll for NFT transfer: ${e.toString()}");
+ }
+ }
+
+ String _errorMessage(Object error) {
+ if (error is NotAnNFTException) {
+ return S.current.nft_send_not_an_nft;
+ }
+
+ if (error is NoAssociatedTokenAccountException) {
+ return S.current.solana_no_associated_token_account_exception;
+ }
+
+ if (error is SignSPLTokenTransactionRentException) {
+ return S.current.solana_sign_spl_token_transaction_rent_exception;
+ }
+
+ if (error is CreateAssociatedTokenAccountException) {
+ return "${S.current.solana_create_associated_token_account_exception} "
+ "${S.current.added_message_for_ata_error}";
+ }
+
+ printV("NFT send failed: ${error.toString()}");
+
+ return error.toString();
+ }
+}
diff --git a/lib/view_model/dashboard/nft_view_model.dart b/lib/view_model/dashboard/nft_view_model.dart
index dda460b9..0e54b268 100644
--- a/lib/view_model/dashboard/nft_view_model.dart
+++ b/lib/view_model/dashboard/nft_view_model.dart
@@ -1,13 +1,18 @@
+import "dart:async";
import 'dart:convert';
-import 'dart:developer';
+import "package:cake_wallet/entities/saved_nfts.dart";
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
+import "package:cake_wallet/solana/solana.dart";
import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
import 'package:cake_wallet/src/screens/wallet_connect/widgets/bottom_sheet/bottom_sheet_message_display_widget.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/utils/debounce.dart';
+import "package:cw_core/utils/ipfs_url.dart";
+import "package:cw_core/utils/print_verbose.dart";
+import "package:cw_core/wallet_base.dart";
import 'package:cw_core/wallet_type.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:mobx/mobx.dart';
@@ -41,94 +46,96 @@ abstract class NFTViewModelBase with Store {
final BottomSheetService bottomSheetService;
final Debounce _fetchDebounce = Debounce(const Duration(milliseconds: 500));
+ Future<void> _lastFetch = Future.value();
+ String? _loadedWalletAddress;
+ Completer<void>? _queuedFetch;
+
+ final SavedNFTs _savedNFTs = SavedNFTs();
+ int _currentRefreshId = 0;
+
+ static const _requestTimeout = Duration(seconds: 20);
+ static const _runDeadline = Duration(minutes: 2);
+
+ Map<String, String> get _moralisHeaders => {
+ "Accept": "application/json",
+ "X-API-Key": secrets.moralisApiKey,
+ };
+
@observable
bool isLoading;
@observable
bool isImportNFTLoading;
+ @observable
+ int unresolvedNFTCount = 0;
+
ObservableList<NFTAssetModel> nftAssetByWalletModels;
ObservableList<SolanaNFTAssetModel> solanaNftAssetModels;
- @action
- Future<void> getNFTAssetByWallet() async {
- final wallet = appStore.wallet!;
+ bool get isSolanaWallet => appStore.wallet?.type == WalletType.solana;
- if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) return;
+ String? get currentWalletName => appStore.wallet?.name;
- final walletAddress = wallet.walletInfo.address;
- log('Fetching wallet NFTs for $walletAddress');
+ @action
+ void onWalletChanged() {
+ nftAssetByWalletModels.clear();
+ solanaNftAssetModels.clear();
+ unresolvedNFTCount = 0;
+ _nftErrorPopupShown = false;
+ _fetchDebounce.run(getNFTAssetByWallet);
+ }
- final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: wallet.chainId);
- // the [chain] refers to the chain network that the nft is on
- // the [format] refers to the number format type of the responses
- // the [normalizedMetadata] field is a boolean that determines if
- // the response would include a json string of the NFT Metadata that can be decoded
- // and used within the wallet
- // the [excludeSpam] field is a boolean that determines if spam nfts be excluded from the response.
+ @action
+ Future<void> getNFTAssetByWallet() {
+ final queued = _queuedFetch;
- Uri uri;
- if (wallet.type == WalletType.solana) {
- uri = Uri.https(
- 'solana-gateway.moralis.io',
- '/account/$chainName/$walletAddress/nft',
- );
- } else {
- uri = Uri.https(
- 'deep-index.moralis.io',
- '/api/v2.2/$walletAddress/nft',
- {
- "chain": chainName,
- "format": "decimal",
- "media_items": "false",
- "exclude_spam": "true",
- "normalizeMetadata": "true",
- },
- );
+ if (queued != null) {
+ return queued.future;
}
- try {
- if (isLoading) return;
+ final done = Completer<void>();
+ _queuedFetch = done;
- isLoading = true;
+ final waitFor = _lastFetch;
+ _lastFetch = done.future;
- final response = await ProxyWrapper().get(
- clearnetUri: uri,
- headers: {
- "Accept": "application/json",
- "X-API-Key": secrets.moralisApiKey,
- },
- );
+ unawaited(_fetchAfterPreviousRun(waitFor, done));
- final decodedResponse = jsonDecode(response.body);
+ return done.future;
+ }
- _nftErrorPopupShown = false;
+ Future<void> _fetchAfterPreviousRun(Future<void> waitFor, Completer<void> done) async {
+ await waitFor;
- if (wallet.type == WalletType.solana) {
- final results = await Future.wait(
- (decodedResponse as List<dynamic>).map(
- (x) {
- final data = x as Map<String, dynamic>;
- final mint = data['mint'] as String? ?? '';
- return getSolanaNFTDetails(mint, chainName);
- },
- ).toList(),
- );
+ if (identical(_queuedFetch, done)) {
+ _queuedFetch = null;
+ }
+
+ try {
+ final wallet = appStore.wallet!;
- solanaNftAssetModels.clear();
+ if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) {
+ return;
+ }
- solanaNftAssetModels.addAll(results);
- } else {
- final result =
- WalletNFTsResponseModel.fromJson(decodedResponse as Map<String, dynamic>).result ?? [];
+ final walletAddress = wallet.walletInfo.address;
+ final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: wallet.chainId);
+ isLoading = true;
- nftAssetByWalletModels.clear();
+ final refreshId = ++_currentRefreshId;
- nftAssetByWalletModels.addAll(result);
+ if (isSolanaWallet) {
+ await _getSolanaNFTAssets(wallet, walletAddress, chainName, refreshId)
+ .timeout(_runDeadline);
+ } else {
+ await _getEvmNFTAssets(wallet.name, walletAddress, chainName, refreshId);
}
+
+ _nftErrorPopupShown = false;
} catch (e) {
- log(e.toString());
+ printV("Fetching wallet NFTs failed: ${e.toString()}");
if (!_nftErrorPopupShown) {
_nftErrorPopupShown = true;
bottomSheetService.queueBottomSheet(
@@ -140,79 +147,342 @@ abstract class NFTViewModelBase with Store {
}
} finally {
isLoading = false;
+ done.complete();
+ }
+ }
+
+ Future<void> _getEvmNFTAssets(
+ String walletName,
+ String walletAddress,
+ String chainName,
+ int refreshId,
+ ) async {
+ final saved = await _savedNFTs.evmNFTs(walletName, chainName);
+
+ var result = const <NFTAssetModel>[];
+ Object? indexerError;
+
+ try {
+ final response = await ProxyWrapper()
+ .get(
+ clearnetUri: Uri.https(
+ "deep-index.moralis.io",
+ "/api/v2.2/$walletAddress/nft",
+ {
+ "chain": chainName,
+ "format": "decimal",
+ "media_items": "false",
+ "exclude_spam": "true",
+ "normalizeMetadata": "true",
+ },
+ ),
+ headers: _moralisHeaders,
+ )
+ .timeout(_requestTimeout);
+
+ result = WalletNFTsResponseModel.fromJson(
+ jsonDecode(response.body) as Map<String, dynamic>,
+ ).result ??
+ [];
+ } catch (e) {
+ indexerError = e;
+ }
+
+ if (appStore.wallet?.walletInfo.address != walletAddress || refreshId != _currentRefreshId) {
+ return;
+ }
+
+ final listed = result.map(SavedNFTs.evmIdentifier).toSet();
+
+ nftAssetByWalletModels.clear();
+ nftAssetByWalletModels.addAll(result);
+ nftAssetByWalletModels
+ .addAll(saved.where((asset) => !listed.contains(SavedNFTs.evmIdentifier(asset))));
+
+ if (indexerError != null) {
+ throw indexerError;
}
}
- Future<SolanaNFTAssetModel> getSolanaNFTDetails(String address, String chainName) async {
- final uri = Uri.https(
- 'solana-gateway.moralis.io',
- '/nft/$chainName/$address/metadata',
+ Future<void> _getSolanaNFTAssets(
+ WalletBase wallet,
+ String walletAddress,
+ String chainName,
+ int refreshId,
+ ) async {
+ final importedAssets = await _savedNFTs.solanaNFTs(wallet.name);
+
+ var indexerMints = const <String>[];
+ Object? indexerError;
+
+ try {
+ indexerMints = await _fetchMintsFromIndexer(walletAddress, chainName);
+ } catch (e) {
+ indexerError = e;
+ }
+
+ final heldMints = await _fetchMintsHeldOnChain(wallet);
+ final failedMints = <String>{};
+
+ final assets = await _loadAssetsForMints(
+ wallet: wallet,
+ chainName: chainName,
+ mints: <String>{...indexerMints, ...importedAssets.keys},
+ heldMints: heldMints,
+ importedAssets: importedAssets,
+ failedMints: failedMints,
);
- final response = await ProxyWrapper().get(
- clearnetUri: uri,
- headers: {
- "Accept": "application/json",
- "X-API-Key": secrets.moralisApiKey,
- },
+ if (appStore.wallet?.walletInfo.address != walletAddress || refreshId != _currentRefreshId) {
+ return;
+ }
+
+ assets.addAll(
+ _assetsToKeepFromPreviousRefresh(walletAddress, assets, failedMints, indexerError != null));
+
+ solanaNftAssetModels.clear();
+ solanaNftAssetModels.addAll(assets);
+ _loadedWalletAddress = walletAddress;
+
+ final shownMints = assets.map((asset) => asset.mint).toSet();
+ unresolvedNFTCount = failedMints.where((mint) => !shownMints.contains(mint)).length;
+
+ if (heldMints != null) {
+ await _savedNFTs.refreshSolana(wallet.name, assets);
+ }
+
+ if (indexerError != null) {
+ throw indexerError;
+ }
+ }
+
+ Future<List<String>> _fetchMintsFromIndexer(String walletAddress, String chainName) async {
+ final response = await ProxyWrapper()
+ .get(
+ clearnetUri: Uri.https(
+ "solana-gateway.moralis.io",
+ "/account/$chainName/$walletAddress/nft",
+ ),
+ headers: _moralisHeaders,
+ )
+ .timeout(_requestTimeout);
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ throw Exception("Failed to fetch wallet NFTs (${response.statusCode})");
+ }
+
+ return (jsonDecode(response.body) as List<dynamic>)
+ .map((entry) => (entry as Map<String, dynamic>)["mint"] as String? ?? "")
+ .where((mint) => mint.isNotEmpty)
+ .toList();
+ }
+
+ Future<Set<String>?> _fetchMintsHeldOnChain(WalletBase wallet) async {
+ try {
+ return await solana!.getHeldTokenMints(wallet);
+ } catch (e) {
+ printV("Could not fetch held token mints: ${e.toString()}");
+ return null;
+ }
+ }
+
+ Future<List<SolanaNFTAssetModel>> _loadAssetsForMints({
+ required WalletBase wallet,
+ required String chainName,
+ required Set<String> mints,
+ required Set<String>? heldMints,
+ required Map<String, SolanaNFTAssetModel> importedAssets,
+ required Set<String> failedMints,
+ }) async {
+ const batchSize = 5;
+ final ordered = mints.toList();
+ final assets = <SolanaNFTAssetModel>[];
+
+ for (var i = 0; i < ordered.length; i += batchSize) {
+ final described = await Future.wait(
+ ordered.skip(i).take(batchSize).map((mint) async {
+ try {
+ final asset = await getSolanaNFTDetails(wallet, mint, chainName);
+ asset.isOwned = heldMints?.contains(mint) ?? importedAssets[mint]?.isOwned;
+ return asset;
+ } catch (e) {
+ printV("Failed to load NFT $mint: ${e.toString()}");
+ failedMints.add(mint);
+
+ final cached = importedAssets[mint];
+
+ if (cached != null && heldMints != null) {
+ cached.isOwned = heldMints.contains(mint);
+ }
+
+ return cached;
+ }
+ }),
+ );
+
+ assets.addAll(described.whereType<SolanaNFTAssetModel>());
+ }
+
+ return assets;
+ }
+
+ List<SolanaNFTAssetModel> _assetsToKeepFromPreviousRefresh(
+ String walletAddress,
+ List<SolanaNFTAssetModel> described,
+ Set<String> failedMints,
+ bool indexerFailed,
+ ) {
+ if (_loadedWalletAddress != walletAddress) {
+ return const [];
+ }
+
+ final describedMints = described.map((asset) => asset.mint).toSet();
+
+ return solanaNftAssetModels.where((asset) {
+ final mint = asset.mint;
+
+ if (mint == null || describedMints.contains(mint)) {
+ return false;
+ }
+
+ return indexerFailed || failedMints.contains(mint);
+ }).toList();
+ }
+
+ Future<SolanaNFTAssetModel> getSolanaNFTDetails(
+ WalletBase wallet,
+ String address,
+ String chainName,
+ ) async {
+ SolanaNFTAssetModel? asset;
+
+ try {
+ asset = await _fetchMoralisNFT(address, chainName);
+ asset.address = address;
+ asset.mint = address;
+ } catch (e) {
+ printV("Moralis NFT metadata failed for $address: ${e.toString()}");
+ }
+
+ if (asset != null && (asset.imageOriginalUrl?.isNotEmpty ?? false)) {
+ return asset;
+ }
+
+ // fallback for when moralis is unavailable, we still get the core details for that nft onchain
+ final onChainData = await solana!.getNFTOnChainMetadata(wallet, address);
+
+ if (asset != null) {
+ if (onChainData != null) {
+ asset.imageOriginalUrl = tryNormalizeIpfsUrl(onChainData.imageUrl);
+ }
+
+ return asset;
+ }
+
+ if (onChainData == null) {
+ throw Exception("Could not load NFT metadata for $address");
+ }
+
+ return SolanaNFTAssetModel(
+ address: address,
+ mint: address,
+ standard: "metaplex",
+ name: onChainData.name,
+ symbol: onChainData.symbol,
+ imageOriginalUrl: onChainData.imageUrl,
+ metadataOriginalUrl: onChainData.metadataUri,
);
+ }
+
+ Future<SolanaNFTAssetModel> _fetchMoralisNFT(String address, String chainName) async {
+ final response = await ProxyWrapper()
+ .get(
+ clearnetUri: Uri.https(
+ "solana-gateway.moralis.io",
+ "/nft/$chainName/$address/metadata",
+ ),
+ headers: _moralisHeaders,
+ )
+ .timeout(_requestTimeout);
- final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ throw Exception("Failed to fetch NFT metadata (${response.statusCode})");
+ }
- return SolanaNFTAssetModel.fromJson(decodedResponse);
+ return SolanaNFTAssetModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
+ }
+
+ @action
+ Future<void> onNFTSent(String walletName, String mint) async {
+ await _savedNFTs.removeSolana(walletName, mint);
+
+ if (appStore.wallet?.name == walletName) {
+ solanaNftAssetModels.removeWhere((asset) => asset.mint == mint);
+ }
+ }
+
+ Future<void> _importEvmNFT(
+ String walletName,
+ String tokenAddress,
+ String? tokenId,
+ String chainName,
+ ) async {
+ final response = await ProxyWrapper()
+ .get(
+ clearnetUri: Uri.https(
+ "deep-index.moralis.io",
+ "/api/v2.2/nft/$tokenAddress/$tokenId",
+ {
+ "chain": chainName,
+ "format": "decimal",
+ "media_items": "false",
+ "normalizeMetadata": "true",
+ },
+ ),
+ headers: _moralisHeaders,
+ )
+ .timeout(_requestTimeout);
+
+ final asset = NFTAssetModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
+
+ nftAssetByWalletModels.add(asset);
+
+ await _savedNFTs.addEvm(walletName, chainName, asset);
}
@action
Future<void> importNFT(String tokenAddress, String? tokenId) async {
- final walletType = appStore.wallet!.type;
+ final wallet = appStore.wallet!;
int? chainId;
- if (isEVMCompatibleChain(walletType)) {
- chainId = evm!.getSelectedChainId(appStore.wallet!);
+ if (isEVMCompatibleChain(wallet.type)) {
+ chainId = evm!.getSelectedChainId(wallet);
}
- final chainName = getChainNameBasedOnWalletType(walletType, chainId: chainId);
- // the [chain] refers to the chain network that the nft is on
- // the [format] refers to the number format type of the responses
- // the [normalizedMetadata] field is a boolean that determines if
- // the response would include a json string of the NFT Metadata that can be decoded
- // and used within the wallet
-
+ final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: chainId);
try {
isImportNFTLoading = true;
- if (appStore.wallet!.type == WalletType.solana) {
- final result = await getSolanaNFTDetails(tokenAddress, chainName);
+ if (isSolanaWallet) {
+ final result = await getSolanaNFTDetails(wallet, tokenAddress, chainName);
- solanaNftAssetModels.add(result);
- } else {
- final uri = Uri.https(
- 'deep-index.moralis.io',
- '/api/v2.2/nft/$tokenAddress/$tokenId',
- {
- "chain": chainName,
- "format": "decimal",
- "media_items": "false",
- "normalizeMetadata": "true",
- },
- );
- final response = await ProxyWrapper().get(
- clearnetUri: uri,
- headers: {
- "Accept": "application/json",
- "X-API-Key": secrets.moralisApiKey,
- },
- );
+ try {
+ result.isOwned = (await solana!.getHeldTokenMints(wallet)).contains(result.mint);
+ } catch (e) {
+ printV("Could not check NFT ownership: ${e.toString()}");
+ }
- final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
-
- final nftAsset = NFTAssetModel.fromJson(decodedResponse);
+ solanaNftAssetModels.removeWhere((asset) => asset.mint == result.mint);
+ solanaNftAssetModels.add(result);
- nftAssetByWalletModels.add(nftAsset);
+ await _savedNFTs.addSolana(wallet.name, result);
+ } else {
+ await _importEvmNFT(wallet.name, tokenAddress, tokenId, chainName);
}
} catch (e) {
+ printV("Importing NFT $tokenAddress failed: ${e.toString()}");
bottomSheetService.queueBottomSheet(
isModalDismissible: true,
widget: BottomSheetMessageDisplayWidget(
- message: e.toString(),
+ message: S.current.nft_import_failed,
),
);
} finally {
diff --git a/lib/view_model/dev/monero_background_sync.dart b/lib/view_model/dev/monero_background_sync.dart
index 3eb4292b..c109b7bb 100644
--- a/lib/view_model/dev/monero_background_sync.dart
+++ b/lib/view_model/dev/monero_background_sync.dart
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:cake_wallet/core/key_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/monero/monero.dart';
-import 'package:cw_monero/monero_wallet.dart';
import 'package:mobx/mobx.dart';
import 'package:cw_core/wallet_base.dart';
@@ -56,26 +55,24 @@ abstract class DevMoneroBackgroundSyncBase with Store {
bool isBackgroundSyncing = false;
Future<void> _setValues() async {
- final w = (wallet as MoneroWallet);
localBlockHeight = (await monero!.getCurrentHeight()).toString();
- nodeBlockHeight = (await w.getNodeHeight()).toString();
- final keys = w.keys;
- primaryAddress = keys.primaryAddress;
- publicViewKey = keys.publicViewKey;
- privateViewKey = keys.privateViewKey;
- publicSpendKey = keys.publicSpendKey;
- privateSpendKey = keys.privateSpendKey;
- passphrase = keys.passphrase;
- seed = w.seed;
- seedLegacy = w.seedLegacy("English");
+ nodeBlockHeight = (await monero!.getNodeHeight(wallet)).toString();
+ final keys = monero!.getKeys(wallet);
+ primaryAddress = keys["primaryAddress"];
+ publicViewKey = keys["publicViewKey"];
+ privateViewKey = keys["privateViewKey"];
+ publicSpendKey = keys["publicSpendKey"];
+ privateSpendKey = keys["privateSpendKey"];
+ passphrase = keys["passphrase"];
+ seed = wallet.seed;
+ seedLegacy = monero!.getLegacySeed(wallet, "English");
tick = refreshTimer?.tick ?? -1;
- isBackgroundSyncing = w.isBackgroundSyncRunning;
+ isBackgroundSyncing = monero!.isBackgroundSyncRunning(wallet);
}
@action
Future<void> manualRescan() async {
- final w = (wallet as MoneroWallet);
- await wallet.rescan(height: await w.getNodeHeight() - 10000);
+ await wallet.rescan(height: await monero!.getNodeHeight(wallet) - 10000);
}
@action
@@ -93,14 +90,13 @@ abstract class DevMoneroBackgroundSyncBase with Store {
@action
void startBackgroundSync() {
- final w = (wallet as MoneroWallet);
- w.startBackgroundSync();
+ unawaited(monero!.startBackgroundSync(wallet));
}
@action
Future<void> stopBackgroundSync() async {
- final w = (wallet as MoneroWallet);
final keyService = getIt.get<KeyService>();
- await w.stopBackgroundSync(await keyService.getWalletPassword(walletName: wallet.name));
+ final password = await keyService.getWalletPassword(walletName: wallet.name);
+ await monero!.stopBackgroundSync(wallet, password);
}
}
diff --git a/lib/view_model/wallet_keys_view_model.dart b/lib/view_model/wallet_keys_view_model.dart
index 73e6a631..ce10a269 100644
--- a/lib/view_model/wallet_keys_view_model.dart
+++ b/lib/view_model/wallet_keys_view_model.dart
@@ -11,13 +11,10 @@ import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
-import 'package:cw_monero/monero_wallet.dart';
import 'package:flutter/foundation.dart';
import 'package:mobx/mobx.dart';
import 'package:cake_wallet/decred/decred.dart';
import 'package:polyseed/polyseed.dart';
-import 'package:cake_wallet/evm/evm.dart';
-import 'package:cake_wallet/reactions/wallet_connect.dart';
part 'wallet_keys_view_model.g.dart';
@@ -118,7 +115,7 @@ abstract class WalletKeysViewModelBase with Store {
final langName = PolyseedLang.getByPhrase(_wallet.seed!).nameEnglish;
if (_wallet.type == WalletType.monero) {
- return (_wallet as MoneroWalletBase).seedLegacy(langName);
+ return monero!.getLegacySeed(_wallet, langName);
} else if (_wallet.type == WalletType.wownero) {
return wownero!.getLegacySeed(_wallet, langName);
}
diff --git a/pubspec_base.yaml b/pubspec_base.yaml
index 210d3a2a..63d97de7 100644
--- a/pubspec_base.yaml
+++ b/pubspec_base.yaml
@@ -128,6 +128,11 @@ dependencies:
git:
url: https://github.com/cake-tech/trezor_connect
ref: d1242cea90f84b00200e7bcab914a5af750e23fb
+ trezor_flutter:
+ git:
+ url: https://github.com/cake-tech/trezor-flutter.git
+ ref: 7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97
+ path: trezor-flutter
bitbox_flutter:
path: ./scripts/bitbox_flutter
hashlib: 1.19.2
diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb
index 3b13b21d..81497576 100644
--- a/res/values/strings_ar.arb
+++ b/res/values/strings_ar.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "إرسال إشعارات حول المعاملات الجديدة",
"new_wallet": "محفظة جديدة",
"newConnection": "اتصال جديد",
+ "nft_import_failed": "تعذر استيراد NFT هذا. تحقق من العنوان وحاول مرة أخرى.",
+ "nft_not_held_by_wallet": "لا تحتفظ بها هذه المحفظة",
+ "nft_send_not_an_nft": "هذا الأصل ليس NFT، لذا لا يمكن إرساله من هنا.",
+ "nft_some_not_loaded": "تعذر تحميل بعض NFTs",
"nfts": "NFTs",
"no": "لا",
"no_cards_found": "لم يتم العثور على أي بطاقات",
@@ -1405,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "المحافظ",
"warning": "تحذير",
+ "watching": "عرض فقط",
"wc_action_approve": "موافقة",
"wc_approve_request_title": "الموافقة على الطلب",
"wc_connect_request_title": "طلب الاتصال",
diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb
index c9486893..9c5ba5e1 100644
--- a/res/values/strings_bg.arb
+++ b/res/values/strings_bg.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Изпращане на известия за нови транзакции",
"new_wallet": "Нов портфейл",
"newConnection": "Нова връзка",
+ "nft_import_failed": "Не може да се импортира този NFT. Проверете адреса и опитайте отново.",
+ "nft_not_held_by_wallet": "Не се държи от този портфейл",
+ "nft_send_not_an_nft": "Този актив не е NFT, така че не може да бъде изпратен оттук.",
+ "nft_some_not_loaded": "Някои NFT не можаха да бъдат заредени",
"nfts": "NFT",
"no": "Не",
"no_cards_found": "Няма намерени карти",
@@ -1405,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Портфейли",
"warning": "Предупреждение",
+ "watching": "Само преглед",
"wc_action_approve": "Одобряване",
"wc_approve_request_title": "Одобряване на заявката",
"wc_connect_request_title": "Заявка за свързване",
diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb
index 2e68008b..eba77174 100644
--- a/res/values/strings_cs.arb
+++ b/res/values/strings_cs.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Odesílat oznámení o nových transakcích",
"new_wallet": "Nová peněženka",
"newConnection": "Nové připojení",
+ "nft_import_failed": "Tento NFT nelze importovat. Zkontrolujte adresu a zkuste to znovu.",
+ "nft_not_held_by_wallet": "Tato peněženka nedrží",
+ "nft_send_not_an_nft": "Toto aktivum není NFT, takže jej odtud nelze odeslat.",
+ "nft_some_not_loaded": "Některé NFT se nepodařilo načíst",
"nfts": "NFT",
"no": "Ne",
"no_cards_found": "Nebyly nalezeny žádné karty",
@@ -1405,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Peněženky",
"warning": "Varování",
+ "watching": "Pouze prohlížet",
"wc_action_approve": "Schvalovat",
"wc_approve_request_title": "Schválit žádost",
"wc_connect_request_title": "Žádost o připojení",
diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb
index 8044948e..a50caead 100644
--- a/res/values/strings_de.arb
+++ b/res/values/strings_de.arb
@@ -700,6 +700,10 @@
"new_transactions_notifications": "Benachrichtigungen über neue Transaktionen senden",
"new_wallet": "Neue Wallet",
"newConnection": "Neue Verbindung",
+ "nft_import_failed": "Dieses NFT konnte nicht importiert werden. Überprüfen Sie die Adresse und versuchen Sie es erneut.",
+ "nft_not_held_by_wallet": "Wird nicht von dieser Wallet gehalten",
+ "nft_send_not_an_nft": "Dieser Vermögenswert ist kein NFT und kann daher nicht von hier aus gesendet werden.",
+ "nft_some_not_loaded": "Einige NFTs konnten nicht geladen werden",
"nfts": "NFTs",
"no": "Nein",
"no_cards_found": "Keine Karten gefunden",
@@ -1444,6 +1448,7 @@
"walletConnect": "WalletConnect",
"wallets": "Wallets",
"warning": "Warnung",
+ "watching": "Nur ansehen",
"wc_action_approve": "Genehmigen",
"wc_approve_request_title": "Anfrage genehmigen",
"wc_connect_request_title": "Verbindungsanfrage",
diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb
index b208f683..6e6d3c48 100644
--- a/res/values/strings_en.arb
+++ b/res/values/strings_en.arb
@@ -700,6 +700,10 @@
"new_transactions_notifications": "Send notifications about new transactions",
"new_wallet": "New Wallet",
"newConnection": "New Connection",
+ "nft_import_failed": "Could not import this NFT. Check the address and try again.",
+ "nft_not_held_by_wallet": "Not held by this wallet",
+ "nft_send_not_an_nft": "This asset is not an NFT, so it cannot be sent from here.",
+ "nft_some_not_loaded": "Some NFTs could not be loaded",
"nfts": "NFTs",
"no": "No",
"no_cards_found": "No cards found",
@@ -1446,6 +1450,7 @@
"walletConnect": "WalletConnect",
"wallets": "Wallets",
"warning": "Warning",
+ "watching": "View only",
"wc_action_approve": "Approve",
"wc_approve_request_title": "Approve request",
"wc_connect_request_title": "Connect request",
diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb
index ac702c0f..444fd55f 100644
--- a/res/values/strings_es.arb
+++ b/res/values/strings_es.arb
@@ -686,6 +686,10 @@
"new_transactions_notifications": "Enviar notificaciones sobre nuevas transacciones",
"new_wallet": "Nueva cartera",
"newConnection": "Nueva conexión",
+ "nft_import_failed": "No se pudo importar este NFT. Verifique la dirección e inténtelo nuevamente.",
+ "nft_not_held_by_wallet": "No retenido por esta billetera",
+ "nft_send_not_an_nft": "Este activo no es un NFT, por lo que no se puede enviar desde aquí.",
+ "nft_some_not_loaded": "Algunos NFT no se pudieron cargar",
"nfts": "NFTs",
"no": "No",
"no_cards_found": "No se encontraron tarjetas",
@@ -1407,6 +1411,7 @@
"walletConnect": "WalletConnect",
"wallets": "Carteras",
"warning": "Advertencia",
+ "watching": "Ver sólo",
"wc_action_approve": "Aprobar",
"wc_approve_request_title": "Aprobar solicitud",
"wc_connect_request_title": "Solicitud de conexión",
diff --git a/res/values/strings_fa.arb b/res/values/strings_fa.arb
index 17f55785..5cf611d2 100644
--- a/res/values/strings_fa.arb
+++ b/res/values/strings_fa.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "ارسال اعلان برای تراکنشهای جدید",
"new_wallet": "کیف پول جدید",
"newConnection": "اتصال جدید",
+ "nft_import_failed": "این NFT وارد نشد. آدرس را بررسی کنید و دوباره امتحان کنید.",
+ "nft_not_held_by_wallet": "توسط این کیف پول نگهداری نمی شود",
+ "nft_send_not_an_nft": "این دارایی یک NFT نیست، بنابراین نمی توان آن را از اینجا ارسال کرد.",
+ "nft_some_not_loaded": "برخی از NFT ها بارگیری نشدند",
"nfts": "NFTها",
"no": "خیر",
"no_cards_found": "هیچ کارتی پیدا نشد",
@@ -1405,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "کیف پولها",
"warning": "هشدار",
+ "watching": "فقط مشاهده کنید",
"wc_action_approve": "تایید کنید",
"wc_approve_request_title": "درخواست را تایید کنید",
"wc_connect_request_title": "درخواست اتصال",
diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb
index a8c8df56..f213e975 100644
--- a/res/values/strings_fr.arb
+++ b/res/values/strings_fr.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Envoyer des notifications pour les nouvelles transactions",
"new_wallet": "Nouveau portefeuille",
"newConnection": "Nouvelle connexion",
+ "nft_import_failed": "Impossible d'importer ce NFT. Vérifiez l'adresse et réessayez.",
+ "nft_not_held_by_wallet": "Non détenu par ce portefeuille",
+ "nft_send_not_an_nft": "Cet actif n'est pas un NFT, il ne peut donc pas être envoyé à partir d'ici.",
+ "nft_some_not_loaded": "Certains NFT n'ont pas pu être chargés",
"nfts": "NFTs",
"no": "Non",
"no_cards_found": "Aucune carte trouvée",
@@ -1405,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Portefeuilles",
"warning": "Avertissement",
+ "watching": "Visualisation uniquement",
"wc_action_approve": "Approuver",
"wc_approve_request_title": "Approuver la demande",
"wc_connect_request_title": "Demande de connexion",
diff --git a/res/values/strings_gn.arb b/res/values/strings_gn.arb
index c86512e7..41ae92d9 100644
--- a/res/values/strings_gn.arb
+++ b/res/values/strings_gn.arb
@@ -688,6 +688,10 @@
"new_transactions_notifications": "Emondo marandu'i umi transacción pyahúre",
"new_wallet": "Billetera pyahu",
"newConnection": "Jeike pyahu",
+ "nft_import_failed": "Ndaikatúi ojegueru ko NFT. Ehecháke pe direksión ha eñehaʼã jey.",
+ "nft_not_held_by_wallet": "Ndojeguerekói ko billetera rupive",
+ "nft_send_not_an_nft": "Ko activo ndaha'éi NFT, upévare ndikatúi oñemondo ko'águi.",
+ "nft_some_not_loaded": "Oĩ NFT ndaikatúiva ojekarga",
"nfts": "NFTs",
"no": "Nahániri",
"no_cards_found": "Ndojejuhúi kuatia'atã",
@@ -1412,6 +1416,7 @@
"walletConnect": "WalletConnect",
"wallets": "Billetera kuéra",
"warning": "Kyhyje",
+ "watching": "Ojehecha añoite",
"wc_action_approve": "Hasapyre",
"wc_approve_request_title": "Omoneĩ mba’ejerure",
"wc_connect_request_title": "Ombojoaju mba’ejerure",
diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb
index 29cda58b..9656357c 100644
--- a/res/values/strings_ha.arb
+++ b/res/values/strings_ha.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Aika sanarwa game da sabbin ma'amaloli",
"new_wallet": "Sabuwar Walat",
"newConnection": "Sabuwar Haɗin",
+ "nft_import_failed": "Ba za a iya shigo da wannan NFT ba. Duba adireshin kuma a sake gwadawa.",
+ "nft_not_held_by_wallet": "Ba a riƙe da wannan walat",
+ "nft_send_not_an_nft": "Wannan kadari ba NFT ba ne, don haka ba za a iya aika shi daga nan ba.",
+ "nft_some_not_loaded": "Wasu NFT ba za a iya loda su ba",
"nfts": "NFTs",
"no": "A'a",
"no_cards_found": "Ba a sami katuna ba",
@@ -1407,6 +1411,7 @@
"walletConnect": "WalletConnect",
"wallets": "Walati",
"warning": "Gargaɗi",
+ "watching": "Duba kawai",
"wc_action_approve": "Amincewa",
"wc_approve_request_title": "Amincewa da buƙata",
"wc_connect_request_title": "Buƙatar haɗin kai",
diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb
index 13d18ae1..4e5e8737 100644
--- a/res/values/strings_hi.arb
+++ b/res/values/strings_hi.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "नए लेन-देन के बारे में सूचनाएं भेजें",
"new_wallet": "नया वॉलेट",
"newConnection": "नया कनेक्शन",
+ "nft_import_failed": "इस एनएफटी को आयात नहीं किया जा सका. पता जांचें और पुनः प्रयास करें.",
+ "nft_not_held_by_wallet": "इस बटुए द्वारा नहीं रखा गया",
+ "nft_send_not_an_nft": "यह संपत्ति एनएफटी नहीं है, इसलिए इसे यहां से नहीं भेजा जा सकता है।",
+ "nft_some_not_loaded": "कुछ एनएफटी लोड नहीं किये जा सके",
"nfts": "एनएफटी",
"no": "नहीं",
"no_cards_found": "कोई कार्ड नहीं मिले",
@@ -1407,6 +1411,7 @@
"walletConnect": "WalletConnect",
"wallets": "वॉलेट्स",
"warning": "चेतावनी",
+ "watching": "केवल देखें",
"wc_action_approve": "मंज़ूरी देना",
"wc_approve_request_title": "अनुरोध स्वीकृत करें",
"wc_connect_request_title": "कनेक्ट अनुरोध",
diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb
index 0e4f55c4..5bce2b96 100644
--- a/res/values/strings_hr.arb
+++ b/res/values/strings_hr.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Šalji obavijesti o novim transakcijama",
"new_wallet": "Novi novčanik",
"newConnection": "Nova veza",
+ "nft_import_failed": "Nije moguće uvesti ovaj NFT. Provjerite adresu i pokušajte ponovo.",
+ "nft_not_held_by_wallet": "Ne drži ga ovaj novčanik",
+ "nft_send_not_an_nft": "Ovo sredstvo nije NFT, pa se ne može poslati odavde.",
+ "nft_some_not_loaded": "Neki NFT-ovi se ne mogu učitati",
"nfts": "NFT-ovi",
"no": "Ne",
"no_cards_found": "Nisu pronađene kartice",
@@ -1405,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Novčanici",
"warning": "Upozorenje",
+ "watching": "Samo pogled",
"wc_action_approve": "Odobriti",
"wc_approve_request_title": "Odobri zahtjev",
"wc_connect_request_title": "Zahtjev za povezivanje",
diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb
index e45a24dc..d9bef69a 100644
--- a/res/values/strings_hy.arb
+++ b/res/values/strings_hy.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Ուղարկել ծանուցումներ նոր գործարքների մասին",
"new_wallet": "Նոր դրամապանակ",
"newConnection": "Նոր միացում",
+ "nft_import_failed": "Չհաջողվեց ներմուծել այս NFT-ը: Ստուգեք հասցեն և նորից փորձեք:",
+ "nft_not_held_by_wallet": "Այս դրամապանակը չի պահվում",
+ "nft_send_not_an_nft": "Այս ակտիվը NFT չէ, ուստի այն չի կարող ուղարկվել այստեղից:",
+ "nft_some_not_loaded": "Որոշ NFT-ներ չհաջողվեց բեռնել",
"nfts": "NFT-ներ",
"no": "Ոչ",
"no_cards_found": "Քարտեր չեն գտնվել",
@@ -1405,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Դրամապանակներ",
"warning": "Զգուշացում",
+ "watching": "Միայն դիտել",
"wc_action_approve": "Հաստատել",
"wc_approve_request_title": "Հաստատել հարցումը",
"wc_connect_request_title": "Միացման հարցում",
diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb
index 478dc7d6..6186f8e8 100644
--- a/res/values/strings_id.arb
+++ b/res/values/strings_id.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Kirim notifikasi tentang transaksi baru",
"new_wallet": "Dompet Baru",
"newConnection": "Koneksi Baru",
+ "nft_import_failed": "Tidak dapat mengimpor NFT ini. Periksa alamatnya dan coba lagi.",
+ "nft_not_held_by_wallet": "Tidak dipegang oleh dompet ini",
+ "nft_send_not_an_nft": "Aset ini bukan NFT, jadi tidak bisa dikirim dari sini.",
+ "nft_some_not_loaded": "Beberapa NFT tidak dapat dimuat",
"nfts": "NFT",
"no": "Tidak",
"no_cards_found": "Tidak ada kartu ditemukan",
@@ -1408,6 +1412,7 @@
"walletConnect": "WalletConnect",
"wallets": "Dompet",
"warning": "Peringatan",
+ "watching": "Lihat saja",
"wc_action_approve": "Setujui",
"wc_approve_request_title": "Setujui permintaan",
"wc_connect_request_title": "Hubungkan permintaan",
diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb
index 1fc6ff17..b9b5a5cb 100644
--- a/res/values/strings_it.arb
+++ b/res/values/strings_it.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Invia notifiche per le nuove transazioni",
"new_wallet": "Nuovo wallet",
"newConnection": "Nuova connessione",
+ "nft_import_failed": "Impossibile importare questo NFT. Controlla l'indirizzo e riprova.",
+ "nft_not_held_by_wallet": "Non trattenuto da questo portafoglio",
+ "nft_send_not_an_nft": "Questa risorsa non è un NFT, quindi non può essere inviata da qui.",
+ "nft_some_not_loaded": "Impossibile caricare alcuni NFT",
"nfts": "NFT",
"no": "No",
"no_cards_found": "Nessuna carta trovata",
@@ -847,6 +851,7 @@
"received": "Ricevuto",
"receiving": "Ricezione",
"recieving": "Ricezione",
+ "recipient_account_creation_fee": "È necessario un extra di ${amount} per creare un account token per il destinatario, perché non ne ha ancora uno.",
"recipient_address": "Indirizzo del destinatario",
"reconnect": "Riconnetti",
"reconnect_alert_text": "Sei sicuro di voler riconnetterti?",
@@ -1406,6 +1411,7 @@
"walletConnect": "WalletConnect",
"wallets": "Portafogli",
"warning": "Attenzione",
+ "watching": "Solo visualizzazione",
"wc_action_approve": "Approva",
"wc_approve_request_title": "Approva la richiesta",
"wc_connect_request_title": "Richiesta di connessione",
@@ -1478,6 +1484,5 @@
"zcash_card_enable_later": "Puoi sempre abilitare questa carta in seguito nelle impostazioni",
"zcash_card_missing_funds": "Fondi mancanti?",
"zcash_card_scan": "Scansiona",
- "zcash_card_warning": "Non chiudere l'app finché la procedura non è completata, altrimenti sarà necessario riavviare il processo da zero.",
- "recipient_account_creation_fee": "È necessario un extra di ${amount} per creare un account token per il destinatario, perché non ne ha ancora uno."
+ "zcash_card_warning": "Non chiudere l'app finché la procedura non è completata, altrimenti sarà necessario riavviare il processo da zero."
}
\ No newline at end of file
diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb
index b6836bf6..3b7a4fd6 100644
--- a/res/values/strings_ja.arb
+++ b/res/values/strings_ja.arb
@@ -686,6 +686,10 @@
"new_transactions_notifications": "新しいトランザクションの通知を送信する",
"new_wallet": "新しいウォレット",
"newConnection": "新しい接続",
+ "nft_import_failed": "このNFTをインポートできませんでした。アドレスを確認して再試行してください。",
+ "nft_not_held_by_wallet": "このウォレットには保持されていません",
+ "nft_send_not_an_nft": "このアセットはNFTではないため、ここから送信することはできません。",
+ "nft_some_not_loaded": "一部のNFTを読み込めませんでした",
"nfts": "NFTs",
"no": "いいえ",
"no_cards_found": "カードが見つかりません",
@@ -847,6 +851,7 @@
"received": "受信",
"receiving": "受信",
"recieving": "受信中",
+ "recipient_account_creation_fee": "受信者はまだトークン アカウントを持っていないため、追加の ${amount} が必要です。",
"recipient_address": "受取人アドレス",
"reconnect": "再接続",
"reconnect_alert_text": "再接続してもよろしいですか?",
@@ -1405,6 +1410,7 @@
"walletConnect": "WalletConnect",
"wallets": "ウォレット",
"warning": "警告",
+ "watching": "閲覧のみ",
"wc_action_approve": "承認",
"wc_approve_request_title": "リクエストの承認",
"wc_connect_request_title": "接続リクエスト",
@@ -1476,6 +1482,5 @@
"zcash_card_enable_later": "このカードは後から設定でいつでも有効にできます",
"zcash_card_missing_funds": "資金が見当たりませんか?",
"zcash_card_scan": "スキャン",
- "zcash_card_warning": "手順が完了するまでアプリを閉じないでください。閉じると、このプロセスを最初からやり直す必要があります。",
- "recipient_account_creation_fee": "受信者はまだトークン アカウントを持っていないため、追加の ${amount} が必要です。"
+ "zcash_card_warning": "手順が完了するまでアプリを閉じないでください。閉じると、このプロセスを最初からやり直す必要があります。"
}
\ No newline at end of file
diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb
index 852be390..8533a9a1 100644
--- a/res/values/strings_ko.arb
+++ b/res/values/strings_ko.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "새 거래에 대한 알림 보내기",
"new_wallet": "새 지갑",
"newConnection": "새 연결",
+ "nft_import_failed": "이 NFT를 가져올 수 없습니다. 주소를 확인하고 다시 시도하세요.",
+ "nft_not_held_by_wallet": "이 지갑에 보관되지 않음",
+ "nft_send_not_an_nft": "이 자산은 NFT가 아니므로 여기에서 보낼 수 없습니다.",
+ "nft_some_not_loaded": "일부 NFT를 로드할 수 없습니다",
"nfts": "NFTs",
"no": "아니요",
"no_cards_found": "카드를 찾을 수 없습니다",
@@ -846,6 +850,7 @@
"received": "수신됨",
"receiving": "수신",
"recieving": "수신 중",
+ "recipient_account_creation_fee": "아직 토큰 계정이 없기 때문에 수신자를 위한 토큰 계정을 생성하려면 추가 ${amount}가 필요합니다.",
"recipient_address": "수신자 주소",
"reconnect": "재연결",
"reconnect_alert_text": "정말 다시 연결하시겠습니까?",
@@ -1404,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "지갑",
"warning": "경고",
+ "watching": "보기 전용",
"wc_action_approve": "승인",
"wc_approve_request_title": "요청 승인",
"wc_connect_request_title": "연결 요청",
@@ -1475,6 +1481,5 @@
"zcash_card_enable_later": "설정에서 언제든지 나중에 이 카드를 활성화할 수 있습니다.",
"zcash_card_missing_funds": "자금이 사라졌나요?",
"zcash_card_scan": "스캔",
- "zcash_card_warning": "절차가 완료될 때까지 앱을 닫지 마십시오. 닫으면 이 과정을 처음부터 다시 시작해야 합니다.",
- "recipient_account_creation_fee": "아직 토큰 계정이 없기 때문에 수신자를 위한 토큰 계정을 생성하려면 추가 ${amount}가 필요합니다."
+ "zcash_card_warning": "절차가 완료될 때까지 앱을 닫지 마십시오. 닫으면 이 과정을 처음부터 다시 시작해야 합니다."
}
\ No newline at end of file
diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb
index 879e8382..4cdccba4 100644
--- a/res/values/strings_my.arb
+++ b/res/values/strings_my.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "အရောင်းအဝယ်အသစ်များအကြောင်း အသိပေးချက်များ ပို့ပါ",
"new_wallet": "အိတ်အသစ်",
"newConnection": "ချိတ်ဆက်မှုအသစ်",
+ "nft_import_failed": "ဤ NFT ကို မတင်သွင်းနိုင်ပါ။ လိပ်စာကို စစ်ဆေးပြီး ထပ်စမ်းကြည့်ပါ။",
+ "nft_not_held_by_wallet": "ဒီပိုက်ဆံအိတ်က မကိုင်ဘူး။",
+ "nft_send_not_an_nft": "ဤပိုင်ဆိုင်မှုသည် NFT မဟုတ်ပါ။ ထို့ကြောင့် ၎င်းကို ဤနေရာမှ ပေးပို့၍မရပါ။",
+ "nft_some_not_loaded": "NFT အချို့ကို တင်၍မရပါ။",
"nfts": "NFTs",
"no": "မဟုတ်ပါ",
"no_cards_found": "ကဒ် မတွေ့ပါ",
@@ -846,6 +850,7 @@
"received": "လက်ခံရရှိ",
"receiving": "လက်ခံနေသည်",
"recieving": "လက်ခံနေသည်",
+ "recipient_account_creation_fee": "လက်ခံသူအတွက် တိုကင်အကောင့်တစ်ခုဖန်တီးရန် ၎င်းတို့တွင် တစ်ခုမရှိသေးသောကြောင့် အပို ${amount} လိုအပ်ပါသည်။",
"recipient_address": "လက်ခံသူ၏ လိပ်စာ",
"reconnect": "ပြန်လည်ချိတ်ဆက်ရန်",
"reconnect_alert_text": "ပြန်လည်ချိတ်ဆက်လိုသည်မှာ သေချာပါသလား?",
@@ -1404,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "ဝေါလက်များ",
"warning": "သတိပေးချက်",
+ "watching": "ကြည့်ရန်သာ",
"wc_action_approve": "အတည်ပြု",
"wc_approve_request_title": "တောင်းဆိုချက်ကို အတည်ပြုပါ။",
"wc_connect_request_title": "တောင်းဆိုချက်ကို ချိတ်ဆက်ပါ။",
@@ -1475,6 +1481,5 @@
"zcash_card_enable_later": "သင်သည် ဤကတ်ကို ဆက်တင်များတွင် နောက်မှ အချိန်မရွေး ဖွင့်နိုင်ပါသည်။",
"zcash_card_missing_funds": "ငွေကြေး ပျောက်ဆုံးနေပါသလား?",
"zcash_card_scan": "စကင်",
- "zcash_card_warning": "လုပ်ငန်းစဉ် ပြီးဆုံးသည်အထိ အက်ပ်ကို မပိတ်ပါနှင့်။ ပိတ်လိုက်ပါက ဤလုပ်ငန်းစဉ်ကို အစမှ ပြန်လည်စတင်ရမည်ဖြစ်သည်။",
- "recipient_account_creation_fee": "လက်ခံသူအတွက် တိုကင်အကောင့်တစ်ခုဖန်တီးရန် ၎င်းတို့တွင် တစ်ခုမရှိသေးသောကြောင့် အပို ${amount} လိုအပ်ပါသည်။"
+ "zcash_card_warning": "လုပ်ငန်းစဉ် ပြီးဆုံးသည်အထိ အက်ပ်ကို မပိတ်ပါနှင့်။ ပိတ်လိုက်ပါက ဤလုပ်ငန်းစဉ်ကို အစမှ ပြန်လည်စတင်ရမည်ဖြစ်သည်။"
}
\ No newline at end of file
diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb
index 9f7bc6d2..2e804bae 100644
--- a/res/values/strings_nl.arb
+++ b/res/values/strings_nl.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Stuur meldingen over nieuwe transacties",
"new_wallet": "Nieuwe wallet",
"newConnection": "Nieuwe verbinding",
+ "nft_import_failed": "Kan deze NFT niet importeren. Controleer het adres en probeer het opnieuw.",
+ "nft_not_held_by_wallet": "Niet in handen van deze portemonnee",
+ "nft_send_not_an_nft": "Dit item is geen NFT en kan dus niet vanaf hier worden verzonden.",
+ "nft_some_not_loaded": "Sommige NFT's konden niet worden geladen",
"nfts": "NFT's",
"no": "Nee",
"no_cards_found": "Geen kaarten gevonden",
@@ -846,6 +850,7 @@
"received": "Ontvangen",
"receiving": "Bezig met ontvangen",
"recieving": "Bezig met ontvangen",
+ "recipient_account_creation_fee": "Er is een extra ${amount} nodig om een tokenaccount voor de ontvanger aan te maken, omdat deze er nog geen heeft.",
"recipient_address": "Adres ontvanger",
"reconnect": "Opnieuw verbinden",
"reconnect_alert_text": "Weet je zeker dat je opnieuw verbinding wilt maken?",
@@ -1405,6 +1410,7 @@
"walletConnect": "WalletConnect",
"wallets": "Wallets",
"warning": "Waarschuwing",
+ "watching": "Alleen bekijken",
"wc_action_approve": "Goedkeuren",
"wc_approve_request_title": "Aanvraag goedkeuren",
"wc_connect_request_title": "Verbind verzoek",
@@ -1476,6 +1482,5 @@
"zcash_card_enable_later": "Je kunt deze kaart later altijd inschakelen via de instellingen",
"zcash_card_missing_funds": "Fondsen ontbreken?",
"zcash_card_scan": "Scannen",
- "zcash_card_warning": "Sluit de app niet voordat de procedure is voltooid. Als je dit wel doet, moet dit proces helemaal opnieuw worden gestart.",
- "recipient_account_creation_fee": "Er is een extra ${amount} nodig om een tokenaccount voor de ontvanger aan te maken, omdat deze er nog geen heeft."
+ "zcash_card_warning": "Sluit de app niet voordat de procedure is voltooid. Als je dit wel doet, moet dit proces helemaal opnieuw worden gestart."
}
\ No newline at end of file
diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb
index 013896b1..082138e3 100644
--- a/res/values/strings_pl.arb
+++ b/res/values/strings_pl.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Wysyłaj powiadomienia o nowych transakcjach",
"new_wallet": "Nowy portfel",
"newConnection": "Nowe połączenie",
+ "nft_import_failed": "Nie można zaimportować tego NFT. Sprawdź adres i spróbuj ponownie.",
+ "nft_not_held_by_wallet": "Nie trzyma się tego portfela",
+ "nft_send_not_an_nft": "Ten zasób nie jest NFT, więc nie można go stąd wysłać.",
+ "nft_some_not_loaded": "Nie można załadować niektórych plików NFT",
"nfts": "NFT-y",
"no": "Nie",
"no_cards_found": "Nie znaleziono żadnych kart",
@@ -846,6 +850,7 @@
"received": "Otrzymane",
"receiving": "Odbieranie",
"recieving": "Odbieranie",
+ "recipient_account_creation_fee": "Do założenia konta tokenowego dla odbiorcy potrzebna jest dodatkowa ${amount}, ponieważ jeszcze takiego nie posiada.",
"recipient_address": "Adres odbiorcy",
"reconnect": "Połącz ponownie",
"reconnect_alert_text": "Czy na pewno chcesz połączyć się ponownie?",
@@ -1404,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Portfele",
"warning": "Ostrzeżenie",
+ "watching": "Tylko przeglądaj",
"wc_action_approve": "Zatwierdź",
"wc_approve_request_title": "Zatwierdź żądanie",
"wc_connect_request_title": "Połącz żądanie",
@@ -1475,6 +1481,5 @@
"zcash_card_enable_later": "Zawsze możesz włączyć tę kartę później w ustawieniach",
"zcash_card_missing_funds": "Brakuje środków?",
"zcash_card_scan": "Skanuj",
- "zcash_card_warning": "Nie zamykaj aplikacji do czasu zakończenia procedury. Jeśli to zrobisz, proces będzie musiał rozpocząć się od nowa.",
- "recipient_account_creation_fee": "Do założenia konta tokenowego dla odbiorcy potrzebna jest dodatkowa ${amount}, ponieważ jeszcze takiego nie posiada."
+ "zcash_card_warning": "Nie zamykaj aplikacji do czasu zakończenia procedury. Jeśli to zrobisz, proces będzie musiał rozpocząć się od nowa."
}
\ No newline at end of file
diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb
index c3ab4bae..b3e100be 100644
--- a/res/values/strings_pt.arb
+++ b/res/values/strings_pt.arb
@@ -686,6 +686,10 @@
"new_transactions_notifications": "Enviar notificações sobre novas transações",
"new_wallet": "Nova carteira",
"newConnection": "Nova conexão",
+ "nft_import_failed": "Não foi possível importar este NFT. Verifique o endereço e tente novamente.",
+ "nft_not_held_by_wallet": "Não mantido por esta carteira",
+ "nft_send_not_an_nft": "Este ativo não é um NFT, portanto não pode ser enviado daqui.",
+ "nft_some_not_loaded": "Alguns NFTs não puderam ser carregados",
"nfts": "NFTs",
"no": "Não",
"no_cards_found": "Nenhum cartão encontrado",
@@ -848,6 +852,7 @@
"received": "Recebido",
"receiving": "Recebendo",
"recieving": "A receber",
+ "recipient_account_creation_fee": "É necessário um ${amount} extra para criar uma conta token para o destinatário, porque ele ainda não tem uma.",
"recipient_address": "Endereço do destinatário",
"reconnect": "Reconectar",
"reconnect_alert_text": "Tem certeza de que deseja reconectar?",
@@ -1407,6 +1412,7 @@
"walletConnect": "WalletConnect",
"wallets": "Carteiras",
"warning": "Aviso",
+ "watching": "Somente visualizar",
"wc_action_approve": "Aprovar",
"wc_approve_request_title": "Aprovar solicitação",
"wc_connect_request_title": "Solicitação de conexão",
@@ -1478,6 +1484,5 @@
"zcash_card_enable_later": "Você sempre pode ativar este cartão mais tarde nas configurações",
"zcash_card_missing_funds": "Fundos em falta?",
"zcash_card_scan": "Escanear",
- "zcash_card_warning": "Não feche o aplicativo até que o procedimento seja concluído; se você fizer isso, esse processo precisará ser reiniciado do zero.",
- "recipient_account_creation_fee": "É necessário um ${amount} extra para criar uma conta token para o destinatário, porque ele ainda não tem uma."
+ "zcash_card_warning": "Não feche o aplicativo até que o procedimento seja concluído; se você fizer isso, esse processo precisará ser reiniciado do zero."
}
\ No newline at end of file
diff --git a/res/values/strings_pt_BR.arb b/res/values/strings_pt_BR.arb
index c552992e..b91f6b0c 100644
--- a/res/values/strings_pt_BR.arb
+++ b/res/values/strings_pt_BR.arb
@@ -652,6 +652,10 @@
"network_address_detected": "Endereço ${network} detectado",
"network_prefix_on": "na",
"network_switch_failed": "Falha ao mudar para a rede solicitada. O pagamento não foi aplicado, tente novamente.",
+ "nft_import_failed": "Não foi possível importar este NFT. Verifique o endereço e tente novamente.",
+ "nft_not_held_by_wallet": "Não mantido por esta carteira",
+ "nft_send_not_an_nft": "Este ativo não é um NFT, portanto não pode ser enviado daqui.",
+ "nft_some_not_loaded": "Alguns NFTs não puderam ser carregados",
"unsupported_token_requested": "Este pedido de pagamento é para um token que não conseguimos identificar na rede selecionada.",
"new_first_wallet_text": "Manter sua cripto segura é moleza",
"new_node_testing": "Teste de novo nó",
@@ -1347,6 +1351,7 @@
"walletConnect": "WalletConnect",
"wallets": "Carteiras",
"warning": "Aviso",
+ "watching": "Somente visualizar",
"wc_approve_request_title": "Aprovar solicitação",
"wc_connect_request_title": "Solicitação de conexão",
"wc_connected_to": "Conectado a ${name}",
diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb
index ca12a089..696eb803 100644
--- a/res/values/strings_ru.arb
+++ b/res/values/strings_ru.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Отправлять уведомления о новых транзакциях",
"new_wallet": "Новый кошелек",
"newConnection": "Новое подключение",
+ "nft_import_failed": "Не удалось импортировать этот NFT. Проверьте адрес и повторите попытку.",
+ "nft_not_held_by_wallet": "Не хранится в этом кошельке",
+ "nft_send_not_an_nft": "Этот актив не является NFT, поэтому его нельзя отправить отсюда.",
+ "nft_some_not_loaded": "Некоторые NFT не удалось загрузить.",
"nfts": "NFT",
"no": "Нет",
"no_cards_found": "Карты не найдены",
@@ -847,6 +851,7 @@
"received": "Получено",
"receiving": "Получение",
"recieving": "Получение",
+ "recipient_account_creation_fee": "Для создания учетной записи токена для получателя требуется дополнительная сумма ${amount}, поскольку у него ее еще нет.",
"recipient_address": "Адрес получателя",
"reconnect": "Переподключиться",
"reconnect_alert_text": "Вы уверены, что хотите переподключиться?",
@@ -1405,6 +1410,7 @@
"walletConnect": "WalletConnect",
"wallets": "Кошельки",
"warning": "Предупреждение",
+ "watching": "Только просмотр",
"wc_action_approve": "Одобрить",
"wc_approve_request_title": "Утвердить запрос",
"wc_connect_request_title": "Запрос на подключение",
@@ -1476,6 +1482,5 @@
"zcash_card_enable_later": "Вы всегда можете включить эту карту позже в настройках",
"zcash_card_missing_funds": "Не хватает средств?",
"zcash_card_scan": "Сканировать",
- "zcash_card_warning": "Не закрывайте приложение до завершения процедуры. Если вы это сделаете, процесс придется перезапустить с нуля.",
- "recipient_account_creation_fee": "Для создания учетной записи токена для получателя требуется дополнительная сумма ${amount}, поскольку у него ее еще нет."
+ "zcash_card_warning": "Не закрывайте приложение до завершения процедуры. Если вы это сделаете, процесс придется перезапустить с нуля."
}
\ No newline at end of file
diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb
index 818a8f6c..b979ea9d 100644
--- a/res/values/strings_th.arb
+++ b/res/values/strings_th.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "ส่งการแจ้งเตือนเกี่ยวกับธุรกรรมใหม่",
"new_wallet": "กระเป๋าเงินใหม่",
"newConnection": "การเชื่อมต่อใหม่",
+ "nft_import_failed": "ไม่สามารถนำเข้า NFT นี้ ตรวจสอบที่อยู่แล้วลองอีกครั้ง",
+ "nft_not_held_by_wallet": "ไม่ได้ถือโดยกระเป๋าเงินนี้",
+ "nft_send_not_an_nft": "เนื้อหานี้ไม่ใช่ NFT ดังนั้นจึงไม่สามารถส่งจากที่นี่ได้",
+ "nft_some_not_loaded": "NFT บางตัวไม่สามารถโหลดได้",
"nfts": "NFTs",
"no": "ไม่",
"no_cards_found": "ไม่พบการ์ด",
@@ -846,6 +850,7 @@
"received": "ได้รับ",
"receiving": "กำลังรับ",
"recieving": "กำลังรับ",
+ "recipient_account_creation_fee": "จำเป็นต้องมี ${amount} เพิ่มเติมเพื่อสร้างบัญชีโทเค็นสำหรับผู้รับ เนื่องจากยังไม่มีบัญชี",
"recipient_address": "ที่อยู่ผู้รับ",
"reconnect": "เชื่อมต่อใหม่",
"reconnect_alert_text": "คุณแน่ใจหรือไม่ว่าต้องการเชื่อมต่อใหม่?",
@@ -1404,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "กระเป๋าเงิน",
"warning": "คำเตือน",
+ "watching": "ดูอย่างเดียว",
"wc_action_approve": "อนุมัติ",
"wc_approve_request_title": "อนุมัติคำขอ",
"wc_connect_request_title": "เชื่อมต่อคำขอ",
@@ -1475,6 +1481,5 @@
"zcash_card_enable_later": "คุณสามารถเปิดใช้งานการ์ดนี้ได้เสมอในภายหลังในการตั้งค่า",
"zcash_card_missing_funds": "ยอดเงินหายไป?",
"zcash_card_scan": "สแกน",
- "zcash_card_warning": "อย่าปิดแอปจนกว่าขั้นตอนจะเสร็จสิ้น หากคุณปิดแอป กระบวนการนี้จะต้องเริ่มใหม่ตั้งแต่ต้น",
- "recipient_account_creation_fee": "จำเป็นต้องมี ${amount} เพิ่มเติมเพื่อสร้างบัญชีโทเค็นสำหรับผู้รับ เนื่องจากยังไม่มีบัญชี"
+ "zcash_card_warning": "อย่าปิดแอปจนกว่าขั้นตอนจะเสร็จสิ้น หากคุณปิดแอป กระบวนการนี้จะต้องเริ่มใหม่ตั้งแต่ต้น"
}
\ No newline at end of file
diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb
index 374cb161..7582cbe8 100644
--- a/res/values/strings_tl.arb
+++ b/res/values/strings_tl.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Magpadala ng mga abiso tungkol sa mga bagong transaksyon",
"new_wallet": "Bagong Wallet",
"newConnection": "Bagong Koneksyon",
+ "nft_import_failed": "Hindi ma-import ang NFT na ito. Tingnan ang address at subukang muli.",
+ "nft_not_held_by_wallet": "Hindi hawak ng wallet na ito",
+ "nft_send_not_an_nft": "Ang asset na ito ay hindi isang NFT, kaya hindi ito maipadala mula rito.",
+ "nft_some_not_loaded": "Hindi ma-load ang ilang NFT",
"nfts": "Mga NFT",
"no": "Hindi",
"no_cards_found": "Walang nahanap na card",
@@ -846,6 +850,7 @@
"received": "Natanggap",
"receiving": "Pagtanggap",
"recieving": "Tumatanggap",
+ "recipient_account_creation_fee": "Kailangan ng dagdag na ${amount} para gumawa ng token account para sa tatanggap, dahil wala pa sila nito.",
"recipient_address": "Address ng tatanggap",
"reconnect": "Muling kumonekta",
"reconnect_alert_text": "Sigurado ka bang gusto mong kumonekta muli?",
@@ -1404,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Mga Wallet",
"warning": "Babala",
+ "watching": "Tingnan lamang",
"wc_action_approve": "Aprubahan",
"wc_approve_request_title": "Aprubahan ang kahilingan",
"wc_connect_request_title": "Kahilingan sa pagkonekta",
@@ -1475,6 +1481,5 @@
"zcash_card_enable_later": "Maaari mo itong paganahin anumang oras sa ibang pagkakataon sa mga setting",
"zcash_card_missing_funds": "Nawawalang pondo?",
"zcash_card_scan": "I-scan",
- "zcash_card_warning": "Huwag isara ang app hanggang sa makumpleto ang proseso; kung gagawin mo ito, kakailanganing magsimula muli mula sa simula.",
- "recipient_account_creation_fee": "Kailangan ng dagdag na ${amount} para gumawa ng token account para sa tatanggap, dahil wala pa sila nito."
+ "zcash_card_warning": "Huwag isara ang app hanggang sa makumpleto ang proseso; kung gagawin mo ito, kakailanganing magsimula muli mula sa simula."
}
\ No newline at end of file
diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb
index 82b27c80..b4ecc86d 100644
--- a/res/values/strings_tr.arb
+++ b/res/values/strings_tr.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Yeni işlemler için bildirim gönder",
"new_wallet": "Yeni Cüzdan",
"newConnection": "Yeni Bağlantı",
+ "nft_import_failed": "Bu NFT içe aktarılamadı. Adresi kontrol edip tekrar deneyin.",
+ "nft_not_held_by_wallet": "Bu cüzdanda tutulmuyor",
+ "nft_send_not_an_nft": "Bu varlık bir NFT olmadığından buradan gönderilemez.",
+ "nft_some_not_loaded": "Bazı NFT'ler yüklenemedi",
"nfts": "NFT'ler",
"no": "Hayır",
"no_cards_found": "Kart bulunamadı",
@@ -846,6 +850,7 @@
"received": "Alındı",
"receiving": "Alma",
"recieving": "Alınıyor",
+ "recipient_account_creation_fee": "Alıcının henüz bir token hesabı olmadığı için, alıcı için bir token hesabı oluşturmak için fazladan ${amount} gerekiyor.",
"recipient_address": "Alıcı adresi",
"reconnect": "Yeniden bağlan",
"reconnect_alert_text": "Yeniden bağlanmak istediğinizden emin misiniz?",
@@ -1404,6 +1409,7 @@
"walletConnect": "WalletConnect",
"wallets": "Cüzdanlar",
"warning": "Uyarı",
+ "watching": "Yalnızca görüntüle",
"wc_action_approve": "Onayla",
"wc_approve_request_title": "İsteği onayla",
"wc_connect_request_title": "Bağlantı isteği",
@@ -1475,6 +1481,5 @@
"zcash_card_enable_later": "Bu kartı daha sonra ayarlardan her zaman etkinleştirebilirsiniz",
"zcash_card_missing_funds": "Bakiye eksik mi?",
"zcash_card_scan": "Tara",
- "zcash_card_warning": "İşlem tamamlanana kadar uygulamayı kapatmayın; aksi takdirde bu işlemin baştan yeniden başlatılması gerekecektir.",
- "recipient_account_creation_fee": "Alıcının henüz bir token hesabı olmadığı için, alıcı için bir token hesabı oluşturmak için fazladan ${amount} gerekiyor."
+ "zcash_card_warning": "İşlem tamamlanana kadar uygulamayı kapatmayın; aksi takdirde bu işlemin baştan yeniden başlatılması gerekecektir."
}
\ No newline at end of file
diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb
index 6558649f..3c756345 100644
--- a/res/values/strings_uk.arb
+++ b/res/values/strings_uk.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "Надсилати сповіщення про нові транзакції",
"new_wallet": "Новий гаманець",
"newConnection": "Нове з’єднання",
+ "nft_import_failed": "Не вдалося імпортувати цей NFT. Перевірте адресу та повторіть спробу.",
+ "nft_not_held_by_wallet": "Не утримується цим гаманцем",
+ "nft_send_not_an_nft": "Цей актив не є NFT, тому його не можна надіслати звідси.",
+ "nft_some_not_loaded": "Деякі NFT не вдалося завантажити",
"nfts": "NFT",
"no": "Ні",
"no_cards_found": "Картки не знайдено",
@@ -846,6 +850,7 @@
"received": "Отримано",
"receiving": "Отримання",
"recieving": "Отримання",
+ "recipient_account_creation_fee": "Потрібна додаткова сума в розмірі ${amount}, щоб створити обліковий запис-токен для одержувача, оскільки він його ще не має.",
"recipient_address": "Адреса отримувача",
"reconnect": "Перепідключити",
"reconnect_alert_text": "Ви впевнені, що хочете перепідключитися?",
@@ -1405,6 +1410,7 @@
"walletConnect": "WalletConnect",
"wallets": "Гаманці",
"warning": "Попередження",
+ "watching": "Тільки перегляд",
"wc_action_approve": "Схвалити",
"wc_approve_request_title": "Підтвердити запит",
"wc_connect_request_title": "Запит на підключення",
@@ -1476,6 +1482,5 @@
"zcash_card_enable_later": "Ви завжди можете ввімкнути цю картку пізніше в налаштуваннях",
"zcash_card_missing_funds": "Зникли кошти?",
"zcash_card_scan": "Сканувати",
- "zcash_card_warning": "Не закривайте застосунок, доки процедура не завершиться. Якщо ви це зробите, цей процес потрібно буде перезапустити з нуля.",
- "recipient_account_creation_fee": "Потрібна додаткова сума в розмірі ${amount}, щоб створити обліковий запис-токен для одержувача, оскільки він його ще не має."
+ "zcash_card_warning": "Не закривайте застосунок, доки процедура не завершиться. Якщо ви це зробите, цей процес потрібно буде перезапустити з нуля."
}
\ No newline at end of file
diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb
index f241478a..ec60f8ce 100644
--- a/res/values/strings_ur.arb
+++ b/res/values/strings_ur.arb
@@ -685,6 +685,10 @@
"new_transactions_notifications": "نئے لین دین کے بارے میں اطلاعات بھیجیں",
"new_wallet": "نیا والیٹ",
"newConnection": "نیا کنکشن",
+ "nft_import_failed": "اس NFT کو درآمد نہیں کیا جا سکا۔ پتہ چیک کریں اور دوبارہ کوشش کریں۔",
+ "nft_not_held_by_wallet": "اس بٹوے کے پاس نہیں ہے۔",
+ "nft_send_not_an_nft": "یہ اثاثہ NFT نہیں ہے، اس لیے اسے یہاں سے نہیں بھیجا جا سکتا۔",
+ "nft_some_not_loaded": "کچھ NFTs کو لوڈ نہیں کیا جا سکا",
"nfts": "NFTs",
"no": "نہیں",
"no_cards_found": "کوئی کارڈ نہیں ملا",
@@ -848,6 +852,7 @@
"received": "موصول ہوا",
"receiving": "وصولی",
"recieving": "وصولی",
+ "recipient_account_creation_fee": "وصول کنندہ کے لیے ٹوکن اکاؤنٹ بنانے کے لیے ایک اضافی ${amount} کی ضرورت ہے، کیونکہ ان کے پاس ابھی تک کوئی اکاؤنٹ نہیں ہے۔",
"recipient_address": "وصول کنندہ کا پتہ",
"reconnect": "دوبارہ جڑیں",
"reconnect_alert_text": "کیا آپ واقعی دوبارہ کنیکٹ کرنا چاہتے ہیں؟",
@@ -1406,6 +1411,7 @@
"walletConnect": "WalletConnect",
"wallets": "والٹس",
"warning": "انتباہ",
+ "watching": "صرف دیکھیں",
"wc_action_approve": "منظور کریں",
"wc_approve_request_title": "درخواست منظور کریں۔",
"wc_connect_request_title": "رابطہ کی درخواست",
@@ -1477,6 +1483,5 @@
"zcash_card_enable_later": "آپ اس کارڈ کو بعد میں بھی ترتیبات میں فعال کر سکتے ہیں",
"zcash_card_missing_funds": "فنڈز غائب ہیں؟",
"zcash_card_scan": "اسکین کریں",
- "zcash_card_warning": "طریقہ کار مکمل ہونے تک ایپ بند نہ کریں۔ اگر آپ ایسا کریں گے تو یہ عمل شروع سے دوبارہ شروع کرنا پڑے گا۔",
- "recipient_account_creation_fee": "وصول کنندہ کے لیے ٹوکن اکاؤنٹ بنانے کے لیے ایک اضافی ${amount} کی ضرورت ہے، کیونکہ ان کے پاس ابھی تک کوئی اکاؤنٹ نہیں ہے۔"
+ "zcash_card_warning": "طریقہ کار مکمل ہونے تک ایپ بند نہ کریں۔ اگر آپ ایسا کریں گے تو یہ عمل شروع سے دوبارہ شروع کرنا پڑے گا۔"
}
\ No newline at end of file
diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb
index 594657af..d6506bbd 100644
--- a/res/values/strings_vi.arb
+++ b/res/values/strings_vi.arb
@@ -686,6 +686,10 @@
"new_transactions_notifications": "Gửi thông báo về các giao dịch mới",
"new_wallet": "Ví mới",
"newConnection": "Kết nối mới",
+ "nft_import_failed": "Không thể nhập NFT này. Hãy kiểm tra địa chỉ và thử lại.",
+ "nft_not_held_by_wallet": "Không được giữ bởi ví này",
+ "nft_send_not_an_nft": "Nội dung này không phải là NFT nên không thể gửi nó từ đây.",
+ "nft_some_not_loaded": "Không thể tải một số NFT",
"nfts": "NFT",
"no": "Không",
"no_cards_found": "Không tìm thấy thẻ nào",
@@ -847,6 +851,7 @@
"received": "Đã nhận",
"receiving": "Đang nhận",
"recieving": "Đang nhận",
+ "recipient_account_creation_fee": "Cần thêm ${amount} để tạo tài khoản mã thông báo cho người nhận vì họ chưa có tài khoản.",
"recipient_address": "Địa chỉ người nhận",
"reconnect": "Kết nối lại",
"reconnect_alert_text": "Bạn có chắc chắn muốn kết nối lại không?",
@@ -1405,6 +1410,7 @@
"walletConnect": "WalletConnect",
"wallets": "Ví",
"warning": "Cảnh báo",
+ "watching": "Chỉ xem",
"wc_action_approve": "Phê duyệt",
"wc_approve_request_title": "Phê duyệt yêu cầu",
"wc_connect_request_title": "Yêu cầu kết nối",
@@ -1476,6 +1482,5 @@
"zcash_card_enable_later": "Bạn luôn có thể bật thẻ này sau trong phần cài đặt",
"zcash_card_missing_funds": "Thiếu tiền?",
"zcash_card_scan": "Quét",
- "zcash_card_warning": "Không đóng ứng dụng cho đến khi quy trình hoàn tất; nếu bạn làm vậy, quy trình này sẽ phải bắt đầu lại từ đầu.",
- "recipient_account_creation_fee": "Cần thêm ${amount} để tạo tài khoản mã thông báo cho người nhận vì họ chưa có tài khoản."
+ "zcash_card_warning": "Không đóng ứng dụng cho đến khi quy trình hoàn tất; nếu bạn làm vậy, quy trình này sẽ phải bắt đầu lại từ đầu."
}
\ No newline at end of file
diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb
index def4de26..f0758d0e 100644
--- a/res/values/strings_yo.arb
+++ b/res/values/strings_yo.arb
@@ -686,6 +686,10 @@
"new_transactions_notifications": "Firanṣẹ àwọn ìwífúnni nípa àwọn ìdúnàdúrà tuntun",
"new_wallet": "Apamọwọ tuntun",
"newConnection": "Asopọ Tuntun",
+ "nft_import_failed": "Ko le gbe NFT yi wọle. Ṣayẹwo adirẹsi naa ki o tun gbiyanju lẹẹkansi.",
+ "nft_not_held_by_wallet": "Ko waye nipasẹ yi apamọwọ",
+ "nft_send_not_an_nft": "Ohun-ini yii kii ṣe NFT, nitorinaa ko le firanṣẹ lati ibi.",
+ "nft_some_not_loaded": "Diẹ ninu awọn NFT ko le ṣe kojọpọ",
"nfts": "NFTs",
"no": "Rárá",
"no_cards_found": "A kò rí kaadi kankan",
@@ -847,6 +851,7 @@
"received": "Ti gbà",
"receiving": "Gbigba",
"recieving": "Gbigba",
+ "recipient_account_creation_fee": "A nilo afikun ${amount} lati ṣẹda akọọlẹ ami-ami kan fun olugba, nitori wọn ko ni ọkan sibẹsibẹ.",
"recipient_address": "Àdírẹ́sì olùgbà",
"reconnect": "Tun so pọ̀",
"reconnect_alert_text": "Ṣé ó dá ẹ lójú pé ẹ fẹ́ tún sopọ̀?",
@@ -1405,6 +1410,7 @@
"walletConnect": "WalletConnect",
"wallets": "Àwọn wálẹ́ẹ̀tì",
"warning": "Ìkìlọ̀",
+ "watching": "Wo nikan",
"wc_action_approve": "Fọwọ́sí",
"wc_approve_request_title": "fọwọsi ìbéèrè",
"wc_connect_request_title": "Sopọ ìbéèrè",
@@ -1476,6 +1482,5 @@
"zcash_card_enable_later": "O le ma a mu kaadi yii ṣiṣẹ nigbamii ninu awọn eto",
"zcash_card_missing_funds": "Ṣe owó rẹ sọnù?",
"zcash_card_scan": "Ṣàwárí",
- "zcash_card_warning": "Maṣe pa ohun elo naa titi ilana naa yoo fi pari; bí o bá ṣe bẹ́ẹ̀, ilana yìí yóò ní láti tún bẹ̀rẹ̀ láti ìbẹ̀rẹ̀.",
- "recipient_account_creation_fee": "A nilo afikun ${amount} lati ṣẹda akọọlẹ ami-ami kan fun olugba, nitori wọn ko ni ọkan sibẹsibẹ."
+ "zcash_card_warning": "Maṣe pa ohun elo naa titi ilana naa yoo fi pari; bí o bá ṣe bẹ́ẹ̀, ilana yìí yóò ní láti tún bẹ̀rẹ̀ láti ìbẹ̀rẹ̀."
}
\ No newline at end of file
diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb
index 91e88581..9d4a11e7 100644
--- a/res/values/strings_zh.arb
+++ b/res/values/strings_zh.arb
@@ -686,6 +686,10 @@
"new_transactions_notifications": "发送新交易通知",
"new_wallet": "新钱包",
"newConnection": "新连接",
+ "nft_import_failed": "无法导入此 NFT。检查地址并重试。",
+ "nft_not_held_by_wallet": "不属于该钱包",
+ "nft_send_not_an_nft": "该资产不是 NFT,因此无法从这里发送。",
+ "nft_some_not_loaded": "部分NFT无法加载",
"nfts": "NFTs",
"no": "否",
"no_cards_found": "未找到卡片",
@@ -847,6 +851,7 @@
"received": "已接收",
"receiving": "接收",
"recieving": "接收中",
+ "recipient_account_creation_fee": "需要额外的 ${amount} 来为收件人创建一个令牌帐户,因为他们还没有令牌帐户。",
"recipient_address": "收款地址",
"reconnect": "重新连接",
"reconnect_alert_text": "您确定要重新连接吗?",
@@ -1405,6 +1410,7 @@
"walletConnect": "WalletConnect",
"wallets": "钱包",
"warning": "警告",
+ "watching": "仅供查看",
"wc_action_approve": "批准",
"wc_approve_request_title": "批准请求",
"wc_connect_request_title": "连接请求",
@@ -1476,6 +1482,5 @@
"zcash_card_enable_later": "您随时可以在设置中启用此卡",
"zcash_card_missing_funds": "资金不见了?",
"zcash_card_scan": "扫描",
- "zcash_card_warning": "在该过程完成之前请勿关闭应用程序,否则该过程将需要从头重新开始。",
- "recipient_account_creation_fee": "需要额外的 ${amount} 来为收件人创建一个令牌帐户,因为他们还没有令牌帐户。"
+ "zcash_card_warning": "在该过程完成之前请勿关闭应用程序,否则该过程将需要从头重新开始。"
}
\ No newline at end of file
diff --git a/test/entities/saved_nfts_test.dart b/test/entities/saved_nfts_test.dart
new file mode 100644
index 00000000..2802e820
--- /dev/null
+++ b/test/entities/saved_nfts_test.dart
@@ -0,0 +1,225 @@
+import "dart:io";
+
+import "package:cake_wallet/entities/saved_nfts.dart";
+import "package:cake_wallet/entities/solana_nft_asset_model.dart";
+import "package:cake_wallet/entities/wallet_nft_response.dart";
+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";
+
+class _FakePathProviderPlatform extends PathProviderPlatform {
+ _FakePathProviderPlatform(this.root);
+
+ final String root;
+
+ @override
+ Future<String?> getApplicationDocumentsPath() async => root;
+
+ @override
+ Future<String?> getApplicationSupportPath() async => root;
+}
+
+SolanaNFTAssetModel solanaNFT(String mint, {String? name, bool? isOwned}) =>
+ SolanaNFTAssetModel(mint: mint, address: mint, name: name ?? mint, isOwned: isOwned);
+
+Future<void> main() async {
+ final dataRoot = Directory("./test/data/saved_nfts");
+
+ const wallet = "MyWallet";
+ const otherWallet = "OtherWallet";
+ const otherChain = "eth";
+
+ final saved = SavedNFTs();
+
+ 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("SavedNFTs, solana", () {
+ test("keeps what was added and reads it back", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA", name: "Ape", isOwned: true));
+
+ final stored = await saved.solanaNFTs(wallet);
+
+ expect(stored.keys, ["MintA"]);
+ expect(stored["MintA"]!.name, "Ape");
+ expect(stored["MintA"]!.isOwned, isTrue);
+ });
+
+ test("keeps wallets and chains apart", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA"));
+
+ expect(await saved.solanaNFTs(otherWallet), isEmpty);
+ expect(await saved.evmNFTs(wallet, otherChain), isEmpty);
+ });
+
+ test("adding the same mint twice replaces it rather than duplicating", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA", name: "first"));
+ await saved.addSolana(wallet, solanaNFT("MintA", name: "second"));
+
+ final stored = await saved.solanaNFTs(wallet);
+
+ expect(stored.length, 1);
+ expect(stored["MintA"]!.name, "second");
+ });
+
+ test("ignores an asset with no mint", () async {
+ await saved.addSolana(wallet, SolanaNFTAssetModel(address: "x"));
+
+ expect(await saved.solanaNFTs(wallet), isEmpty);
+ });
+
+ test("removes what was sent", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA"));
+ await saved.removeSolana(wallet, "MintA");
+
+ expect(await saved.solanaNFTs(wallet), isEmpty);
+ });
+
+ test("refresh updates what is still saved and does not add anything new", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA", name: "old", isOwned: true));
+
+ await saved.refreshSolana(wallet, [
+ solanaNFT("MintA", name: "new", isOwned: false),
+ solanaNFT("MintB", name: "never imported"),
+ ]);
+
+ final stored = await saved.solanaNFTs(wallet);
+
+ expect(stored.keys, ["MintA"]);
+ expect(stored["MintA"]!.name, "new");
+ expect(stored["MintA"]!.isOwned, isFalse);
+ });
+
+ // A refresh runs for seconds while it fetches metadata per mint, and the
+ // user can import during it.
+ test("an import that lands during a refresh is not lost", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA", name: "old"));
+
+ final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
+ final importing = saved.addSolana(wallet, solanaNFT("MintNew", name: "imported"));
+
+ await Future.wait([refreshing, importing]);
+
+ final stored = await saved.solanaNFTs(wallet);
+
+ expect(stored.keys.toSet(), {"MintA", "MintNew"});
+ expect(stored["MintA"]!.name, "refreshed");
+ });
+
+ test("an import during a refresh survives whichever order they run in", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA"));
+
+ final importing = saved.addSolana(wallet, solanaNFT("MintNew"));
+ final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
+
+ await Future.wait([importing, refreshing]);
+
+ expect((await saved.solanaNFTs(wallet)).keys.toSet(), {"MintA", "MintNew"});
+ });
+
+ test("an NFT sent during a refresh is not put back", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA"));
+
+ final sending = saved.removeSolana(wallet, "MintA");
+ final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
+
+ await Future.wait([sending, refreshing]);
+
+ expect(await saved.solanaNFTs(wallet), isEmpty);
+ });
+
+ test("an NFT sent after a refresh has already read the rows is not put back", () async {
+ await saved.addSolana(wallet, solanaNFT("MintA"));
+
+ // The refresh starts first here, so its read of the rows is queued ahead of
+ // the delete. That is the ordering a real send hits: the user taps send
+ // while a refresh is already in flight.
+ final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
+ final sending = saved.removeSolana(wallet, "MintA");
+
+ await Future.wait([refreshing, sending]);
+
+ expect(await saved.solanaNFTs(wallet), isEmpty);
+ });
+
+ test("many overlapping writes all land", () async {
+ await Future.wait([
+ for (var i = 0; i < 20; i++) saved.addSolana(wallet, solanaNFT("Mint$i")),
+ ]);
+
+ expect((await saved.solanaNFTs(wallet)).length, 20);
+ });
+ });
+
+ group("SavedNFTs, evm", () {
+ NFTAssetModel evmNFT(String address, String tokenId, {String? name}) => NFTAssetModel(
+ tokenAddress: address,
+ tokenId: tokenId,
+ name: name ?? address,
+ symbol: "SYM",
+ normalizedMetadata: NormalizedMetadata(
+ name: name,
+ description: "a description",
+ image: "ipfs://QmAbC",
+ ),
+ );
+
+ test("an imported evm nft survives a restart", () async {
+ await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7", name: "Punk"));
+
+ final stored = await saved.evmNFTs(wallet, otherChain);
+
+ expect(stored.length, 1);
+ expect(stored.first.tokenAddress, "0xabc");
+ expect(stored.first.tokenId, "7");
+ expect(stored.first.name, "Punk");
+ expect(stored.first.normalizedMetadata?.description, "a description");
+ });
+
+ test("stores the ipfs image through the shared gateway", () async {
+ await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7"));
+
+ final stored = await saved.evmNFTs(wallet, otherChain);
+
+ expect(stored.first.normalizedMetadata?.imageUrl, "https://ipfs.io/ipfs/QmAbC");
+ });
+
+ test("tells two token ids of one contract apart", () async {
+ await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7"));
+ await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "8"));
+
+ expect((await saved.evmNFTs(wallet, otherChain)).length, 2);
+ });
+
+ test("keeps evm and solana entries apart by chain", () async {
+ await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7"));
+ await saved.addSolana(wallet, solanaNFT("MintA"));
+
+ expect((await saved.evmNFTs(wallet, otherChain)).length, 1);
+ expect((await saved.solanaNFTs(wallet)).length, 1);
+ });
+ });
+}
diff --git a/test/entities/solana_nft_asset_model_test.dart b/test/entities/solana_nft_asset_model_test.dart
new file mode 100644
index 00000000..45d2c925
--- /dev/null
+++ b/test/entities/solana_nft_asset_model_test.dart
@@ -0,0 +1,107 @@
+import "package:cake_wallet/entities/solana_nft_asset_model.dart";
+import "package:cw_core/utils/nft_text.dart";
+import "package:cake_wallet/entities/wallet_nft_response.dart";
+import "package:flutter_test/flutter_test.dart";
+
+void main() {
+ group("SolanaNFTAssetModel", () {
+ Map<String, dynamic> json({String name = "DeGod", bool? isOwned}) => {
+ "address": "MintAddr",
+ "mint": "MintAddr",
+ "standard": "metaplex",
+ "name": name,
+ "symbol": "DGOD",
+ "description": "a description",
+ "imageOriginalUrl": "ipfs://QmAbC123",
+ "externalUrl": "https://example.com",
+ "metadataOriginalUrl": "https://example.com/meta.json",
+ "totalSupply": "1",
+ "isOwned": isOwned,
+ };
+
+ test("sanitises the text a mint controls", () {
+ final asset = SolanaNFTAssetModel.fromJson(json(name: "Ape\u2028Address: 9xQeW"));
+
+ expect(asset.name, "Ape Address: 9xQeW");
+ });
+
+ test("routes an ipfs image through the gateway", () {
+ expect(
+ SolanaNFTAssetModel.fromJson(json()).imageOriginalUrl,
+ "https://ipfs.io/ipfs/QmAbC123",
+ );
+ });
+
+ test("reads the fields the indexer actually sends", () {
+ final asset = SolanaNFTAssetModel.fromJson(json());
+
+ expect(asset.mint, "MintAddr");
+ expect(asset.name, "DeGod");
+ expect(asset.imageOriginalUrl, "https://ipfs.io/ipfs/QmAbC123");
+ });
+
+ test("sanitising is idempotent, so a value read back from storage stays stable", () {
+ final once = SolanaNFTAssetModel.fromJson(json(name: "Ape\u2028Name"));
+
+ expect(once.name, "Ape Name");
+ expect(sanitizeNFTText(once.name), once.name);
+ });
+
+ test("sanitises collection and contract text, which render on the same screen", () {
+ final asset = SolanaNFTAssetModel.fromJson({
+ ...json(),
+ "collection": {
+ "name": "Coll\u2028ection",
+ "description": "desc\u202Eription",
+ },
+ "contract": {
+ "name": "Cont\u2029ract",
+ "symbol": "SY\u200BM",
+ },
+ });
+
+ expect(asset.collection?.name, "Coll ection");
+ expect(asset.collection?.description, "desc ription");
+ expect(asset.contract?.name, "Cont ract");
+ expect(asset.contract?.symbol, "SY M");
+ });
+ });
+
+ group("NFTAssetModel", () {
+ Map<String, dynamic> evmJson({String name = "Punk", String image = "ipfs://QmAbC"}) => {
+ "token_address": "0xabc",
+ "token_id": "7",
+ "contract_type": "ERC721",
+ "name": name,
+ "symbol": "PNK",
+ "normalized_metadata": {
+ "name": name,
+ "description": "a description",
+ "image": image,
+ },
+ };
+
+ test("sanitises the text a contract controls", () {
+ final asset = NFTAssetModel.fromJson(evmJson(name: "Punk\u2028Address: 0xdead"));
+
+ expect(asset.name, "Punk Address: 0xdead");
+ expect(asset.normalizedMetadata?.name, "Punk Address: 0xdead");
+ });
+
+ test("routes an ipfs image through the same gateway as solana", () {
+ expect(
+ NFTAssetModel.fromJson(evmJson()).normalizedMetadata?.imageUrl,
+ "https://ipfs.io/ipfs/QmAbC",
+ );
+ });
+
+ test("leaves a plain gateway url alone instead of mangling it", () {
+ expect(
+ NFTAssetModel.fromJson(evmJson(image: "https://example.com/ipfs/QmAbC"))
+ .normalizedMetadata
+ ?.imageUrl,
+ "https://example.com/ipfs/QmAbC",
+ );
+ });
+ });
+}
diff --git a/tool/configure.dart b/tool/configure.dart
index e30bb6e9..c11d020e 100644
--- a/tool/configure.dart
+++ b/tool/configure.dart
@@ -341,9 +341,9 @@ import 'package:cw_core/wallet_service.dart';
import 'package:hive/hive.dart';
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart' as ledger;
import 'package:trezor_flutter/trezor_flutter.dart' as trezor;
+import "package:cw_core/hardware/hardware_wallet_service.dart";
import 'package:polyseed/polyseed.dart';""";
const moneroCWHeaders = """
-import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:cw_core/account.dart' as monero_account;
import 'package:cw_core/get_height_by_date.dart';
import 'package:cw_core/monero_amount_format.dart';
@@ -489,6 +489,11 @@ WalletCredentials createMoneroNewWalletCredentials({required String name, requir
Future<void> syncTrezor(Object wallet);
Map<String, List<int>> debugCallLength();
Map<String, dynamic> getWalletCacheDebug();
+ Future<int> getNodeHeight(Object wallet);
+ String getLegacySeed(Object wallet, String langName);
+ bool isBackgroundSyncRunning(Object wallet);
+ Future<void> startBackgroundSync(Object wallet);
+ Future<void> stopBackgroundSync(Object wallet, String password);
}
abstract class MoneroSubaddressList {
@@ -1020,7 +1025,16 @@ abstract class Solana {
});
Future<void> discoverAndAddWalletTokens(WalletBase wallet);
-
+
+ Future<PendingTransaction> sendNFT(
+ WalletBase wallet, {
+ required String mintAddress,
+ required String destinationAddress,
+ String? name,
+ });
+ Future<SolanaNFTMetadata?> getNFTOnChainMetadata(WalletBase wallet, String mintAddress);
+ Future<Set<String>> getHeldTokenMints(WalletBase wallet);
+
TransactionInfo getTransactionInfo({
required String id,
required DateTime blockTime,
@@ -1033,6 +1047,22 @@ abstract class Solana {
});
}
+class SolanaNFTMetadata {
+ const SolanaNFTMetadata({
+ required this.mint,
+ required this.name,
+ required this.symbol,
+ required this.metadataUri,
+ this.imageUrl,
+ });
+
+ final String mint;
+ final String name;
+ final String symbol;
+ final String metadataUri;
+ final String? imageUrl;
+}
+
class JupiterSwapFailedException implements Exception {
final String message;
final String signature;
Why this scored 47/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.