mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
solana nft improvements and other fixes (#3581)
* feat: improvements covering solana nfts(send, ownership badge, persistent imports etc), closing emptied token accounts and other fixes from patch * feat: more improvements for nfts including security hardening * fix: display error in full
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -55,6 +55,8 @@ class SignSPLTokenTransactionRentException implements Exception {}
|
||||
|
||||
class NoAssociatedTokenAccountException implements Exception {}
|
||||
|
||||
class NotAnNFTException implements Exception {}
|
||||
|
||||
class AmbiguousTokenSymbolException implements Exception {
|
||||
AmbiguousTokenSymbolException(this.symbol);
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import "package:cw_core/db/sqlite.dart";
|
||||
import "package:sqflite/sqflite.dart";
|
||||
|
||||
class ImportedNFT {
|
||||
ImportedNFT({
|
||||
required this.walletName,
|
||||
required this.chain,
|
||||
required this.identifier,
|
||||
this.name,
|
||||
this.symbol,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.isOwned,
|
||||
this.id = 0,
|
||||
});
|
||||
|
||||
ImportedNFT.copyWith(ImportedNFT other, {String? walletName})
|
||||
: id = 0,
|
||||
walletName = walletName ?? other.walletName,
|
||||
chain = other.chain,
|
||||
identifier = other.identifier,
|
||||
name = other.name,
|
||||
symbol = other.symbol,
|
||||
description = other.description,
|
||||
imageUrl = other.imageUrl,
|
||||
isOwned = other.isOwned;
|
||||
|
||||
factory ImportedNFT.fromMap(Map<String, Object?> map) => ImportedNFT(
|
||||
id: map[selfIdColumn] as int? ?? 0,
|
||||
walletName: map["walletName"]! as String,
|
||||
chain: map["chain"]! as String,
|
||||
identifier: map["identifier"]! as String,
|
||||
name: map["name"] as String?,
|
||||
symbol: map["symbol"] as String?,
|
||||
description: map["description"] as String?,
|
||||
imageUrl: map["imageUrl"] as String?,
|
||||
isOwned: map["isOwned"] == null ? null : map["isOwned"] == 1,
|
||||
);
|
||||
|
||||
int id;
|
||||
final String walletName;
|
||||
final String chain;
|
||||
final String identifier;
|
||||
final String? name;
|
||||
final String? symbol;
|
||||
final String? description;
|
||||
final String? imageUrl;
|
||||
final bool? isOwned;
|
||||
|
||||
static String get tableName => "ImportedNFT";
|
||||
static String get selfIdColumn => "${tableName}Id";
|
||||
static const solanaChain = "solana";
|
||||
|
||||
Map<String, Object?> toMap() => {
|
||||
selfIdColumn: id,
|
||||
"walletName": walletName,
|
||||
"chain": chain,
|
||||
"identifier": identifier,
|
||||
"name": name,
|
||||
"symbol": symbol,
|
||||
"description": description,
|
||||
"imageUrl": imageUrl,
|
||||
"isOwned": isOwned == null ? null : (isOwned! ? 1 : 0),
|
||||
};
|
||||
|
||||
Future<int> save() async {
|
||||
final row = toMap();
|
||||
|
||||
if (row[selfIdColumn] == 0) {
|
||||
row[selfIdColumn] = null;
|
||||
}
|
||||
|
||||
id = await db!.insert(tableName, row, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
static Future<int> updateMetadata(ImportedNFT nft) => db!.update(
|
||||
tableName,
|
||||
nft.toMap()..remove(selfIdColumn),
|
||||
where: "walletName = ? AND chain = ? AND identifier = ?",
|
||||
whereArgs: [nft.walletName, nft.chain, nft.identifier],
|
||||
);
|
||||
|
||||
static Future<List<ImportedNFT>> getAllForWallet(String walletName, [String? chain]) async {
|
||||
final rows = await db!.query(
|
||||
tableName,
|
||||
where: chain == null ? "walletName = ?" : "walletName = ? AND chain = ?",
|
||||
whereArgs: chain == null ? [walletName] : [walletName, chain],
|
||||
orderBy: selfIdColumn,
|
||||
);
|
||||
|
||||
return rows.map(ImportedNFT.fromMap).toList();
|
||||
}
|
||||
|
||||
static Future<int> deleteOne(String walletName, String chain, String identifier) => db!.delete(
|
||||
tableName,
|
||||
where: "walletName = ? AND chain = ? AND identifier = ?",
|
||||
whereArgs: [walletName, chain, identifier],
|
||||
);
|
||||
|
||||
static String _scope(List<String>? chains) =>
|
||||
chains == null ? "" : " AND chain IN (${List.filled(chains.length, "?").join(", ")})";
|
||||
|
||||
static Future<int> deleteAllForWallet(String walletName, {List<String>? chains}) async {
|
||||
if (chains != null && chains.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return db!.delete(
|
||||
tableName,
|
||||
where: "walletName = ?${_scope(chains)}",
|
||||
whereArgs: [walletName, ...?chains],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> renameWallet(
|
||||
String oldName,
|
||||
String newName, {
|
||||
List<String>? chains,
|
||||
}) async {
|
||||
if (chains != null && chains.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db!.delete(
|
||||
tableName,
|
||||
where: "walletName = ?${_scope(chains)}",
|
||||
whereArgs: [newName, ...?chains],
|
||||
);
|
||||
await db!.update(
|
||||
tableName,
|
||||
{"walletName": newName},
|
||||
where: "walletName = ?${_scope(chains)}",
|
||||
whereArgs: [oldName, ...?chains],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const _ipfsGateway = "https://ipfs.io/ipfs/";
|
||||
const _ipfsScheme = "ipfs://";
|
||||
|
||||
String? tryNormalizeIpfsUrl(String? url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!url.toLowerCase().startsWith(_ipfsScheme)) {
|
||||
return url.toLowerCase().startsWith("https://") ? url : null;
|
||||
}
|
||||
|
||||
var cid = url.substring(_ipfsScheme.length);
|
||||
|
||||
// Some minters write ipfs://ipfs/<cid>, which would double the gateway path.
|
||||
if (cid.toLowerCase().startsWith("ipfs/")) {
|
||||
cid = cid.substring(5);
|
||||
}
|
||||
|
||||
if (cid.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "$_ipfsGateway$cid";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Control characters and bidi overrides let a mint's own metadata fake extra
|
||||
// lines or reverse the text around it wherever the name is shown.
|
||||
final _unsafeDisplayCharacters = RegExp(
|
||||
"[\u0000-\u001F\u007F\u00AD\u034F\u061C\u115F\u1160\u180E\u200B\u200E\u200F"
|
||||
"\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\u3164\uFEFF\uFFA0]"
|
||||
"|[\u{E0000}-\u{E007F}]",
|
||||
unicode: true,
|
||||
);
|
||||
|
||||
const _maxDisplayTextLength = 512;
|
||||
|
||||
String? sanitizeNFTText(String? text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final cleaned = text.replaceAll(_unsafeDisplayCharacters, " ").trim();
|
||||
final runes = cleaned.runes;
|
||||
|
||||
if (runes.length <= _maxDisplayTextLength) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
return "${String.fromCharCodes(runes.take(_maxDisplayTextLength))}\u2026";
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:cw_core/imported_nft.dart";
|
||||
import "package:cw_core/pathForWallet.dart";
|
||||
import "package:cw_core/spl_token.dart";
|
||||
import "package:cw_core/tron_token.dart";
|
||||
@@ -62,6 +63,11 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
|
||||
Future<void> _renameTokenRows(String currentName, String newName) async {
|
||||
if (getType() == WalletType.solana) {
|
||||
await SPLToken.renameWallet(currentName, newName);
|
||||
await ImportedNFT.renameWallet(
|
||||
currentName,
|
||||
newName,
|
||||
chains: const [ImportedNFT.solanaChain],
|
||||
);
|
||||
}
|
||||
|
||||
if (getType() == WalletType.tron) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:cw_core/db/sqlite.dart";
|
||||
import "package:cw_core/imported_nft.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
|
||||
import "package:sqflite_common_ffi/sqflite_ffi.dart";
|
||||
|
||||
// Faking the documents dir keeps getAppDir() off the platform channel, so the
|
||||
// test runs with a plain `flutter test` on any host and in CI.
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform {
|
||||
_FakePathProviderPlatform(this.root);
|
||||
|
||||
final String root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => root;
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
final dataRoot = Directory("./test/data/imported_nft");
|
||||
|
||||
Future<void> insert(String walletName, String chain, String identifier) =>
|
||||
ImportedNFT(walletName: walletName, chain: chain, identifier: identifier, name: identifier)
|
||||
.save();
|
||||
|
||||
Future<List<String>> identifiersFor(String walletName, [String? chain]) async =>
|
||||
(await ImportedNFT.getAllForWallet(walletName, chain)).map((nft) => nft.identifier).toList();
|
||||
|
||||
setUpAll(() async {
|
||||
if (dataRoot.existsSync()) {
|
||||
dataRoot.deleteSync(recursive: true);
|
||||
}
|
||||
dataRoot.createSync(recursive: true);
|
||||
Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
|
||||
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
await initDb();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await db!.delete(ImportedNFT.tableName);
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
if (dataRoot.existsSync()) {
|
||||
dataRoot.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group("ImportedNFT wallet lifecycle", () {
|
||||
test("reading without a chain returns every chain the wallet imported on", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Main", "polygon", "0xother:7");
|
||||
|
||||
expect(await identifiersFor("Main", "eth"), ["0xcontract:1"]);
|
||||
expect(
|
||||
(await identifiersFor("Main"))..sort(),
|
||||
["0xcontract:1", "0xother:7", "SolMint"],
|
||||
);
|
||||
});
|
||||
|
||||
test("rename moves every chain and leaves other wallets alone", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Sidekick", "eth", "0xkeep:9");
|
||||
|
||||
await ImportedNFT.renameWallet("Main", "Renamed");
|
||||
|
||||
expect(await identifiersFor("Main"), isEmpty);
|
||||
expect((await identifiersFor("Renamed"))..sort(), ["0xcontract:1", "SolMint"]);
|
||||
expect(await identifiersFor("Sidekick"), ["0xkeep:9"]);
|
||||
});
|
||||
|
||||
test("renaming into a name that still has orphan rows does not trip the unique index",
|
||||
() async {
|
||||
await insert("Orphaned", "eth", "0xcontract:1");
|
||||
await insert("Orphaned", "polygon", "0xsurvivor:5");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Main", "eth", "0xsecond:2");
|
||||
|
||||
await ImportedNFT.renameWallet("Main", "Orphaned", chains: const ["eth"]);
|
||||
|
||||
// Only the colliding chain is cleared. A row the destination holds on
|
||||
// another chain is not ours to delete.
|
||||
expect(
|
||||
(await identifiersFor("Orphaned"))..sort(),
|
||||
["0xcontract:1", "0xsecond:2", "0xsurvivor:5"],
|
||||
);
|
||||
});
|
||||
|
||||
test("renaming a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
|
||||
await insert("Sol", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Vault", "eth", "0xEthNFT:1");
|
||||
|
||||
await ImportedNFT.renameWallet(
|
||||
"Sol",
|
||||
"Vault",
|
||||
chains: const [ImportedNFT.solanaChain],
|
||||
);
|
||||
|
||||
expect((await identifiersFor("Vault"))..sort(), ["0xEthNFT:1", "SolMint"]);
|
||||
});
|
||||
|
||||
test("deleting a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
|
||||
await insert("Vault", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Vault", "eth", "0xEthNFT:1");
|
||||
|
||||
await ImportedNFT.deleteAllForWallet(
|
||||
"Vault",
|
||||
chains: const [ImportedNFT.solanaChain],
|
||||
);
|
||||
|
||||
expect(await identifiersFor("Vault"), ["0xEthNFT:1"]);
|
||||
});
|
||||
|
||||
test("an empty chain scope deletes nothing rather than everything", () async {
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
|
||||
await ImportedNFT.deleteAllForWallet("Main", chains: const []);
|
||||
|
||||
expect(await identifiersFor("Main"), ["0xcontract:1"]);
|
||||
});
|
||||
|
||||
test("delete removes every chain for that wallet only", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Sidekick", ImportedNFT.solanaChain, "OtherMint");
|
||||
|
||||
await ImportedNFT.deleteAllForWallet("Main");
|
||||
|
||||
expect(await identifiersFor("Main"), isEmpty);
|
||||
expect(await identifiersFor("Sidekick"), ["OtherMint"]);
|
||||
});
|
||||
|
||||
test("deleting one leaves the wallet's other imports in place", () async {
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Main", "eth", "0xsecond:2");
|
||||
await insert("Main", "polygon", "0xcontract:1");
|
||||
|
||||
await ImportedNFT.deleteOne("Main", "eth", "0xcontract:1");
|
||||
|
||||
expect(await identifiersFor("Main", "eth"), ["0xsecond:2"]);
|
||||
expect(await identifiersFor("Main", "polygon"), ["0xcontract:1"]);
|
||||
});
|
||||
|
||||
test("copying to a new name keeps the rows under the old one", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
|
||||
for (final nft in await ImportedNFT.getAllForWallet("Main")) {
|
||||
await ImportedNFT.copyWith(nft, walletName: "Copy").save();
|
||||
}
|
||||
|
||||
expect((await identifiersFor("Main"))..sort(), ["0xcontract:1", "SolMint"]);
|
||||
expect((await identifiersFor("Copy"))..sort(), ["0xcontract:1", "SolMint"]);
|
||||
});
|
||||
|
||||
test("a copy is a new row, so saving it does not overwrite the original", () async {
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
|
||||
final original = (await ImportedNFT.getAllForWallet("Main")).single;
|
||||
final copy = ImportedNFT.copyWith(original, walletName: "Copy");
|
||||
await copy.save();
|
||||
|
||||
expect(copy.id, isNot(original.id));
|
||||
expect((await ImportedNFT.getAllForWallet("Main")).single.id, original.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import "package:cw_core/utils/ipfs_url.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("tryNormalizeIpfsUrl", () {
|
||||
test("rewrites an ipfs uri to the gateway", () {
|
||||
expect(tryNormalizeIpfsUrl("ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
});
|
||||
|
||||
test("does not double the gateway path when the uri already says ipfs/", () {
|
||||
expect(tryNormalizeIpfsUrl("ipfs://ipfs/QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
});
|
||||
|
||||
test("matches the scheme whatever its case", () {
|
||||
expect(tryNormalizeIpfsUrl("IPFS://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
expect(tryNormalizeIpfsUrl("Ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
});
|
||||
|
||||
test("returns null for an ipfs uri with no cid, rather than a bare gateway url", () {
|
||||
expect(tryNormalizeIpfsUrl("ipfs://"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("ipfs://ipfs/"), isNull);
|
||||
});
|
||||
|
||||
test("passes through an https url that is not ipfs", () {
|
||||
expect(tryNormalizeIpfsUrl("https://arweave.net/abc"), "https://arweave.net/abc");
|
||||
});
|
||||
|
||||
test("drops a scheme nothing can render, rather than handing it to the image widget", () {
|
||||
expect(tryNormalizeIpfsUrl("ar://abc123"), isNull);
|
||||
expect(tryNormalizeIpfsUrl(""), isNull);
|
||||
});
|
||||
|
||||
test("returns null for null", () {
|
||||
expect(tryNormalizeIpfsUrl(null), isNull);
|
||||
});
|
||||
|
||||
test("is idempotent, so a value read back from storage stays stable", () {
|
||||
final once = tryNormalizeIpfsUrl("ipfs://QmAbC123");
|
||||
expect(tryNormalizeIpfsUrl(once), once);
|
||||
});
|
||||
|
||||
test("drops any scheme that is not https", () {
|
||||
// These render through Image.network, which does not go through the proxy.
|
||||
expect(tryNormalizeIpfsUrl("http://127.0.0.1:8080/x.png"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("file:///etc/passwd"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("javascript:alert(1)"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("data:text/html,x"), isNull);
|
||||
});
|
||||
|
||||
test("keeps an https url whatever its casing", () {
|
||||
expect(tryNormalizeIpfsUrl("https://ok.example/a.png"), "https://ok.example/a.png");
|
||||
expect(tryNormalizeIpfsUrl("HTTPS://Up.example/a.png"), "HTTPS://Up.example/a.png");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import "package:cw_core/utils/nft_text.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("sanitizeNFTText", () {
|
||||
test("removes the separators that would fake extra lines", () {
|
||||
expect(sanitizeNFTText("Ape\u2028Address: 9xQeW"), "Ape Address: 9xQeW");
|
||||
expect(sanitizeNFTText("Ape\u2029Address: 9xQeW"), "Ape Address: 9xQeW");
|
||||
expect(sanitizeNFTText("Ape\nAddress: 9xQeW"), "Ape Address: 9xQeW");
|
||||
});
|
||||
|
||||
test("removes bidi overrides and isolates that reorder what follows", () {
|
||||
expect(sanitizeNFTText("evil\u202Etxen"), "evil txen");
|
||||
expect(sanitizeNFTText("a\u2066b\u2069c"), "a b c");
|
||||
});
|
||||
|
||||
test("removes invisibles that hide or reorder characters", () {
|
||||
expect(sanitizeNFTText("A\u200BB"), "A B");
|
||||
expect(sanitizeNFTText("A\u200FB"), "A B");
|
||||
expect(sanitizeNFTText("A\u061CB"), "A B");
|
||||
expect(sanitizeNFTText("A\u2060B"), "A B");
|
||||
});
|
||||
|
||||
test("removes control characters and delete", () {
|
||||
expect(sanitizeNFTText("A\u0000B"), "A B");
|
||||
expect(sanitizeNFTText("A\u001FB"), "A B");
|
||||
expect(sanitizeNFTText("A\u007FB"), "A B");
|
||||
});
|
||||
|
||||
test("leaves ordinary text alone", () {
|
||||
expect(sanitizeNFTText("DeGod #1234"), "DeGod #1234");
|
||||
expect(sanitizeNFTText(" padded "), "padded");
|
||||
});
|
||||
|
||||
test("returns null for null", () {
|
||||
expect(sanitizeNFTText(null), isNull);
|
||||
});
|
||||
|
||||
test("counts the cap in characters, so emoji are not cut in half", () {
|
||||
final long = sanitizeNFTText("\u{1F600}" * 600)!;
|
||||
|
||||
expect(long.runes.length, 513);
|
||||
expect(long.runes.last, 0x2026);
|
||||
|
||||
final units = long.codeUnits;
|
||||
final endsOnHighSurrogate = units.isNotEmpty && units.last >= 0xD800 && units.last <= 0xDBFF;
|
||||
expect(endsOnHighSurrogate, isFalse);
|
||||
});
|
||||
|
||||
test("does not truncate a name that is long in code units but short in characters", () {
|
||||
expect(sanitizeNFTText("\u{1F600}" * 400)!.runes.length, 400);
|
||||
});
|
||||
|
||||
test("is idempotent, so re-reading a saved value does not change it", () {
|
||||
const raw = "Ape\u2028Name\u202E with \u{1F600}";
|
||||
final once = sanitizeNFTText(raw);
|
||||
|
||||
expect(sanitizeNFTText(once), once);
|
||||
});
|
||||
|
||||
test("keeps the joiners that carry meaning in persian and emoji", () {
|
||||
// Replacing these put a visible space inside a Persian word and split
|
||||
// a two-person emoji into two glyphs.
|
||||
expect(sanitizeNFTText("\u{1F468}\u200D\u{1F4BB}"), "\u{1F468}\u200D\u{1F4BB}");
|
||||
expect(
|
||||
sanitizeNFTText("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645"),
|
||||
"\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645",
|
||||
);
|
||||
});
|
||||
|
||||
test("strips the invisibles a mint can hide text behind", () {
|
||||
for (final invisible in [
|
||||
"\u00AD", // soft hyphen
|
||||
"\u034F", // combining grapheme joiner
|
||||
"\u180E", // mongolian vowel separator
|
||||
"\u3164", // hangul filler
|
||||
"\uFEFF", // zero width no-break space
|
||||
"\uFFA0", // halfwidth hangul filler
|
||||
"\u{E0041}", // tag latin capital A
|
||||
]) {
|
||||
expect(
|
||||
sanitizeNFTText("A${invisible}B"),
|
||||
"A B",
|
||||
reason: "U+${invisible.runes.first.toRadixString(16).toUpperCase()} survived",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 => {};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>()));
|
||||
|
||||
@@ -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(":") : "";
|
||||
}
|
||||
}
|
||||
@@ -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?),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
+13
-1
@@ -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>(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
child: CakeImageWidget(
|
||||
imageUrl: nftAsset.imageOriginalUrl,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
bool get isSolanaWallet => appStore.wallet?.type == WalletType.solana;
|
||||
|
||||
String? get currentWalletName => appStore.wallet?.name;
|
||||
|
||||
@action
|
||||
Future<void> getNFTAssetByWallet() async {
|
||||
final wallet = appStore.wallet!;
|
||||
void onWalletChanged() {
|
||||
nftAssetByWalletModels.clear();
|
||||
solanaNftAssetModels.clear();
|
||||
unresolvedNFTCount = 0;
|
||||
_nftErrorPopupShown = false;
|
||||
_fetchDebounce.run(getNFTAssetByWallet);
|
||||
}
|
||||
|
||||
if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) return;
|
||||
@action
|
||||
Future<void> getNFTAssetByWallet() {
|
||||
final queued = _queuedFetch;
|
||||
|
||||
final walletAddress = wallet.walletInfo.address;
|
||||
log('Fetching wallet NFTs for $walletAddress');
|
||||
if (queued != null) {
|
||||
return queued.future;
|
||||
}
|
||||
|
||||
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.
|
||||
final done = Completer<void>();
|
||||
_queuedFetch = done;
|
||||
|
||||
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",
|
||||
},
|
||||
);
|
||||
final waitFor = _lastFetch;
|
||||
_lastFetch = done.future;
|
||||
|
||||
unawaited(_fetchAfterPreviousRun(waitFor, done));
|
||||
|
||||
return done.future;
|
||||
}
|
||||
|
||||
Future<void> _fetchAfterPreviousRun(Future<void> waitFor, Completer<void> done) async {
|
||||
await waitFor;
|
||||
|
||||
if (identical(_queuedFetch, done)) {
|
||||
_queuedFetch = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isLoading) return;
|
||||
final wallet = appStore.wallet!;
|
||||
|
||||
if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final walletAddress = wallet.walletInfo.address;
|
||||
final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: wallet.chainId);
|
||||
isLoading = true;
|
||||
|
||||
final response = await ProxyWrapper().get(
|
||||
clearnetUri: uri,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"X-API-Key": secrets.moralisApiKey,
|
||||
},
|
||||
);
|
||||
final refreshId = ++_currentRefreshId;
|
||||
|
||||
final decodedResponse = jsonDecode(response.body);
|
||||
if (isSolanaWallet) {
|
||||
await _getSolanaNFTAssets(wallet, walletAddress, chainName, refreshId)
|
||||
.timeout(_runDeadline);
|
||||
} else {
|
||||
await _getEvmNFTAssets(wallet.name, walletAddress, chainName, refreshId);
|
||||
}
|
||||
|
||||
_nftErrorPopupShown = false;
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
solanaNftAssetModels.clear();
|
||||
|
||||
solanaNftAssetModels.addAll(results);
|
||||
} else {
|
||||
final result =
|
||||
WalletNFTsResponseModel.fromJson(decodedResponse as Map<String, dynamic>).result ?? [];
|
||||
|
||||
nftAssetByWalletModels.clear();
|
||||
|
||||
nftAssetByWalletModels.addAll(result);
|
||||
}
|
||||
} 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<SolanaNFTAssetModel> getSolanaNFTDetails(String address, String chainName) async {
|
||||
final uri = Uri.https(
|
||||
'solana-gateway.moralis.io',
|
||||
'/nft/$chainName/$address/metadata',
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
|
||||
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
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);
|
||||
|
||||
return SolanaNFTAssetModel.fromJson(decodedResponse);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception("Failed to fetch NFT metadata (${response.statusCode})");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
result.isOwned = (await solana!.getHeldTokenMints(wallet)).contains(result.mint);
|
||||
} catch (e) {
|
||||
printV("Could not check NFT ownership: ${e.toString()}");
|
||||
}
|
||||
|
||||
solanaNftAssetModels.removeWhere((asset) => asset.mint == result.mint);
|
||||
solanaNftAssetModels.add(result);
|
||||
|
||||
await _savedNFTs.addSolana(wallet.name, 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,
|
||||
},
|
||||
);
|
||||
|
||||
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
final nftAsset = NFTAssetModel.fromJson(decodedResponse);
|
||||
|
||||
nftAssetByWalletModels.add(nftAsset);
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "طلب الاتصال",
|
||||
|
||||
@@ -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": "Заявка за свързване",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "درخواست اتصال",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "कनेक्ट अनुरोध",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Միացման հարցում",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "手順が完了するまでアプリを閉じないでください。閉じると、このプロセスを最初からやり直す必要があります。"
|
||||
}
|
||||
@@ -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": "절차가 완료될 때까지 앱을 닫지 마십시오. 닫으면 이 과정을 처음부터 다시 시작해야 합니다."
|
||||
}
|
||||
@@ -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": "လုပ်ငန်းစဉ် ပြီးဆုံးသည်အထိ အက်ပ်ကို မပိတ်ပါနှင့်။ ပိတ်လိုက်ပါက ဤလုပ်ငန်းစဉ်ကို အစမှ ပြန်လည်စတင်ရမည်ဖြစ်သည်။"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "Не закрывайте приложение до завершения процедуры. Если вы это сделаете, процесс придется перезапустить с нуля."
|
||||
}
|
||||
@@ -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": "อย่าปิดแอปจนกว่าขั้นตอนจะเสร็จสิ้น หากคุณปิดแอป กระบวนการนี้จะต้องเริ่มใหม่ตั้งแต่ต้น"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "Не закривайте застосунок, доки процедура не завершиться. Якщо ви це зробите, цей процес потрібно буде перезапустити з нуля."
|
||||
}
|
||||
@@ -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": "طریقہ کار مکمل ہونے تک ایپ بند نہ کریں۔ اگر آپ ایسا کریں گے تو یہ عمل شروع سے دوبارہ شروع کرنا پڑے گا۔"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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ẹ̀."
|
||||
}
|
||||
@@ -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": "在该过程完成之前请勿关闭应用程序,否则该过程将需要从头重新开始。"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+32
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user