From 0d353a0932150fd10c9b25cfbad48a52c9796c88 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Tue, 1 Sep 2026 13:42:33 +0200 Subject: [PATCH 1/7] feat: enhance hardware wallet compatibility and improve transaction handling (#3517) * feat: enhance hardware wallet compatibility and improve transaction handling - Updated hardware wallet services for Bitcoin and Litecoin (Ledger & Trezor). - Added `LitecoinTrezorService` for signing and managing Litecoin transactions. - Improved `PSBTTransaction` builder to handle change outputs and derivation paths. - Fixed exception handling in `wallet_hardware_restore_view_model`. - Refactored output and input structures to ensure consistency and extend compatibility. * refactor: address comments * refactor: address comments * refactor: address comments --- cw_bitcoin/lib/bitcoin_wallet.dart | 64 +++-- cw_bitcoin/lib/electrum_wallet.dart | 7 +- cw_bitcoin/lib/hardware/bitbox_service.dart | 4 +- .../bitcoin_hardware_wallet_service.dart | 9 +- .../lib/hardware/litecoin_ledger_service.dart | 89 +++--- .../lib/hardware/litecoin_trezor_service.dart | 100 +++++++ cw_bitcoin/lib/hardware/trezor_service.dart | 268 +++++++++--------- cw_bitcoin/lib/litecoin_wallet.dart | 48 +++- cw_bitcoin/lib/psbt/transaction_builder.dart | 175 ++++++++---- cw_bitcoin/pubspec.yaml | 5 + cw_monero/pubspec.yaml | 4 +- lib/bitcoin/cw_bitcoin.dart | 6 +- lib/router.dart | 25 +- .../trezor_connect_view_model.dart | 8 +- .../wallet_hardware_restore_view_model.dart | 2 +- pubspec_overrides.yaml | 8 +- tool/configure.dart | 4 +- 17 files changed, 521 insertions(+), 305 deletions(-) create mode 100644 cw_bitcoin/lib/hardware/litecoin_trezor_service.dart diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index ac40de051..a583dcc01 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -430,7 +430,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { .isNotEmpty; Future buildPsbt({ - required List outputs, + required List outputs, required List cwOutputs, required BigInt fee, required BasedUtxoNetwork network, @@ -447,33 +447,65 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; - psbtReadyInputs.add(PSBTReadyUtxoWithAddress( - utxo: utxo.utxo, - rawTx: rawTx, - ownerDetails: utxo.ownerDetails, - ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, - ownerMasterFingerprint: masterFingerprint, - ownerPublicKey: publicKeyAndDerivationPath.publicKey, - )); + psbtReadyInputs.add( + PSBTReadyUtxoWithAddress( + utxo: utxo.utxo, + rawTx: rawTx, + ownerDetails: utxo.ownerDetails, + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ownerMasterFingerprint: masterFingerprint, + ownerPublicKey: publicKeyAndDerivationPath.publicKey, + ), + ); } + final psbtReadyOutputs = outputs.map((o) { + final cwOutput = cwOutputs + .where( + (e) => [e.address, e.extractedAddress] + .map((e) => e?.toLowerCase()) + .contains(o.address.toAddress().toLowerCase()), + ) + .firstOrNull; + + if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) { + final changeKey = publicKeys[o.address.pubKeyHash()]!; + return PSBTReadyBitcoinOutput( + address: o.address, + value: o.value, + isSilentPayment: o.isSilentPayment, + isChange: o.isChange, + changeMasterFingerprint: masterFingerprint, + changeDerivationPath: changeKey.derivationPath, + changePublicKey: changeKey.publicKey, + outputInfo: cwOutput, + ); + } + return PSBTReadyBitcoinOutput( + address: o.address, + value: o.value, + isSilentPayment: o.isSilentPayment, + isChange: o.isChange, + outputInfo: cwOutput, + ); + }).toList(); + final locktime = antiFeeSnipingLocktime( chainTip: await getCurrentChainTip(), synced: syncStatus is SyncedSyncStatus, ); return PSBTTransactionBuild( - inputs: psbtReadyInputs, - outputs: outputs, - enableRBF: enableRBF, - cwOutputs: cwOutputs, - locktime: locktime) - .psbt; + inputs: psbtReadyInputs, + outputs: psbtReadyOutputs, + enableRBF: enableRBF, + locktime: locktime, + ).psbt; } @override Future buildHardwareWalletTransaction({ - required List outputs, + required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 1da016931..8b1cc99fb 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1283,8 +1283,11 @@ abstract class ElectrumWalletBase final changeDerivationPath = "${_hardenedDerivationPath(changeBaseDerivationPath)}" "/${changeAddress.isHidden ? "1" : "0"}" "/${changeAddress.index}"; + final changeHd = _hdFor(record: changeAddress).childKey(Bip32KeyIndex(changeAddress.index)); + final changePubKeyHex = changeHd.publicKey.toHex(); + utxoDetails.publicKeys[address.pubKeyHash()] = - PublicKeyWithDerivationPath('', changeDerivationPath); + PublicKeyWithDerivationPath(changePubKeyHex, changeDerivationPath); // calcFee updates the silent payment outputs to calculate the tx size accounting // for taproot addresses, but if more inputs are needed to make up for fees, @@ -1737,7 +1740,7 @@ abstract class ElectrumWalletBase HardwareWalletService? hardwareWalletService; Future buildHardwareWalletTransaction({ - required List outputs, + required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, diff --git a/cw_bitcoin/lib/hardware/bitbox_service.dart b/cw_bitcoin/lib/hardware/bitbox_service.dart index a71dcacbc..4b3ea5915 100644 --- a/cw_bitcoin/lib/hardware/bitbox_service.dart +++ b/cw_bitcoin/lib/hardware/bitbox_service.dart @@ -106,11 +106,11 @@ class LitecoinBitboxService extends HardwareWalletService @override Future signLitecoinTransaction({ - required List outputs, + required List outputs, required List inputs, required Map publicKeys, }) async { - final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs, cwOutputs: []).psbt; + final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs).psbt; final signedPsbt = await manager.signBTCPsbt(bitboxCoinType, base64Encode(psbt.asPsbtV0())); final transactionRes = PsbtV2() diff --git a/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart b/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart index 885868d3c..22954d113 100644 --- a/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart +++ b/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart @@ -1,8 +1,7 @@ -import 'dart:typed_data'; +import "dart:typed_data"; -import 'package:cw_bitcoin/electrum_wallet.dart'; -import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:cw_bitcoin/psbt/transaction_builder.dart'; +import "package:cw_bitcoin/electrum_wallet.dart"; +import "package:cw_bitcoin/psbt/transaction_builder.dart"; mixin BitcoinHardwareWalletService { Future getMasterFingerprint() async => Uint8List.fromList([0, 0, 0, 0]); @@ -10,7 +9,7 @@ mixin BitcoinHardwareWalletService { mixin LitecoinHardwareWalletService on BitcoinHardwareWalletService { Future signLitecoinTransaction({ - required List outputs, + required List outputs, required List inputs, required Map publicKeys, }); diff --git a/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart b/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart index aa73fc106..852f77881 100644 --- a/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart +++ b/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart @@ -1,16 +1,16 @@ -import 'dart:async'; -import 'dart:typed_data'; +import "dart:async"; +import "dart:typed_data"; -import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:blockchain_utils/blockchain_utils.dart'; -import 'package:cw_bitcoin/electrum_wallet.dart'; -import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; -import 'package:cw_bitcoin/psbt/transaction_builder.dart'; -import 'package:cw_bitcoin/utils.dart'; -import 'package:cw_core/hardware/hardware_account_data.dart'; -import 'package:cw_core/hardware/hardware_wallet_service.dart'; -import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; -import 'package:ledger_litecoin/ledger_litecoin.dart'; +import "package:bitcoin_base/bitcoin_base.dart"; +import "package:blockchain_utils/blockchain_utils.dart"; +import "package:cw_bitcoin/electrum_wallet.dart"; +import "package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart"; +import "package:cw_bitcoin/psbt/transaction_builder.dart"; +import "package:cw_bitcoin/utils.dart"; +import "package:cw_core/hardware/hardware_account_data.dart"; +import "package:cw_core/hardware/hardware_wallet_service.dart"; +import "package:ledger_flutter_plus/ledger_flutter_plus.dart"; +import "package:ledger_litecoin/ledger_litecoin.dart"; class LitecoinLedgerService extends HardwareWalletService with BitcoinHardwareWalletService, LitecoinHardwareWalletService { @@ -31,18 +31,21 @@ class LitecoinLedgerService extends HardwareWalletService for (final i in indexRange) { final derivationPath = "m/84'/2'/$i'"; final xpub = await litecoinLedgerApp.getXPubKey( - accountsDerivationPath: derivationPath, - xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16)); + accountsDerivationPath: derivationPath, + xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16), + ); final hd = Bip32Slip10Secp256k1.fromExtendedKey(xpub, xpubVersion).childKey(Bip32KeyIndex(0)); final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet); - accounts.add(HardwareAccountData( - address: address, - accountIndex: i, - derivationPath: derivationPath, - xpub: xpub, - )); + accounts.add( + HardwareAccountData( + address: address, + accountIndex: i, + derivationPath: derivationPath, + xpub: xpub, + ), + ); } return accounts; @@ -50,7 +53,7 @@ class LitecoinLedgerService extends HardwareWalletService @override Future signLitecoinTransaction({ - required List outputs, + required List outputs, required List inputs, required Map publicKeys, }) { @@ -58,31 +61,39 @@ class LitecoinLedgerService extends HardwareWalletService for (final utxo in inputs) { final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; - readyInputs.add(LedgerTransaction( - rawTx: utxo.rawTx, - outputIndex: utxo.utxo.vout, - ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)), - ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, - )); + readyInputs.add( + LedgerTransaction( + rawTx: utxo.rawTx, + outputIndex: utxo.utxo.vout, + ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)), + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ), + ); } // Check if we have the key to one of the output addresses to hide change on the device String? changePath; for (final output in outputs) { - final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; - if (maybeChangePath != null) changePath ??= maybeChangePath.derivationPath; + if (output.isChange) { + changePath ??= output.changeDerivationPath; + } } return litecoinLedgerApp.createTransaction( - inputs: readyInputs, - outputs: outputs - .map((e) => TransactionOutput.fromBigInt((e as BitcoinOutput).value, - Uint8List.fromList(e.address.toScriptPubKey().toBytes()))) - .toList(), - changePath: changePath, - sigHashType: 0x01, - additionals: ["bech32"], - isSegWit: true, - useTrustedInputForSegwit: true); + inputs: readyInputs, + outputs: outputs + .map( + (e) => TransactionOutput.fromBigInt( + (e as BitcoinOutput).value, + Uint8List.fromList(e.address.toScriptPubKey().toBytes()), + ), + ) + .toList(), + changePath: changePath, + sigHashType: 0x01, + additionals: ["bech32"], + isSegWit: true, + useTrustedInputForSegwit: true, + ); } } diff --git a/cw_bitcoin/lib/hardware/litecoin_trezor_service.dart b/cw_bitcoin/lib/hardware/litecoin_trezor_service.dart new file mode 100644 index 000000000..4858e30fe --- /dev/null +++ b/cw_bitcoin/lib/hardware/litecoin_trezor_service.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; +import 'package:cw_bitcoin/psbt/transaction_builder.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/hardware/hardware_account_data.dart'; +import 'package:cw_core/hardware/hardware_wallet_service.dart'; +import 'package:trezor_connect/trezor_connect.dart'; + +class LitecoinTrezorService extends HardwareWalletService + with BitcoinHardwareWalletService, LitecoinHardwareWalletService { + LitecoinTrezorService(this.connect); + + final TrezorConnect connect; + + @override + Future> getAvailableAccounts({int index = 0, int limit = 5}) async { + final indexRange = List.generate(limit, (i) => i + index); + final requestParams = []; + final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer; + + for (final i in indexRange) { + final derivationPath = "m/84'/2'/$i'"; + requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC")); + } + + final accounts = await connect.getPublicKeyBundle(requestParams); + + return accounts?.map((account) { + final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion) + .childKey(Bip32KeyIndex(0)); + + final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet); + return HardwareAccountData( + address: address, + xpub: account.xpub, + accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index + derivationPath: account.serializedPath, + ); + }).toList() ?? + []; + } + + @override + Future signLitecoinTransaction({ + required List outputs, + required List inputs, + required Map publicKeys, + }) async { + final readyInputs = inputs + .map((input) => TrezorTxInput( + prevHash: input.utxo.txHash, + prevIndex: input.utxo.vout, + amount: input.utxo.value.toInt(), + addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(), + scriptType: "SPENDWITNESS", + )) + .toList(); + + final readyOutputs = outputs.map((output) { + final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; + + return TrezorTxOutput( + amount: output.toOutput.amount.toInt(), + address: maybeChangePath != null + ? null + : output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet), + scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!), + addressPath: maybeChangePath != null + ? Bip32PathParser.parse(maybeChangePath.derivationPath).toList() + : null, + ); + }).toList(); + + final signedTx = + await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs); + + return signedTx!.serializedTx; + } +} + +String _getScriptType(BitcoinAddressType addressType) { + switch (addressType) { + case P2pkhAddressType.p2pkh: + return "PAYTOADDRESS"; + case P2shAddressType.p2wpkhInP2sh: + return "PAYTOSCRIPTHASH"; + case SegwitAddresType.p2tr: + return "PAYTOTAPROOT"; + case SegwitAddresType.p2wsh: + return "PAYTOP2SHWITNESS"; + case SegwitAddresType.p2wpkh: + return "PAYTOWITNESS"; + default: + throw Exception("Unknown Address Type"); + } +} diff --git a/cw_bitcoin/lib/hardware/trezor_service.dart b/cw_bitcoin/lib/hardware/trezor_service.dart index c9071ccce..238a01ee0 100644 --- a/cw_bitcoin/lib/hardware/trezor_service.dart +++ b/cw_bitcoin/lib/hardware/trezor_service.dart @@ -1,190 +1,178 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; -import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; -import 'package:cw_bitcoin/psbt/transaction_builder.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/hardware/hardware_account_data.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; +import 'package:flutter/foundation.dart'; import 'package:ledger_bitcoin/psbt.dart'; -import 'package:trezor_connect/trezor_connect.dart'; +import 'package:ledger_bitcoin/src/utils/uint8list_extension.dart' as ext; +import 'package:trezor_flutter/trezor_flutter.dart' as sdk; class BitcoinTrezorService extends HardwareWalletService with BitcoinHardwareWalletService { - BitcoinTrezorService(this.connect); + BitcoinTrezorService(this.client) : _trezorBitcoin = sdk.TrezorBitcoin(client); - final TrezorConnect connect; + final sdk.TrezorClient client; + final sdk.TrezorBitcoin _trezorBitcoin; + + @override + Future getMasterFingerprint() async { + final publicKey = await _trezorBitcoin.getPublicKey(derivationPath: "m/84'/0'/0'"); + final fingerprintBuffer = ByteData(4)..setUint32(0, publicKey.$2); + return fingerprintBuffer.buffer.asUint8List(); + } @override Future> getAvailableAccounts({int index = 0, int limit = 5}) async { + final accounts = []; final indexRange = List.generate(limit, (i) => i + index); - final requestParams = []; for (final i in indexRange) { - requestParams.add(TrezorGetPublicKeyParams(path: "m/84'/0'/$i'")); + final derivationPath = "m/84'/0'/$i'"; + final xPub = + await _trezorBitcoin.getPublicKey(derivationPath: derivationPath, ignoreXpubMagic: true); + final hd = Bip32Slip10Secp256k1.fromExtendedKey(xPub.$1).childKey(Bip32KeyIndex(0)); + + final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet); + + accounts.add( + HardwareAccountData( + address: address, + accountIndex: i, + derivationPath: derivationPath, + xpub: xPub.$1, + ), + ); } - final accounts = await connect.getPublicKeyBundle(requestParams); - - return accounts?.map((account) { - final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub).childKey(Bip32KeyIndex(0)); - final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet); - return HardwareAccountData( - address: address, - xpub: account.xpub, - accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index - derivationPath: account.serializedPath, - ); - }).toList() ?? - []; + return accounts; } @override Future signTransaction({required String transaction}) async { final psbt = PsbtV2()..deserialize(base64Decode(transaction)); + final masterFingerprint = await getMasterFingerprint(); + + final inputs = []; + final prevTxs = {}; - final inputs = []; final inputCount = psbt.getGlobalInputCount(); for (var i = 0; i < inputCount; i++) { - final inputTxRaw = psbt.getInputNonWitnessUtxo(i); - final inputTx = BtcTransaction.fromRaw(hex.encode(inputTxRaw!)); - final inputOutputIndex = psbt.getInputOutputIndex(i); + final rawPrevTx = psbt.getInputNonWitnessUtxo(i); + if (rawPrevTx == null) { + throw StateError("PSBT input $i is missing its previous transaction"); + } + final prevTx = BtcTransaction.fromRaw(hex.encode(rawPrevTx)); + final vout = psbt.getInputOutputIndex(i); - final publicKeys = psbt.inputMaps[i].keys.where((e) => e.startsWith("06")); - final pubkey = Uint8List.fromList(hex.decode(publicKeys.first.substring(2))); + final txidBytes = psbt.getInputPreviousTxid(i).reversed.toList(); - inputs.add(TrezorTxInput( - prevHash: hex.encode(psbt.getInputPreviousTxid(i).reversed.toList()), - prevIndex: inputOutputIndex, - amount: inputTx.outputs[inputOutputIndex].amount.toInt(), - addressPath: psbt.getInputBip32Derivation(i, pubkey)!.$2, + final pubkey = _firstDerivationPubkey(psbt.inputMaps[i], "06"); + if (pubkey == null) { + throw StateError("PSBT input $i is missing its BIP32 derivation"); + } + + final derivation = psbt.getInputBip32Derivation(i, pubkey)!; + final path = derivation.$2; + + if (!listEquals(derivation.$1, masterFingerprint)) { + throw Exception("Fingerprint missmatch with the PSBT"); + } + + inputs.add( + sdk.TrezorTxInput( + addressPath: path, + prevHash: txidBytes, + prevIndex: vout, + amount: prevTx.outputs[vout].amount.toInt(), sequence: psbt.getInputSequence(i), - scriptType: "SPENDWITNESS")); + scriptType: "SPENDWITNESS", + ), + ); + + prevTxs[hex.encode(txidBytes)] = sdk.TrezorPrevTx( + meta: sdk.TrezorPrevTxMeta( + version: Uint8List.fromList(prevTx.version).readUint32LE(0), + lockTime: Uint8List.fromList(prevTx.locktime).readUint32LE(0), + inputsCount: prevTx.inputs.length, + outputsCount: prevTx.outputs.length, + ), + inputs: prevTx.inputs + .map( + (e) => sdk.TrezorPrevInput( + prevHash: hex.decode(e.txId), + prevIndex: e.txIndex, + scriptSig: e.scriptSig.toBytes(), + sequence: Uint8List.fromList(e.sequence).readUint32LE(0), + ), + ) + .toList(), + outputs: prevTx.outputs + .map( + (e) => sdk.TrezorPrevOutput( + amount: e.amount.toInt(), + scriptPubkey: e.scriptPubKey.toBytes(), + ), + ) + .toList(), + ); } - final outputs = []; + final outputs = []; final outputCount = psbt.getGlobalOutputCount(); for (var i = 0; i < outputCount; i++) { + final amount = psbt.getOutputAmount(i); + + // An output carrying our own BIP32 derivation is change: identify it + // by path so the device verifies it internally instead of displaying + // it as a payment. + final changePubkey = _firstDerivationPubkey(psbt.outputMaps[i], "02"); + if (changePubkey != null) { + final (fingerprint, path) = psbt.getOutputBip32Derivation(i, changePubkey); + if (listEquals(fingerprint, masterFingerprint)) { + outputs.add(sdk.TrezorTxOutput( + addressPath: path, + amount: amount, + scriptType: "PAYTOWITNESS", + )); + continue; + } + } + final script = Script.fromRaw(byteData: psbt.getOutputScript(i)); - // Trezor's protocol expects script_type: PAYTOADDRESS whenever the - // output is identified by `address` (vs. own-change `addressPath`). - // Suite parses the address string itself to determine the actual - // on-chain script type (P2WPKH, P2TR, P2SH, etc.); script_type is - // only semantically meaningful when addressPath is set. Passing - // PAYTOWITNESS / PAYTOP2SHWITNESS / PAYTOTAPROOT here together with - // `address` causes Suite to reject the deeplink as "Invalid - // parameters from calling app" because those values specifically - // mean "own change paid to that wallet type." - outputs.add(TrezorTxOutput( - amount: psbt.getOutputAmount(i), + outputs.add(sdk.TrezorTxOutput( address: script.toAddress(), + amount: amount, scriptType: "PAYTOADDRESS", - // ToDo: when change-output detection lands, set addressPath + _getScriptType(...) for own-change outputs. - // ToDo: addressPath: psbt.getOutputBip32Derivation(i, pubkey).$2, // To highlight change outputs )); } - final signedTx = await connect.signTransaction(coin: 'btc', inputs: inputs, outputs: outputs); + final signed = await _trezorBitcoin.signTransaction( + inputs: inputs, + outputs: outputs, + prevTxs: prevTxs, + version: psbt.getGlobalTxVersion(), + lockTime: psbt.getGlobalFallbackLocktime() ?? 0, + ); - return Uint8List.fromList(BytesUtils.fromHexString(signedTx!.serializedTx)); + return signed.serializedTx; } @override - Future signMessage({required Uint8List message, String? derivationPath}) async { - final sig = await connect.signMessage(derivationPath ?? "m/84'/0'/0'/0/0", - message: hex.encode(message), hex: true); - return base64Decode(sig!.signature); - } -} - -class LitecoinTrezorService extends HardwareWalletService - with BitcoinHardwareWalletService, LitecoinHardwareWalletService { - LitecoinTrezorService(this.connect); - - final TrezorConnect connect; - - @override - Future> getAvailableAccounts({int index = 0, int limit = 5}) async { - final indexRange = List.generate(limit, (i) => i + index); - final requestParams = []; - final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer; - - for (final i in indexRange) { - final derivationPath = "m/84'/2'/$i'"; - requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC")); - } - - final accounts = await connect.getPublicKeyBundle(requestParams); - - return accounts?.map((account) { - final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion) - .childKey(Bip32KeyIndex(0)); - - final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet); - return HardwareAccountData( - address: address, - xpub: account.xpub, - accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index - derivationPath: account.serializedPath, - ); - }).toList() ?? - []; - } - - @override - Future signLitecoinTransaction({ - required List outputs, - required List inputs, - required Map publicKeys, - }) async { - final readyInputs = inputs - .map((input) => TrezorTxInput( - prevHash: input.utxo.txHash, - prevIndex: input.utxo.vout, - amount: input.utxo.value.toInt(), - addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(), - scriptType: "SPENDWITNESS", - )) - .toList(); - - final readyOutputs = outputs.map((output) { - final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; - - return TrezorTxOutput( - amount: output.toOutput.amount.toInt(), - address: maybeChangePath != null - ? null - : output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet), - scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!), - addressPath: maybeChangePath != null - ? Bip32PathParser.parse(maybeChangePath.derivationPath).toList() - : null, + Future signMessage({required Uint8List message, String? derivationPath}) => + _trezorBitcoin.signMessage( + derivationPath: derivationPath ?? "m/84'/0'/0'/0/0", + message: message, ); - }).toList(); - final signedTx = - await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs); - - return signedTx!.serializedTx; - } -} - -String _getScriptType(BitcoinAddressType addressType) { - switch (addressType) { - case P2pkhAddressType.p2pkh: - return "PAYTOADDRESS"; - case P2shAddressType.p2wpkhInP2sh: - return "PAYTOSCRIPTHASH"; - case SegwitAddresType.p2tr: - return "PAYTOTAPROOT"; - case SegwitAddresType.p2wsh: - return "PAYTOP2SHWITNESS"; - case SegwitAddresType.p2wpkh: - return "PAYTOWITNESS"; - default: - throw Exception("Unknown Address Type"); + /// Extracts the pubkey from the first BIP32-derivation record + /// ([keyTypeHex] `06` for inputs, `02` for outputs) of a PSBT key-value map. + Uint8List? _firstDerivationPubkey(Map map, String keyTypeHex) { + final key = map.keys.where((mapKey) => mapKey.startsWith(keyTypeHex)).firstOrNull; + if (key == null) return null; + return Uint8List.fromList(hex.decode(key.substring(2))); } } diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index aa8fcd330..fbd946233 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -1556,7 +1556,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { @override Future buildHardwareWalletTransaction({ - required List outputs, + required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, @@ -1568,28 +1568,48 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, }) async { final masterFingerprint = - await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint(); + await (hardwareWalletService! as BitcoinHardwareWalletService).getMasterFingerprint(); final readyInputs = []; for (final utxo in utxos) { final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; - readyInputs.add(PSBTReadyUtxoWithAddress( - utxo: utxo.utxo, - rawTx: rawTx, - ownerDetails: utxo.ownerDetails, - ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, - ownerMasterFingerprint: masterFingerprint, - ownerPublicKey: publicKeyAndDerivationPath.publicKey, - )); + readyInputs.add( + PSBTReadyUtxoWithAddress( + utxo: utxo.utxo, + rawTx: rawTx, + ownerDetails: utxo.ownerDetails, + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ownerMasterFingerprint: masterFingerprint, + ownerPublicKey: publicKeyAndDerivationPath.publicKey, + ), + ); } - final orderedOutputs = orderOutputs(outputs, outputOrdering); + final orderedOutputs = + orderOutputs(outputs, outputOrdering).map((o) { + if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) { + final changeKey = publicKeys[o.address.pubKeyHash()]!; + return PSBTReadyBitcoinOutput( + address: o.address, + value: o.value, + isSilentPayment: o.isSilentPayment, + isChange: o.isChange, + changeMasterFingerprint: masterFingerprint, + changeDerivationPath: changeKey.derivationPath, + changePublicKey: changeKey.publicKey, + ); + } + return PSBTReadyBitcoinOutput.fromOutput(o); + }); - final rawHex = await (hardwareWalletService as LitecoinHardwareWalletService) - .signLitecoinTransaction( - outputs: orderedOutputs, inputs: readyInputs, publicKeys: publicKeys); + final rawHex = + await (hardwareWalletService! as LitecoinHardwareWalletService).signLitecoinTransaction( + outputs: orderedOutputs.toList(), + inputs: readyInputs, + publicKeys: publicKeys, + ); return BtcTransaction.fromRaw(rawHex); } diff --git a/cw_bitcoin/lib/psbt/transaction_builder.dart b/cw_bitcoin/lib/psbt/transaction_builder.dart index ab107bfeb..5eea75794 100644 --- a/cw_bitcoin/lib/psbt/transaction_builder.dart +++ b/cw_bitcoin/lib/psbt/transaction_builder.dart @@ -1,21 +1,19 @@ -import 'dart:convert'; -import 'dart:typed_data'; +import "dart:convert"; +import "dart:typed_data"; -import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:convert/convert.dart'; -import 'package:cw_core/output_info.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:ledger_bitcoin/psbt.dart'; +import "package:bitcoin_base/bitcoin_base.dart"; +import "package:convert/convert.dart"; +import "package:cw_core/output_info.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:ledger_bitcoin/psbt.dart"; class PSBTTransactionBuild { - final PsbtV2 psbt = PsbtV2(); - - PSBTTransactionBuild( - {required List inputs, - required List outputs, - required List cwOutputs, - bool enableRBF = true, - int locktime = 0}) { + PSBTTransactionBuild({ + required List inputs, + required List outputs, + bool enableRBF = true, + int locktime = 0, + }) { psbt.setGlobalTxVersion(2); psbt.setGlobalFallbackLocktime(locktime); psbt.setGlobalInputCount(inputs.length); @@ -24,12 +22,10 @@ class PSBTTransactionBuild { for (var i = 0; i < inputs.length; i++) { final input = inputs[i]; - printV(input.utxo.isP2tr()); - printV(input.utxo.isSegwit()); - printV(input.utxo.isP2shSegwit()); - psbt.setInputPreviousTxId( - i, Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList())); + i, + Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList()), + ); psbt.setInputOutputIndex(i, input.utxo.vout); psbt.setInputSequence(i, enableRBF ? 0xfffffffd : 0xffffffff); @@ -47,75 +43,98 @@ class PSBTTransactionBuild { for (var i = 0; i < outputs.length; i++) { final output = outputs[i]; - if (output is BitcoinOutput) { - psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes())); - psbt.setOutputAmount(i, output.value.toInt()); - if (cwOutputs.isNotEmpty) { - try { - final cwOutput = cwOutputs - .where((e) => [e.address, e.extractedAddress] - .map((e) => e?.toLowerCase()) - .contains(output.address.toAddress().toLowerCase())) - .firstOrNull; - if (cwOutput != null && - cwOutput.extra.containsKey('bip353_name') && - cwOutput.extra.containsKey('bip353_proof')) { - final bip353Name = utf8.encode(cwOutput.extra['bip353_name'] as String); - final bip353Proof = base64.decode(cwOutput.extra['bip353_proof'] as String); + psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes())); + psbt.setOutputAmount(i, output.value.toInt()); - if (bip353Name.length > 255) { - printV('BIP353 name is too long, skipping'); - continue; - } - final proof = Uint8List.fromList([ - bip353Name.length, - ...bip353Name, - ...bip353Proof, - ]); + if (output.isChange && + output.changeDerivationPath != null && + output.changePublicKey != null && + output.changeMasterFingerprint != null) { + psbt.setOutputBip32Derivation( + i, + Uint8List.fromList(hex.decode(output.changePublicKey!)), + output.changeMasterFingerprint!, + BIPPath.fromString(output.changeDerivationPath!).toPathArray(), + ); + } - psbt.setOutputDNSSECProof(i, proof); + if (output.outputInfo != null) { + try { + final cwOutput = output.outputInfo!; + if (cwOutput.extra.containsKey("bip353_name") && + cwOutput.extra.containsKey("bip353_proof")) { + final bip353Name = utf8.encode(cwOutput.extra["bip353_name"] as String); + final bip353Proof = base64.decode(cwOutput.extra["bip353_proof"] as String); + + if (bip353Name.length > 255) { + printV("BIP353 name is too long, skipping"); + continue; } - } catch (e) { - printV('Error setting DNSSEC proof: $e'); + final proof = Uint8List.fromList([ + bip353Name.length, + ...bip353Name, + ...bip353Proof, + ]); + + psbt.setOutputDNSSECProof(i, proof); } + } catch (e) { + printV("Error setting DNSSEC proof: $e"); } } } } + final PsbtV2 psbt = PsbtV2(); + void setInputP2pkh(int i, PSBTReadyUtxoWithAddress input) { psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx))); - psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)), - input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray()); + psbt.setInputBip32Derivation( + i, + Uint8List.fromList(hex.decode(input.ownerPublicKey)), + input.ownerMasterFingerprint, + BIPPath.fromString(input.ownerDerivationPath).toPathArray(), + ); } void setInputSegwit(int i, PSBTReadyUtxoWithAddress input) { psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx))); - psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)), - input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray()); + psbt.setInputBip32Derivation( + i, + Uint8List.fromList(hex.decode(input.ownerPublicKey)), + input.ownerMasterFingerprint, + BIPPath.fromString(input.ownerDerivationPath).toPathArray(), + ); - psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), - Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes())); + psbt.setInputWitnessUtxo( + i, + Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), + Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()), + ); } void setInputP2shSegwit(int i, PSBTReadyUtxoWithAddress input) { psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx))); - psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)), - input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray()); + psbt.setInputBip32Derivation( + i, + Uint8List.fromList(hex.decode(input.ownerPublicKey)), + input.ownerMasterFingerprint, + BIPPath.fromString(input.ownerDerivationPath).toPathArray(), + ); psbt.setInputRedeemScript( - i, Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes())); - psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), - Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes())); + i, + Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()), + ); + psbt.setInputWitnessUtxo( + i, + Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), + Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()), + ); } } class PSBTReadyUtxoWithAddress extends UtxoWithAddress { - final String rawTx; - final String ownerDerivationPath; - final Uint8List ownerMasterFingerprint; - final String ownerPublicKey; - PSBTReadyUtxoWithAddress({ required super.utxo, required this.rawTx, @@ -124,4 +143,34 @@ class PSBTReadyUtxoWithAddress extends UtxoWithAddress { required this.ownerMasterFingerprint, required this.ownerPublicKey, }); + + final String rawTx; + final String ownerDerivationPath; + final Uint8List ownerMasterFingerprint; + final String ownerPublicKey; +} + +class PSBTReadyBitcoinOutput extends BitcoinOutput { + PSBTReadyBitcoinOutput({ + required super.address, + required super.value, + super.isSilentPayment = false, + super.isChange = false, + this.changeDerivationPath, + this.changeMasterFingerprint, + this.changePublicKey, + this.outputInfo, + }); + + factory PSBTReadyBitcoinOutput.fromOutput(BitcoinOutput output) => PSBTReadyBitcoinOutput( + address: output.address, + value: output.value, + isSilentPayment: output.isSilentPayment, + isChange: output.isChange, + ); + + final String? changeDerivationPath; + final Uint8List? changeMasterFingerprint; + final String? changePublicKey; + final OutputInfo? outputInfo; } diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index 3eb747a72..922a83f1d 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -55,6 +55,11 @@ dependencies: git: url: https://github.com/cake-tech/trezor_connect ref: d1242cea90f84b00200e7bcab914a5af750e23fb + trezor_flutter: + git: + url: https://github.com/cake-tech/trezor-flutter + ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 + path: trezor-flutter bitbox_flutter: path: ../scripts/bitbox_flutter socks_socket: diff --git a/cw_monero/pubspec.yaml b/cw_monero/pubspec.yaml index 04d3f3997..4e3196955 100644 --- a/cw_monero/pubspec.yaml +++ b/cw_monero/pubspec.yaml @@ -32,8 +32,8 @@ dependencies: ledger_flutter_plus: ^1.4.1 trezor_flutter: git: - url: https://github.com/cake-tech/trezor-flutter.git - ref: 7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97 + url: https://github.com/cake-tech/trezor-flutter + ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 path: trezor-flutter dev_dependencies: diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index c2343dfe1..4e4789e57 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -558,9 +558,9 @@ class CWBitcoin extends Bitcoin { @override HardwareWalletService getTrezorHardwareWalletService( - trezor.TrezorConnect connect, bool isBitcoin) { - if (isBitcoin) return BitcoinTrezorService(connect); - return LitecoinTrezorService(connect); + trezor.TrezorConnect? connect, trezor.TrezorClient? client, bool isBitcoin) { + if (isBitcoin) return BitcoinTrezorService(client!); + return LitecoinTrezorService(connect!); } @override diff --git a/lib/router.dart b/lib/router.dart index 2b01b079f..643a03cde 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -361,9 +361,10 @@ Route createRoute(RouteSettings settings) { ConnectDevicePageParams( walletType: availableWalletTypes.first, hardwareWalletType: hardwareWalletType, - onConnectDevice: (BuildContext context, _) => Navigator.of(context).pushNamed( - Routes.chooseHardwareWalletAccount, - arguments: [availableWalletTypes.first, hardwareWalletType]), + onConnectDevice: (context, _) => Navigator.of(context).pushNamed( + Routes.chooseHardwareWalletAccount, + arguments: [availableWalletTypes.first, hardwareWalletType], + ), isReconnect: false, ), getIt.get(param1: hardwareWalletType), @@ -373,19 +374,23 @@ Route createRoute(RouteSettings settings) { return handleRouteWithPlatformAwareness( (_) => getIt.get( param1: NewWalletTypeArguments( - onTypeSelected: (BuildContext context, WalletType type) { - if (hardwareWalletType == HardwareWalletType.trezor && type != WalletType.monero) { - Navigator.of(context).pushNamed(Routes.chooseHardwareWalletAccount, - arguments: [type, hardwareWalletType]); + onTypeSelected: (context, type) { + if (hardwareWalletType == HardwareWalletType.trezor && + !trezorUseNative.contains(type)) { + Navigator.of(context).pushNamed( + Routes.chooseHardwareWalletAccount, + arguments: [type, hardwareWalletType], + ); return; } final arguments = ConnectDevicePageParams( walletType: type, hardwareWalletType: hardwareWalletType, - onConnectDevice: (BuildContext context, _) => Navigator.of(context).pushNamed( - Routes.chooseHardwareWalletAccount, - arguments: [type, hardwareWalletType]), + onConnectDevice: (context, _) => Navigator.of(context).pushNamed( + Routes.chooseHardwareWalletAccount, + arguments: [type, hardwareWalletType], + ), isReconnect: false, ); diff --git a/lib/view_model/hardware_wallet/trezor_connect_view_model.dart b/lib/view_model/hardware_wallet/trezor_connect_view_model.dart index 4d65e4d53..6e23881d8 100644 --- a/lib/view_model/hardware_wallet/trezor_connect_view_model.dart +++ b/lib/view_model/hardware_wallet/trezor_connect_view_model.dart @@ -29,6 +29,8 @@ import "package:trezor_flutter/trezor_flutter.dart" as sdk; part "trezor_connect_view_model.g.dart"; +const trezorUseNative = [WalletType.bitcoin, WalletType.monero]; + class TrezorConnectViewModel = TrezorConnectViewModelBase with _$TrezorConnectViewModel; abstract class TrezorConnectViewModelBase extends HardwareWalletViewModel with Store { @@ -242,7 +244,7 @@ abstract class TrezorConnectViewModelBase extends HardwareWalletViewModel with S } @override - bool isConnected(WalletType type) => type == WalletType.monero + bool isConnected(WalletType type) => trezorUseNative.contains(type) ? _client != null && _client?.connection.isDisconnected == false : true; @@ -252,9 +254,9 @@ abstract class TrezorConnectViewModelBase extends HardwareWalletViewModel with S case WalletType.monero: return monero!.getTrezorHardwareWalletService(_client!); case WalletType.bitcoin: - return bitcoin!.getTrezorHardwareWalletService(trezorConnect, true); + return bitcoin!.getTrezorHardwareWalletService(null, _client!, true); case WalletType.litecoin: - return bitcoin!.getTrezorHardwareWalletService(trezorConnect, false); + return bitcoin!.getTrezorHardwareWalletService(trezorConnect, null, false); case WalletType.ethereum: case WalletType.polygon: return evm!.getTrezorHardwareWalletService(trezorConnect); diff --git a/lib/view_model/wallet_hardware_restore_view_model.dart b/lib/view_model/wallet_hardware_restore_view_model.dart index 35de089c8..4d7a48315 100644 --- a/lib/view_model/wallet_hardware_restore_view_model.dart +++ b/lib/view_model/wallet_hardware_restore_view_model.dart @@ -56,7 +56,7 @@ abstract class WalletHardwareRestoreViewModelBase extends WalletCreationVM with availableAccounts.addAll(accounts); _nextIndex += limit; - } catch (e) { + } on Exception catch (e) { printV(e); error = hardwareWalletVM.interpretErrorCode(e.toString()) ?? S.current.ledger_connection_error; diff --git a/pubspec_overrides.yaml b/pubspec_overrides.yaml index 90cceec37..15910a4f2 100644 --- a/pubspec_overrides.yaml +++ b/pubspec_overrides.yaml @@ -1480,13 +1480,13 @@ dependency_overrides: ref: d1242cea90f84b00200e7bcab914a5af750e23fb trezor_flutter: git: - url: https://github.com/cake-tech/trezor-flutter.git - ref: 7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97 + url: https://github.com/cake-tech/trezor-flutter + ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 path: ./trezor-flutter trezor_usb_transport: git: - url: https://github.com/cake-tech/trezor-flutter.git - ref: 7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97 + url: https://github.com/cake-tech/trezor-flutter + ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 path: ./trezor_usb_transport tuple: git: diff --git a/tool/configure.dart b/tool/configure.dart index 2f170465c..e30bb6e9d 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -127,6 +127,7 @@ import 'package:hive/hive.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart' as ledger; import 'package:bitbox_flutter/bitbox_flutter.dart' as bitbox; import 'package:trezor_connect/trezor_connect.dart' as trezor; +import 'package:trezor_flutter/trezor_flutter.dart' as trezor; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:bip39/bip39.dart' as bip39; import 'package:collection/collection.dart'; @@ -156,6 +157,7 @@ import 'package:cw_bitcoin/litecoin_wallet.dart'; import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart'; import 'package:cw_bitcoin/hardware/litecoin_ledger_service.dart'; import 'package:cw_bitcoin/hardware/bitbox_service.dart'; +import 'package:cw_bitcoin/hardware/litecoin_trezor_service.dart'; import 'package:cw_bitcoin/hardware/trezor_service.dart'; import 'package:mobx/mobx.dart'; import "package:breez_sdk_spark_flutter/src/rust/errors.dart"; @@ -275,7 +277,7 @@ abstract class Bitcoin { Future setHardwareWalletService(WalletBase wallet, HardwareWalletService service); HardwareWalletService getLedgerHardwareWalletService(ledger.LedgerConnection connection, bool isBitcoin); HardwareWalletService getBitboxHardwareWalletService(bitbox.BitboxManager manager, bool isBitcoin); - HardwareWalletService getTrezorHardwareWalletService(trezor.TrezorConnect connect, bool isBitcoin); + HardwareWalletService getTrezorHardwareWalletService(trezor.TrezorConnect? connect, trezor.TrezorClient? client, bool isBitcoin); List updateOutputs(PendingTransaction pendingTransaction, List outputs); bool txIsReceivedSilentPayment(TransactionInfo txInfo); bool txIsMweb(TransactionInfo txInfo); From 5a497bf05aebe2488c91a51fb8a041083d24c3e0 Mon Sep 17 00:00:00 2001 From: cyan Date: Wed, 2 Sep 2026 18:17:28 +0800 Subject: [PATCH 2/7] Monero: keep the sync progress redrawing during wallet2's warm-up (#3575) * Monero: keep the sync progress redrawing during wallet2's warm-up * fix: double call in getNodeHeight() fix: error handling in _refreshNodeHeight --------- Co-authored-by: Vikrant Sharma --- cw_monero/lib/api/wallet.dart | 64 +++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/cw_monero/lib/api/wallet.dart b/cw_monero/lib/api/wallet.dart index 787933217..7e094235a 100644 --- a/cw_monero/lib/api/wallet.dart +++ b/cw_monero/lib/api/wallet.dart @@ -165,23 +165,44 @@ int getCurrentHeight() => currentWallet?.blockChainHeight() ?? 0; int cachedNodeHeight = 0; bool isHeightRefreshing = false; + +Future? _nodeHeightInFlight; + +Future _refreshNodeHeight() { + final inFlight = _nodeHeightInFlight; + if (inFlight != null) return inFlight; + + try { + final wallet = currentWallet; + if (wallet == null) return Future.value(cachedNodeHeight); + + final wptrAddress = wallet.ffiAddress(); + isHeightRefreshing = true; + final f = Isolate.run(() async { + return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress)); + }).then((h) { + cachedNodeHeight = h; + return h; + }).catchError((_) => cachedNodeHeight).whenComplete(() { + isHeightRefreshing = false; + _nodeHeightInFlight = null; + }); + _nodeHeightInFlight = f; + return f; + } catch (e) { + printV("error refreshing node height: $e"); + isHeightRefreshing = false; + _nodeHeightInFlight = null; + return Future.value(cachedNodeHeight); + } +} + int getNodeHeightSync() { if (isHeightRefreshing == false) { if (cachedNodeHeight != 0 && getWlptr()?.height() == 1) { return cachedNodeHeight; } - (() async { - try { - isHeightRefreshing = true; - final wptrAddress = currentWallet!.ffiAddress(); - cachedNodeHeight = await Isolate.run(() async { - return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress)); - }); - } catch (_) { - } finally { - isHeightRefreshing = false; - } - })(); + unawaited(_refreshNodeHeight()); } return cachedNodeHeight; } @@ -319,6 +340,9 @@ class SyncListener { int _cachedBlockchainHeight; int _lastKnownBlockHeight; int _initialSyncHeight; + int _lastKnownNodeHeight = 0; + int _ticksSinceEmit = 0; + static const int _heartbeatTicks = 5; Future getNodeHeightOrUpdate(int baseHeight) async { if (_cachedBlockchainHeight < baseHeight || _cachedBlockchainHeight == 0) { @@ -331,6 +355,8 @@ class SyncListener { void _start() { _cachedBlockchainHeight = 0; _lastKnownBlockHeight = 0; + _lastKnownNodeHeight = 0; + _ticksSinceEmit = 0; _initialSyncHeight = 0; _updateSyncInfoTimer ??= Timer.periodic(Duration(milliseconds: 1200), (_) async { if (isNewTransactionExist()) { @@ -355,11 +381,17 @@ class SyncListener { } final bchHeight = max(nodeHeight, syncHeight); // printV("syncHeight: $syncHeight, _lastKnownBlockHeight: $_lastKnownBlockHeight, bchHeight: $bchHeight"); - if (_lastKnownBlockHeight == syncHeight) { + + _ticksSinceEmit++; + final nothingChanged = _lastKnownBlockHeight == syncHeight && + _lastKnownNodeHeight == nodeHeight; + if (nothingChanged && _ticksSinceEmit < _heartbeatTicks) { return; } + _ticksSinceEmit = 0; _lastKnownBlockHeight = syncHeight; + _lastKnownNodeHeight = nodeHeight; final track = bchHeight - _initialSyncHeight; final diff = track - (bchHeight - syncHeight); final ptc = diff <= 0 ? 0.0 : diff / track; @@ -410,7 +442,11 @@ void startRefresh() => startRefreshSync(); Future store() async => _storeSync(0); -Future getNodeHeight() async => getNodeHeightSync(); +Future getNodeHeight() async { + final cached = getNodeHeightSync(); + if (cached != 0) return cached; + return _refreshNodeHeight(); +} void rescanBlockchainAsync() => currentWallet!.rescanBlockchainAsync(); From 1f3b261e3d97300b6a1307756b306648c74bfba7 Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:30:28 +0100 Subject: [PATCH 3/7] CW 1515 Contacts List Out Of Sync (#3550) * fix android CI * fix: address book wallet contacts tab not updating after wallet changes * feat: add page open listener for contact list and handle concurrent calls to fetch contact list --- lib/new-ui/new_dashboard.dart | 4 + lib/new-ui/page_open_listener.dart | 3 + .../screens/contact/contact_list_page.dart | 10 +- .../contact_list/contact_list_view_model.dart | 141 +++++++++++------- 4 files changed, 99 insertions(+), 59 deletions(-) create mode 100644 lib/new-ui/page_open_listener.dart diff --git a/lib/new-ui/new_dashboard.dart b/lib/new-ui/new_dashboard.dart index 9c9294648..76917cce5 100644 --- a/lib/new-ui/new_dashboard.dart +++ b/lib/new-ui/new_dashboard.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; +import "package:cake_wallet/new-ui/page_open_listener.dart"; import 'package:cake_wallet/new-ui/pages/home_page.dart'; import 'package:cake_wallet/new-ui/widgets/changelog_modal.dart'; import 'package:cake_wallet/src/screens/contact/contact_list_page.dart'; @@ -112,6 +113,9 @@ class _NewDashboardState extends State { setState(() { _selectedPage = index; }); + if (widget.dashboardPageWidgets[_selectedPage] case PageOpenListener page) { + page.onPageOpen(); + } }, ) ], diff --git a/lib/new-ui/page_open_listener.dart b/lib/new-ui/page_open_listener.dart new file mode 100644 index 000000000..7914869ee --- /dev/null +++ b/lib/new-ui/page_open_listener.dart @@ -0,0 +1,3 @@ +abstract interface class PageOpenListener { + void onPageOpen(); +} diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 79892062e..e8a336fa8 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -1,9 +1,12 @@ +import "dart:async"; + import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/entities/contact_base.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/wallet_contact.dart'; import 'package:cake_wallet/entities/wallet_list_order_types.dart'; import 'package:cake_wallet/generated/i18n.dart'; +import "package:cake_wallet/new-ui/page_open_listener.dart"; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart'; @@ -26,7 +29,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -class ContactListPage extends BasePage { +class ContactListPage extends BasePage implements PageOpenListener { ContactListPage(this.contactListViewModel, this.authService, {this.showAddButton = false}); final ContactListViewModel contactListViewModel; @@ -89,6 +92,11 @@ class ContactListPage extends BasePage { ); } + @override + void onPageOpen() { + unawaited(contactListViewModel.loadWalletContacts()); + } + @override Widget body(BuildContext context) => ContactPageBody(contactListViewModel: contactListViewModel); } diff --git a/lib/view_model/contact_list/contact_list_view_model.dart b/lib/view_model/contact_list/contact_list_view_model.dart index 62db91143..19425242b 100644 --- a/lib/view_model/contact_list/contact_list_view_model.dart +++ b/lib/view_model/contact_list/contact_list_view_model.dart @@ -14,6 +14,7 @@ import 'package:cake_wallet/utils/mobx.dart'; import 'package:cake_wallet/utils/token_utilities.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency_for_wallet_type.dart'; +import "package:cw_core/utils/print_verbose.dart"; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:hive/hive.dart'; @@ -33,79 +34,103 @@ abstract class ContactListViewModelBase with Store { } Future _init() async { - final walletInfos = await WalletInfo.getAll(); - for (final info in walletInfos) { - final addressInfos = await info.getAddressInfos(); - final addresses = await info.getAddresses(); - if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) && - addressInfos.isNotEmpty) { - for (var key in addressInfos.keys) { - final value = addressInfos[key]; - final address = value?.first; - if (address != null) { - final name = _createName(info.name, address.label, key: key); - walletContacts.add(WalletContact( - address.address, + await loadWalletContacts(); + + _subscription = contactSource.bindToListWithTransform( + contacts, + (contact) => ContactRecord(contactSource, contact), + initialFire: true, + ); + + setOrderType(settingsStore.contactListOrder); + } + + Future _lastWalletContactsLoad = Future.value(); + + @action + Future loadWalletContacts() async { + final waitFor = _lastWalletContactsLoad; + final done = Completer(); + _lastWalletContactsLoad = done.future; + + await waitFor; + + try { + final loadedContacts = []; + final walletInfos = await WalletInfo.getAll(); + for (final info in walletInfos) { + final addressInfos = await info.getAddressInfos(); + final addresses = await info.getAddresses(); + if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) && + addressInfos.isNotEmpty) { + for (var key in addressInfos.keys) { + final value = addressInfos[key]; + final address = value?.first; + if (address != null) { + final name = _createName(info.name, address.label, key: key); + loadedContacts.add(WalletContact( + address.address, + name, + getCryptoCurrencyForWalletListItem( + info.type, + ), + walletType: info.type, + )); + } + } + } else if (addresses.isNotEmpty == true && addresses.length > 1) { + if ([WalletType.monero, WalletType.wownero, WalletType.haven, WalletType.decred] + .contains(info.type)) { + final address = info.address; + final name = _createName(info.name, "", key: 0); + loadedContacts.add(WalletContact( + address, name, getCryptoCurrencyForWalletListItem( info.type, ), walletType: info.type, )); + } else { + addresses.forEach((address, label) { + if (label.isEmpty) { + return; + } + final name = _createName(info.name, label, key: null); + loadedContacts.add(WalletContact( + address, + name, + getCryptoCurrencyForWalletListItem( + info.type, + isTestnet: info.network == null + ? false + : info.network!.toLowerCase().contains("testnet"), + ), + walletType: info.type, + )); + }); } - } - } else if (addresses.isNotEmpty == true && addresses.length > 1) { - if ([WalletType.monero, WalletType.wownero, WalletType.haven, WalletType.decred] - .contains(info.type)) { - final address = info.address; - final name = _createName(info.name, "", key: 0); - walletContacts.add(WalletContact( - address, - name, + } else { + loadedContacts.add(WalletContact( + info.address, + _createName(info.name, "", + key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) + ? 0 + : null), getCryptoCurrencyForWalletListItem( info.type, ), walletType: info.type, )); - } else { - addresses.forEach((address, label) { - if (label.isEmpty) { - return; - } - final name = _createName(info.name, label, key: null); - walletContacts.add(WalletContact( - address, - name, - getCryptoCurrencyForWalletListItem( - info.type, - isTestnet: - info.network == null ? false : info.network!.toLowerCase().contains("testnet"), - ), - walletType: info.type, - )); - }); } - } else { - walletContacts.add(WalletContact( - info.address, - _createName(info.name, "", - key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) - ? 0 - : null), - getCryptoCurrencyForWalletListItem( - info.type, - ), - walletType: info.type, - )); } + + walletContacts = loadedContacts; + } catch (e) { + printV("loadWalletContacts failed: $e"); + } finally { + done.complete(); } - - _subscription = contactSource.bindToListWithTransform( - contacts, (Contact contact) => ContactRecord(contactSource, contact), - initialFire: true); - - setOrderType(settingsStore.contactListOrder); - walletContacts = walletContacts.toList(); // rebuild } String _createName(String walletName, String label, {int? key = null}) { From 68eb1c3dedca07f4c92696cf1e0498575b9e4f98 Mon Sep 17 00:00:00 2001 From: malik1004x Date: Wed, 2 Sep 2026 13:11:57 +0200 Subject: [PATCH 4/7] auto-select custom node on wallet creation (#3592) * auto-select custom node on wallet creation * safeguard for wallet type --- lib/reactions/on_current_node_change.dart | 8 ++++++-- .../new_wallet/advanced_privacy_settings_page.dart | 7 ++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/reactions/on_current_node_change.dart b/lib/reactions/on_current_node_change.dart index 4b49d05ad..8b67674a6 100644 --- a/lib/reactions/on_current_node_change.dart +++ b/lib/reactions/on_current_node_change.dart @@ -8,14 +8,18 @@ void startOnCurrentNodeChangeReaction(AppStore appStore) { _onCurrentNodeChangeReaction?.reaction.dispose(); appStore.settingsStore.nodes.observe((change) async { try { - await appStore.wallet!.connectToNode(node: change.newValue!); + if(change.newValue?.type == appStore.wallet!.type) { + await appStore.wallet!.connectToNode(node: change.newValue!); + } } catch (e) { printV(e.toString()); } }); appStore.settingsStore.powNodes.observe((change) async { try { - await appStore.wallet!.connectToPowNode(node: change.newValue!); + if(change.newValue?.type == appStore.wallet!.type) { + await appStore.wallet!.connectToPowNode(node: change.newValue!); + } } catch (e) { printV(e.toString()); } diff --git a/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart b/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart index 2f190c857..a39235b89 100644 --- a/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart +++ b/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart @@ -343,13 +343,14 @@ class _AdvancedPrivacySettingsBodyState extends State<_AdvancedPrivacySettingsBo ), const SizedBox(height: 24), LoadingPrimaryButton( - onPressed: () { + onPressed: () async { if (widget.privacySettingsViewModel.addCustomNode) { if (_formKey.currentState != null && !_formKey.currentState!.validate()) { return; } - widget.nodeViewModel.save(); + await widget.nodeViewModel.save(); + widget.nodeViewModel.setAsCurrent(widget.nodeViewModel.editingNode!); } if (testnetValue == true && widget.privacySettingsViewModel.type == WalletType.bitcoin) { @@ -358,7 +359,7 @@ class _AdvancedPrivacySettingsBodyState extends State<_AdvancedPrivacySettingsBo widget.nodeViewModel.address = publicBitcoinTestnetElectrumAddress; widget.nodeViewModel.port = publicBitcoinTestnetElectrumPort; - widget.nodeViewModel.save(); + await widget.nodeViewModel.save(); } if (_passphraseFormKey.currentState != null && !_passphraseFormKey.currentState!.validate()) { From 87ba3838ae154d21cb01e3565c5f052e0c394029 Mon Sep 17 00:00:00 2001 From: malik1004x Date: Wed, 2 Sep 2026 19:48:53 +0200 Subject: [PATCH 5/7] disable mweb ad (#3593) --- lib/view_model/dashboard/dashboard_view_model.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 994121a90..b19bf1239 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -614,6 +614,7 @@ abstract class DashboardViewModelBase with Store { @computed bool get shouldShowMwebAd { + return false; if (wallet.type != WalletType.litecoin) return false; if (mwebEnabled) return false; From ea4d752914f75b04a233f7b92264b2ff9cb07ba9 Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:54:11 +0100 Subject: [PATCH 6/7] 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 --- cw_core/lib/db/sqlite.dart | 27 +- cw_core/lib/exceptions.dart | 2 + cw_core/lib/imported_nft.dart | 138 +++++ cw_core/lib/utils/ipfs_url.dart | 25 + cw_core/lib/utils/nft_text.dart | 25 + cw_core/lib/wallet_service.dart | 6 + cw_core/test/imported_nft_test.dart | 176 +++++++ cw_core/test/ipfs_url_test.dart | 55 ++ cw_core/test/nft_text_test.dart | 89 ++++ cw_evm/lib/evm_chain_wallet_service.dart | 12 + cw_solana/lib/solana_client.dart | 275 +++++++++- cw_solana/lib/solana_exceptions.dart | 2 + cw_solana/lib/solana_wallet.dart | 32 ++ cw_solana/lib/solana_wallet_service.dart | 2 + lib/di.dart | 4 + lib/entities/saved_nfts.dart | 116 ++++ lib/entities/solana_nft_asset_model.dart | 87 +-- lib/entities/wallet_nft_response.dart | 37 +- lib/monero/cw_monero.dart | 17 + lib/new-ui/pages/home_page.dart | 15 +- lib/router.dart | 14 +- lib/routes.dart | 1 + lib/solana/cw_solana.dart | 34 ++ lib/src/screens/dashboard/dashboard_page.dart | 6 +- .../dashboard/pages/nft_details_page.dart | 172 ++++-- .../dashboard/pages/nft_import_page.dart | 2 +- .../dashboard/pages/nft_listing_page.dart | 43 +- .../dashboard/pages/nft_send_page.dart | 375 +++++++++++++ .../widgets/solana_nft_tile_widget.dart | 182 ++++--- .../dashboard/nft_send_view_model.dart | 132 +++++ lib/view_model/dashboard/nft_view_model.dart | 494 ++++++++++++++---- .../dev/monero_background_sync.dart | 34 +- lib/view_model/wallet_keys_view_model.dart | 5 +- pubspec_base.yaml | 5 + res/values/strings_ar.arb | 5 + res/values/strings_bg.arb | 5 + res/values/strings_cs.arb | 5 + res/values/strings_de.arb | 5 + res/values/strings_en.arb | 5 + res/values/strings_es.arb | 5 + res/values/strings_fa.arb | 5 + res/values/strings_fr.arb | 5 + res/values/strings_gn.arb | 5 + res/values/strings_ha.arb | 5 + res/values/strings_hi.arb | 5 + res/values/strings_hr.arb | 5 + res/values/strings_hy.arb | 5 + res/values/strings_id.arb | 5 + res/values/strings_it.arb | 9 +- res/values/strings_ja.arb | 9 +- res/values/strings_ko.arb | 9 +- res/values/strings_my.arb | 9 +- res/values/strings_nl.arb | 9 +- res/values/strings_pl.arb | 9 +- res/values/strings_pt.arb | 9 +- res/values/strings_pt_BR.arb | 5 + res/values/strings_ru.arb | 9 +- res/values/strings_th.arb | 9 +- res/values/strings_tl.arb | 9 +- res/values/strings_tr.arb | 9 +- res/values/strings_uk.arb | 9 +- res/values/strings_ur.arb | 9 +- res/values/strings_vi.arb | 9 +- res/values/strings_yo.arb | 9 +- res/values/strings_zh.arb | 9 +- test/entities/saved_nfts_test.dart | 225 ++++++++ .../entities/solana_nft_asset_model_test.dart | 107 ++++ tool/configure.dart | 34 +- 68 files changed, 2830 insertions(+), 396 deletions(-) create mode 100644 cw_core/lib/imported_nft.dart create mode 100644 cw_core/lib/utils/ipfs_url.dart create mode 100644 cw_core/lib/utils/nft_text.dart create mode 100644 cw_core/test/imported_nft_test.dart create mode 100644 cw_core/test/ipfs_url_test.dart create mode 100644 cw_core/test/nft_text_test.dart create mode 100644 lib/entities/saved_nfts.dart create mode 100644 lib/src/screens/dashboard/pages/nft_send_page.dart create mode 100644 lib/view_model/dashboard/nft_send_view_model.dart create mode 100644 test/entities/saved_nfts_test.dart create mode 100644 test/entities/solana_nft_asset_model_test.dart diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index 0deffab8e..1c8ade037 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -63,7 +63,7 @@ Future _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 _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 _createTronTokenTable(Database db) async { await db.execute(""" CREATE TABLE IF NOT EXISTS TronToken ( diff --git a/cw_core/lib/exceptions.dart b/cw_core/lib/exceptions.dart index 76d4782ad..b64a4115e 100644 --- a/cw_core/lib/exceptions.dart +++ b/cw_core/lib/exceptions.dart @@ -55,6 +55,8 @@ class SignSPLTokenTransactionRentException implements Exception {} class NoAssociatedTokenAccountException implements Exception {} +class NotAnNFTException implements Exception {} + class AmbiguousTokenSymbolException implements Exception { AmbiguousTokenSymbolException(this.symbol); diff --git a/cw_core/lib/imported_nft.dart b/cw_core/lib/imported_nft.dart new file mode 100644 index 000000000..2284f680f --- /dev/null +++ b/cw_core/lib/imported_nft.dart @@ -0,0 +1,138 @@ +import "package:cw_core/db/sqlite.dart"; +import "package:sqflite/sqflite.dart"; + +class ImportedNFT { + ImportedNFT({ + required this.walletName, + required this.chain, + required this.identifier, + this.name, + this.symbol, + this.description, + this.imageUrl, + this.isOwned, + this.id = 0, + }); + + ImportedNFT.copyWith(ImportedNFT other, {String? walletName}) + : id = 0, + walletName = walletName ?? other.walletName, + chain = other.chain, + identifier = other.identifier, + name = other.name, + symbol = other.symbol, + description = other.description, + imageUrl = other.imageUrl, + isOwned = other.isOwned; + + factory ImportedNFT.fromMap(Map 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 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 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 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> 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 deleteOne(String walletName, String chain, String identifier) => db!.delete( + tableName, + where: "walletName = ? AND chain = ? AND identifier = ?", + whereArgs: [walletName, chain, identifier], + ); + + static String _scope(List? chains) => + chains == null ? "" : " AND chain IN (${List.filled(chains.length, "?").join(", ")})"; + + static Future deleteAllForWallet(String walletName, {List? chains}) async { + if (chains != null && chains.isEmpty) { + return 0; + } + + return db!.delete( + tableName, + where: "walletName = ?${_scope(chains)}", + whereArgs: [walletName, ...?chains], + ); + } + + static Future renameWallet( + String oldName, + String newName, { + List? chains, + }) async { + if (chains != null && chains.isEmpty) { + return; + } + + await db!.delete( + tableName, + where: "walletName = ?${_scope(chains)}", + whereArgs: [newName, ...?chains], + ); + await db!.update( + tableName, + {"walletName": newName}, + where: "walletName = ?${_scope(chains)}", + whereArgs: [oldName, ...?chains], + ); + } +} diff --git a/cw_core/lib/utils/ipfs_url.dart b/cw_core/lib/utils/ipfs_url.dart new file mode 100644 index 000000000..f5c320aea --- /dev/null +++ b/cw_core/lib/utils/ipfs_url.dart @@ -0,0 +1,25 @@ +const _ipfsGateway = "https://ipfs.io/ipfs/"; +const _ipfsScheme = "ipfs://"; + +String? tryNormalizeIpfsUrl(String? url) { + if (url == null) { + return null; + } + + if (!url.toLowerCase().startsWith(_ipfsScheme)) { + return url.toLowerCase().startsWith("https://") ? url : null; + } + + var cid = url.substring(_ipfsScheme.length); + + // Some minters write ipfs://ipfs/, which would double the gateway path. + if (cid.toLowerCase().startsWith("ipfs/")) { + cid = cid.substring(5); + } + + if (cid.isEmpty) { + return null; + } + + return "$_ipfsGateway$cid"; +} diff --git a/cw_core/lib/utils/nft_text.dart b/cw_core/lib/utils/nft_text.dart new file mode 100644 index 000000000..1cf0f6f8d --- /dev/null +++ b/cw_core/lib/utils/nft_text.dart @@ -0,0 +1,25 @@ +// Control characters and bidi overrides let a mint's own metadata fake extra +// lines or reverse the text around it wherever the name is shown. +final _unsafeDisplayCharacters = RegExp( + "[\u0000-\u001F\u007F\u00AD\u034F\u061C\u115F\u1160\u180E\u200B\u200E\u200F" + "\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\u3164\uFEFF\uFFA0]" + "|[\u{E0000}-\u{E007F}]", + unicode: true, +); + +const _maxDisplayTextLength = 512; + +String? sanitizeNFTText(String? text) { + if (text == null) { + return null; + } + + final cleaned = text.replaceAll(_unsafeDisplayCharacters, " ").trim(); + final runes = cleaned.runes; + + if (runes.length <= _maxDisplayTextLength) { + return cleaned; + } + + return "${String.fromCharCodes(runes.take(_maxDisplayTextLength))}\u2026"; +} diff --git a/cw_core/lib/wallet_service.dart b/cw_core/lib/wallet_service.dart index 122193649..72ea98a97 100644 --- a/cw_core/lib/wallet_service.dart +++ b/cw_core/lib/wallet_service.dart @@ -1,6 +1,7 @@ import "dart:convert"; import "dart:io"; +import "package:cw_core/imported_nft.dart"; import "package:cw_core/pathForWallet.dart"; import "package:cw_core/spl_token.dart"; import "package:cw_core/tron_token.dart"; @@ -62,6 +63,11 @@ abstract class WalletService _renameTokenRows(String currentName, String newName) async { if (getType() == WalletType.solana) { await SPLToken.renameWallet(currentName, newName); + await ImportedNFT.renameWallet( + currentName, + newName, + chains: const [ImportedNFT.solanaChain], + ); } if (getType() == WalletType.tron) { diff --git a/cw_core/test/imported_nft_test.dart b/cw_core/test/imported_nft_test.dart new file mode 100644 index 000000000..2bc14b546 --- /dev/null +++ b/cw_core/test/imported_nft_test.dart @@ -0,0 +1,176 @@ +import "dart:io"; + +import "package:cw_core/db/sqlite.dart"; +import "package:cw_core/imported_nft.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path_provider_platform_interface/path_provider_platform_interface.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +// Faking the documents dir keeps getAppDir() off the platform channel, so the +// test runs with a plain `flutter test` on any host and in CI. +class _FakePathProviderPlatform extends PathProviderPlatform { + _FakePathProviderPlatform(this.root); + + final String root; + + @override + Future getApplicationDocumentsPath() async => root; + + @override + Future getApplicationSupportPath() async => root; +} + +Future main() async { + final dataRoot = Directory("./test/data/imported_nft"); + + Future insert(String walletName, String chain, String identifier) => + ImportedNFT(walletName: walletName, chain: chain, identifier: identifier, name: identifier) + .save(); + + Future> identifiersFor(String walletName, [String? chain]) async => + (await ImportedNFT.getAllForWallet(walletName, chain)).map((nft) => nft.identifier).toList(); + + setUpAll(() async { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + dataRoot.createSync(recursive: true); + Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true); + + PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await initDb(); + }); + + setUp(() async { + await db!.delete(ImportedNFT.tableName); + }); + + tearDownAll(() { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + }); + + group("ImportedNFT wallet lifecycle", () { + test("reading without a chain returns every chain the wallet imported on", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Main", "polygon", "0xother:7"); + + expect(await identifiersFor("Main", "eth"), ["0xcontract:1"]); + expect( + (await identifiersFor("Main"))..sort(), + ["0xcontract:1", "0xother:7", "SolMint"], + ); + }); + + test("rename moves every chain and leaves other wallets alone", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Sidekick", "eth", "0xkeep:9"); + + await ImportedNFT.renameWallet("Main", "Renamed"); + + expect(await identifiersFor("Main"), isEmpty); + expect((await identifiersFor("Renamed"))..sort(), ["0xcontract:1", "SolMint"]); + expect(await identifiersFor("Sidekick"), ["0xkeep:9"]); + }); + + test("renaming into a name that still has orphan rows does not trip the unique index", + () async { + await insert("Orphaned", "eth", "0xcontract:1"); + await insert("Orphaned", "polygon", "0xsurvivor:5"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Main", "eth", "0xsecond:2"); + + await ImportedNFT.renameWallet("Main", "Orphaned", chains: const ["eth"]); + + // Only the colliding chain is cleared. A row the destination holds on + // another chain is not ours to delete. + expect( + (await identifiersFor("Orphaned"))..sort(), + ["0xcontract:1", "0xsecond:2", "0xsurvivor:5"], + ); + }); + + test("renaming a solana wallet leaves a same-named evm wallet's NFTs alone", () async { + await insert("Sol", ImportedNFT.solanaChain, "SolMint"); + await insert("Vault", "eth", "0xEthNFT:1"); + + await ImportedNFT.renameWallet( + "Sol", + "Vault", + chains: const [ImportedNFT.solanaChain], + ); + + expect((await identifiersFor("Vault"))..sort(), ["0xEthNFT:1", "SolMint"]); + }); + + test("deleting a solana wallet leaves a same-named evm wallet's NFTs alone", () async { + await insert("Vault", ImportedNFT.solanaChain, "SolMint"); + await insert("Vault", "eth", "0xEthNFT:1"); + + await ImportedNFT.deleteAllForWallet( + "Vault", + chains: const [ImportedNFT.solanaChain], + ); + + expect(await identifiersFor("Vault"), ["0xEthNFT:1"]); + }); + + test("an empty chain scope deletes nothing rather than everything", () async { + await insert("Main", "eth", "0xcontract:1"); + + await ImportedNFT.deleteAllForWallet("Main", chains: const []); + + expect(await identifiersFor("Main"), ["0xcontract:1"]); + }); + + test("delete removes every chain for that wallet only", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Sidekick", ImportedNFT.solanaChain, "OtherMint"); + + await ImportedNFT.deleteAllForWallet("Main"); + + expect(await identifiersFor("Main"), isEmpty); + expect(await identifiersFor("Sidekick"), ["OtherMint"]); + }); + + test("deleting one leaves the wallet's other imports in place", () async { + await insert("Main", "eth", "0xcontract:1"); + await insert("Main", "eth", "0xsecond:2"); + await insert("Main", "polygon", "0xcontract:1"); + + await ImportedNFT.deleteOne("Main", "eth", "0xcontract:1"); + + expect(await identifiersFor("Main", "eth"), ["0xsecond:2"]); + expect(await identifiersFor("Main", "polygon"), ["0xcontract:1"]); + }); + + test("copying to a new name keeps the rows under the old one", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + + for (final nft in await ImportedNFT.getAllForWallet("Main")) { + await ImportedNFT.copyWith(nft, walletName: "Copy").save(); + } + + expect((await identifiersFor("Main"))..sort(), ["0xcontract:1", "SolMint"]); + expect((await identifiersFor("Copy"))..sort(), ["0xcontract:1", "SolMint"]); + }); + + test("a copy is a new row, so saving it does not overwrite the original", () async { + await insert("Main", "eth", "0xcontract:1"); + + final original = (await ImportedNFT.getAllForWallet("Main")).single; + final copy = ImportedNFT.copyWith(original, walletName: "Copy"); + await copy.save(); + + expect(copy.id, isNot(original.id)); + expect((await ImportedNFT.getAllForWallet("Main")).single.id, original.id); + }); + }); +} diff --git a/cw_core/test/ipfs_url_test.dart b/cw_core/test/ipfs_url_test.dart new file mode 100644 index 000000000..14ab8439c --- /dev/null +++ b/cw_core/test/ipfs_url_test.dart @@ -0,0 +1,55 @@ +import "package:cw_core/utils/ipfs_url.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("tryNormalizeIpfsUrl", () { + test("rewrites an ipfs uri to the gateway", () { + expect(tryNormalizeIpfsUrl("ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + }); + + test("does not double the gateway path when the uri already says ipfs/", () { + expect(tryNormalizeIpfsUrl("ipfs://ipfs/QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + }); + + test("matches the scheme whatever its case", () { + expect(tryNormalizeIpfsUrl("IPFS://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + expect(tryNormalizeIpfsUrl("Ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + }); + + test("returns null for an ipfs uri with no cid, rather than a bare gateway url", () { + expect(tryNormalizeIpfsUrl("ipfs://"), isNull); + expect(tryNormalizeIpfsUrl("ipfs://ipfs/"), isNull); + }); + + test("passes through an https url that is not ipfs", () { + expect(tryNormalizeIpfsUrl("https://arweave.net/abc"), "https://arweave.net/abc"); + }); + + test("drops a scheme nothing can render, rather than handing it to the image widget", () { + expect(tryNormalizeIpfsUrl("ar://abc123"), isNull); + expect(tryNormalizeIpfsUrl(""), isNull); + }); + + test("returns null for null", () { + expect(tryNormalizeIpfsUrl(null), isNull); + }); + + test("is idempotent, so a value read back from storage stays stable", () { + final once = tryNormalizeIpfsUrl("ipfs://QmAbC123"); + expect(tryNormalizeIpfsUrl(once), once); + }); + + test("drops any scheme that is not https", () { + // These render through Image.network, which does not go through the proxy. + expect(tryNormalizeIpfsUrl("http://127.0.0.1:8080/x.png"), isNull); + expect(tryNormalizeIpfsUrl("file:///etc/passwd"), isNull); + expect(tryNormalizeIpfsUrl("javascript:alert(1)"), isNull); + expect(tryNormalizeIpfsUrl("data:text/html,x"), isNull); + }); + + test("keeps an https url whatever its casing", () { + expect(tryNormalizeIpfsUrl("https://ok.example/a.png"), "https://ok.example/a.png"); + expect(tryNormalizeIpfsUrl("HTTPS://Up.example/a.png"), "HTTPS://Up.example/a.png"); + }); + }); +} diff --git a/cw_core/test/nft_text_test.dart b/cw_core/test/nft_text_test.dart new file mode 100644 index 000000000..420562e45 --- /dev/null +++ b/cw_core/test/nft_text_test.dart @@ -0,0 +1,89 @@ +import "package:cw_core/utils/nft_text.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("sanitizeNFTText", () { + test("removes the separators that would fake extra lines", () { + expect(sanitizeNFTText("Ape\u2028Address: 9xQeW"), "Ape Address: 9xQeW"); + expect(sanitizeNFTText("Ape\u2029Address: 9xQeW"), "Ape Address: 9xQeW"); + expect(sanitizeNFTText("Ape\nAddress: 9xQeW"), "Ape Address: 9xQeW"); + }); + + test("removes bidi overrides and isolates that reorder what follows", () { + expect(sanitizeNFTText("evil\u202Etxen"), "evil txen"); + expect(sanitizeNFTText("a\u2066b\u2069c"), "a b c"); + }); + + test("removes invisibles that hide or reorder characters", () { + expect(sanitizeNFTText("A\u200BB"), "A B"); + expect(sanitizeNFTText("A\u200FB"), "A B"); + expect(sanitizeNFTText("A\u061CB"), "A B"); + expect(sanitizeNFTText("A\u2060B"), "A B"); + }); + + test("removes control characters and delete", () { + expect(sanitizeNFTText("A\u0000B"), "A B"); + expect(sanitizeNFTText("A\u001FB"), "A B"); + expect(sanitizeNFTText("A\u007FB"), "A B"); + }); + + test("leaves ordinary text alone", () { + expect(sanitizeNFTText("DeGod #1234"), "DeGod #1234"); + expect(sanitizeNFTText(" padded "), "padded"); + }); + + test("returns null for null", () { + expect(sanitizeNFTText(null), isNull); + }); + + test("counts the cap in characters, so emoji are not cut in half", () { + final long = sanitizeNFTText("\u{1F600}" * 600)!; + + expect(long.runes.length, 513); + expect(long.runes.last, 0x2026); + + final units = long.codeUnits; + final endsOnHighSurrogate = units.isNotEmpty && units.last >= 0xD800 && units.last <= 0xDBFF; + expect(endsOnHighSurrogate, isFalse); + }); + + test("does not truncate a name that is long in code units but short in characters", () { + expect(sanitizeNFTText("\u{1F600}" * 400)!.runes.length, 400); + }); + + test("is idempotent, so re-reading a saved value does not change it", () { + const raw = "Ape\u2028Name\u202E with \u{1F600}"; + final once = sanitizeNFTText(raw); + + expect(sanitizeNFTText(once), once); + }); + + test("keeps the joiners that carry meaning in persian and emoji", () { + // Replacing these put a visible space inside a Persian word and split + // a two-person emoji into two glyphs. + expect(sanitizeNFTText("\u{1F468}\u200D\u{1F4BB}"), "\u{1F468}\u200D\u{1F4BB}"); + expect( + sanitizeNFTText("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645"), + "\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645", + ); + }); + + test("strips the invisibles a mint can hide text behind", () { + for (final invisible in [ + "\u00AD", // soft hyphen + "\u034F", // combining grapheme joiner + "\u180E", // mongolian vowel separator + "\u3164", // hangul filler + "\uFEFF", // zero width no-break space + "\uFFA0", // halfwidth hangul filler + "\u{E0041}", // tag latin capital A + ]) { + expect( + sanitizeNFTText("A${invisible}B"), + "A B", + reason: "U+${invisible.runes.first.toRadixString(16).toUpperCase()} survived", + ); + } + }); + }); +} diff --git a/cw_evm/lib/evm_chain_wallet_service.dart b/cw_evm/lib/evm_chain_wallet_service.dart index ec52c5194..a1217a36d 100644 --- a/cw_evm/lib/evm_chain_wallet_service.dart +++ b/cw_evm/lib/evm_chain_wallet_service.dart @@ -3,6 +3,7 @@ import "dart:io"; import 'package:bip39/bip39.dart' as bip39; import 'package:cw_core/encryption_file_utils.dart'; import "package:cw_core/erc20_token.dart"; +import "package:cw_core/imported_nft.dart"; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; @@ -36,6 +37,9 @@ class EVMChainWalletService extends WalletService< return _registry.getRegisteredWalletTypes(); } + List get _importedNFTChains => + _registry.getAllChains().map((chain) => chain.shortCode).toList(); + Future _findWalletByName(String name) async { for (final type in _evmWalletTypes) { final walletInfo = await WalletInfo.get(name, type); @@ -192,8 +196,15 @@ class EVMChainWalletService extends WalletService< final copiedToken = Erc20Token.copyWith(token, walletName: newName); await copiedToken.save(); } + + final ownNFTs = (await ImportedNFT.getAllForWallet(currentName)) + .where((nft) => _importedNFTChains.contains(nft.chain)); + for (final nft in ownNFTs) { + await ImportedNFT.copyWith(nft, walletName: newName).save(); + } } else { await Erc20Token.renameWallet(currentName, newName); + await ImportedNFT.renameWallet(currentName, newName, chains: _importedNFTChains); } final oldDir = Directory(p.join(await pathForWalletTypeDir(type: type), currentName)); @@ -351,6 +362,7 @@ class EVMChainWalletService extends WalletService< final nameStillUsed = (await _findWalletByName(wallet)) != null; if (!nameStillUsed) { await Erc20Token.deleteAllForWallet(wallet); + await ImportedNFT.deleteAllForWallet(wallet, chains: _importedNFTChains); } } diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart index 7e218179c..c69ec8387 100644 --- a/cw_solana/lib/solana_client.dart +++ b/cw_solana/lib/solana_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import "dart:typed_data"; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_core/amount/money.dart'; @@ -7,6 +8,7 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/solana_rpc_http_service.dart'; +import "package:cw_core/utils/ipfs_url.dart"; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_solana/pending_solana_transaction.dart'; import 'package:cw_solana/solana_balance.dart'; @@ -110,6 +112,62 @@ class SolanaWalletClient { } } + Future> _mintsToWithholdFromTokenList(Set mints) async { + const batchSize = 10; + final ordered = mints.toList(); + final withheld = {}; + + Future 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> 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 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 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 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 _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 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)["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 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 tokens = []; + final nftCandidates = {}; for (final item in decodedResponse) { - final tokenData = item as Map; + try { + final tokenData = item as Map; - final amountStr = tokenData['amount'] as String? ?? '0'; - final amount = double.tryParse(amountStr) ?? 0.0; + final amountStr = tokenData["amount"] as String? ?? "0"; + final amount = double.tryParse(amountStr) ?? 0.0; - if (amount <= 0) continue; + if (amount <= 0) continue; - final mint = tokenData['mint'] as String? ?? ''; - if (mint.isEmpty) continue; + final mint = tokenData["mint"] as String? ?? ""; + if (mint.isEmpty) continue; - tokens.add( - MoralisSolanaTokenBalance( - mint: mint, - amount: amount, - ), - ); + final amountRaw = tokenData["amountRaw"]?.toString() ?? "0"; + + final decimals = _parseMoralisDecimals(tokenData["decimals"]); + + if (decimals == 0 && amountRaw == "1") { + nftCandidates.add(mint); + } + + tokens.add( + MoralisSolanaTokenBalance( + mint: mint, + amount: amount, + ), + ); + } catch (e) { + printV("Skipping malformed Moralis token entry: ${e.toString()}"); + } + } + + if (nftCandidates.isNotEmpty) { + final withheld = await _mintsToWithholdFromTokenList(nftCandidates); + tokens.removeWhere((token) => withheld.contains(token.mint)); } return tokens; @@ -1908,6 +2143,22 @@ class SolanaWalletClient { } } +class NFTOnChainMetadata { + const NFTOnChainMetadata({ + required this.mint, + required this.name, + required this.symbol, + required this.metadataUri, + this.imageUrl, + }); + + final String mint; + final String name; + final String symbol; + final String metadataUri; + final String? imageUrl; +} + class MoralisSolanaTokenBalance { final String mint; final double amount; diff --git a/cw_solana/lib/solana_exceptions.dart b/cw_solana/lib/solana_exceptions.dart index a3873a37a..fd009cb7e 100644 --- a/cw_solana/lib/solana_exceptions.dart +++ b/cw_solana/lib/solana_exceptions.dart @@ -30,6 +30,8 @@ class SolanaCreateAssociatedTokenAccountException extends CreateAssociatedTokenA class SolanaSignSPLTokenTransactionRentException extends SignSPLTokenTransactionRentException {} +class SolanaNotAnNFTException extends NotAnNFTException {} + class SolanaNoAssociatedTokenAccountException extends NoAssociatedTokenAccountException { SolanaNoAssociatedTokenAccountException(this.account, this.mint); diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart index 55ad9f9f3..8253b5894 100644 --- a/cw_solana/lib/solana_wallet.dart +++ b/cw_solana/lib/solana_wallet.dart @@ -323,6 +323,38 @@ abstract class SolanaWalletBase return matches.first; } + Future> heldTokenMints() => _client.fetchHeldTokenMints(walletAddresses.address); + + Future 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> fetchTransactions() async => {}; diff --git a/cw_solana/lib/solana_wallet_service.dart b/cw_solana/lib/solana_wallet_service.dart index 85eae6024..0828d5f77 100644 --- a/cw_solana/lib/solana_wallet_service.dart +++ b/cw_solana/lib/solana_wallet_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:bip39/bip39.dart' as bip39; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/balance.dart'; +import "package:cw_core/imported_nft.dart"; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/spl_token.dart'; import 'package:cw_core/transaction_history.dart'; @@ -101,6 +102,7 @@ class SolanaWalletService extends WalletService< final nameStillUsed = await WalletInfo.get(wallet, getType()) != null; if (!nameStillUsed) { await SPLToken.deleteAllForWallet(wallet); + await ImportedNFT.deleteAllForWallet(wallet, chains: const [ImportedNFT.solanaChain]); } final prefs = await SharedPreferences.getInstance(); diff --git a/lib/di.dart b/lib/di.dart index ee904e21f..a4f691703 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -223,6 +223,7 @@ import 'package:cake_wallet/view_model/dashboard/cake_features_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/desktop_sidebar_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/home_settings_view_model.dart'; +import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart"; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/receive_option_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart'; @@ -1700,6 +1701,9 @@ Future setup({ getIt.registerFactory(() => NFTViewModel(appStore, getIt.get())); + getIt.registerFactory(() => + NFTSendViewModel(appStore, getIt.get(param1: CryptoCurrency.sol))); + getIt.registerFactory(() => SignViewModel(getIt.get().wallet!)); getIt.registerFactory(() => SeedVerificationPage(getIt.get())); diff --git a/lib/entities/saved_nfts.dart b/lib/entities/saved_nfts.dart new file mode 100644 index 000000000..9a76499ae --- /dev/null +++ b/lib/entities/saved_nfts.dart @@ -0,0 +1,116 @@ +import "package:cake_wallet/entities/solana_nft_asset_model.dart"; +import "package:cake_wallet/entities/wallet_nft_response.dart"; +import "package:cw_core/imported_nft.dart"; + +class SavedNFTs { + Future> 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 addSolana(String walletName, SolanaNFTAssetModel asset) async { + final mint = asset.mint; + + if (mint == null || mint.isEmpty) { + return; + } + + await _rowForSolana(walletName, mint, asset).save(); + } + + Future refreshSolana(String walletName, List assets) async { + for (final asset in assets) { + final mint = asset.mint; + + if (mint == null) { + continue; + } + + await ImportedNFT.updateMetadata(_rowForSolana(walletName, mint, asset)); + } + } + + Future> 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 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 removeSolana(String walletName, String mint) => + ImportedNFT.deleteOne(walletName, ImportedNFT.solanaChain, mint); + + static String? evmIdentifier(NFTAssetModel asset) { + final address = asset.tokenAddress; + final tokenId = asset.tokenId; + + if (address == null || address.isEmpty) { + return null; + } + + return "$address:${tokenId ?? ""}"; + } + + ImportedNFT _rowForSolana(String walletName, String mint, SolanaNFTAssetModel asset) => + ImportedNFT( + walletName: walletName, + chain: ImportedNFT.solanaChain, + identifier: mint, + name: asset.name, + symbol: asset.symbol, + description: asset.description, + imageUrl: asset.imageOriginalUrl, + isOwned: asset.isOwned, + ); + + String _tokenAddressOf(String identifier) => identifier.split(":").first; + + String _tokenIdOf(String identifier) { + final parts = identifier.split(":"); + + return parts.length > 1 ? parts.sublist(1).join(":") : ""; + } +} diff --git a/lib/entities/solana_nft_asset_model.dart b/lib/entities/solana_nft_asset_model.dart index 51f43c583..62f3df5c6 100644 --- a/lib/entities/solana_nft_asset_model.dart +++ b/lib/entities/solana_nft_asset_model.dart @@ -1,3 +1,6 @@ +import "package:cw_core/utils/ipfs_url.dart"; +import "package:cw_core/utils/nft_text.dart"; + class SolanaNFTAssetModel { String? address; String? mint; @@ -12,46 +15,50 @@ class SolanaNFTAssetModel { Metaplex? metaplex; Collection? collection; Contract? contract; + bool? isOwned; SolanaNFTAssetModel({ this.address, this.mint, this.standard, - this.name, - this.symbol, - this.description, - this.imageOriginalUrl, + String? name, + String? symbol, + String? description, + String? imageOriginalUrl, this.externalUrl, this.metadataOriginalUrl, this.totalSupply, this.metaplex, this.collection, this.contract, - }); + this.isOwned, + }) : name = sanitizeNFTText(name), + symbol = sanitizeNFTText(symbol), + description = sanitizeNFTText(description), + imageOriginalUrl = tryNormalizeIpfsUrl(imageOriginalUrl); - factory SolanaNFTAssetModel.fromJson(Map 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) - : null, - collection: json['collection'] != null - ? Collection.fromJson(json['collection'] as Map) - : null, - contract: json['contract'] != null - ? Contract.fromJson(json['contract'] as Map) - : null, - ); - } + factory SolanaNFTAssetModel.fromJson(Map 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) + : null, + collection: json["collection"] != null + ? Collection.fromJson(json["collection"] as Map) + : null, + contract: json["contract"] != null + ? Contract.fromJson(json["contract"] as Map) + : null, + isOwned: json["isOwned"] as bool?, + ); } class Metaplex { @@ -100,17 +107,15 @@ class Collection { this.metaplexMint, this.sellerFeeBasisPoints}); - factory Collection.fromJson(Map 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 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 json) { return Contract( type: json['type'] as String?, - name: json['name'] as String?, - symbol: json['symbol'] as String?, + name: sanitizeNFTText(json["name"] as String?), + symbol: sanitizeNFTText(json["symbol"] as String?), ); } } diff --git a/lib/entities/wallet_nft_response.dart b/lib/entities/wallet_nft_response.dart index b35df93b5..558b285cb 100644 --- a/lib/entities/wallet_nft_response.dart +++ b/lib/entities/wallet_nft_response.dart @@ -1,3 +1,6 @@ +import "package:cw_core/utils/ipfs_url.dart"; +import "package:cw_core/utils/nft_text.dart"; + class WalletNFTsResponseModel { final int? page; final int? pageSize; @@ -40,8 +43,8 @@ class NFTAssetModel { tokenAddress: json['token_address'] as String?, tokenId: json['token_id'] as String?, contractType: json['contract_type'] as String?, - name: json['name'] as String?, - symbol: json['symbol'] as String?, + name: sanitizeNFTText(json["name"] as String?), + symbol: sanitizeNFTText(json["symbol"] as String?), normalizedMetadata: json['normalized_metadata'] != null ? new NormalizedMetadata.fromJson(json['normalized_metadata'] as Map) : null, @@ -61,33 +64,11 @@ class NormalizedMetadata { factory NormalizedMetadata.fromJson(Map json) { return NormalizedMetadata( - name: json['name'] as String?, - description: json['description'] as String?, - image: json['image'] as String?, + name: sanitizeNFTText(json["name"] as String?), + description: sanitizeNFTText(json["description"] as String?), + image: json["image"] as String?, ); } - String? get imageUrl { - if (image == null) return image; - - if (image!.contains('ipfs.io')) return image; - - if (!image!.contains('ipfs')) return image; - - // IPFS public gateway provided by Cloudflare is https://cloudflare-ipfs.com/ipfs/ - // - // Here is an example of an ipfs image link: - // - // [ipfs://bafkreia2i2ctfexpovgzfff66wqhbmwwpvqjvozan7ioifzcnq76jharwu] - - //https://ipfs.io/ipfs/QmTRcRXo6cXByjHYHTVxGpag6vpocrG3rxjPC9PxKAArR9/1620.png - - const String ipfsPublicGateway = 'https://cloudflare-ipfs.com/ipfs/'; - - final ipfsPath = image?.split('//')[1]; - - final imageLink = '$ipfsPublicGateway$ipfsPath'; - - return imageLink; - } + String? get imageUrl => tryNormalizeIpfsUrl(image); } diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index d1e421d30..05450ca77 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -294,6 +294,23 @@ class CWMonero extends Monero { }; } + @override + Future 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 startBackgroundSync(Object wallet) => (wallet as MoneroWallet).startBackgroundSync(); + + @override + Future stopBackgroundSync(Object wallet, String password) => + (wallet as MoneroWallet).stopBackgroundSync(password); + @override int? getRestoreHeight(Object wallet) { final moneroWallet = wallet as MoneroWallet; diff --git a/lib/new-ui/pages/home_page.dart b/lib/new-ui/pages/home_page.dart index f62440952..a7ba2694b 100644 --- a/lib/new-ui/pages/home_page.dart +++ b/lib/new-ui/pages/home_page.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; @@ -18,9 +20,6 @@ import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart'; import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart'; -import "package:cw_core/amount/money.dart"; -import "package:cw_core/crypto_currency.dart"; -import "package:cw_core/transaction_direction.dart"; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -53,9 +52,9 @@ class _NewHomePageState extends State { }); }); - 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 { sliver: CupertinoSliverRefreshControl( refreshTriggerPullDistance: 160, refreshIndicatorExtent: 90, - onRefresh: () => widget.dashboardViewModel.refreshDashboard(), + onRefresh: () { + unawaited(widget.nftViewModel.getNFTAssetByWallet()); + + return widget.dashboardViewModel.refreshDashboard(); + }, ), ), SliverToBoxAdapter( diff --git a/lib/router.dart b/lib/router.dart index 643a03cde..a583e587c 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; +import "package:cake_wallet/core/auth_service.dart"; import 'package:cake_wallet/core/new_wallet_arguments.dart'; import 'package:cake_wallet/new-ui/new_dashboard.dart'; import 'package:cake_wallet/new-ui/pages/about_page.dart'; @@ -44,6 +45,7 @@ import 'package:cake_wallet/src/screens/dashboard/edit_token_page.dart'; import 'package:cake_wallet/src/screens/dashboard/home_settings_page.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/address_page.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart'; +import "package:cake_wallet/src/screens/dashboard/pages/nft_send_page.dart"; import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart'; import 'package:cake_wallet/src/screens/dashboard/sign_page.dart'; import 'package:cake_wallet/src/screens/dev/exchange_provider_logs_page.dart'; @@ -143,6 +145,7 @@ import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; import 'package:cake_wallet/view_model/bridge/bridge_history_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart"; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart'; import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart'; @@ -962,7 +965,7 @@ Route createRoute(RouteSettings settings) { )); case Routes.nftDetailsPage: - return MaterialPageRoute( + return MaterialPageRoute( builder: (_) => NFTDetailsPage( arguments: settings.arguments as NFTDetailsPageArguments, dashboardViewModel: getIt.get(), @@ -976,6 +979,15 @@ Route createRoute(RouteSettings settings) { ), ); + case Routes.nftSendPage: + return MaterialPageRoute( + builder: (_) => NFTSendPage( + nftSendViewModel: getIt.get(), + authService: getIt.get(), + arguments: settings.arguments as NFTSendPageArguments, + ), + ); + case Routes.signPage: final vm = getIt.get(); return MaterialPageRoute( diff --git a/lib/routes.dart b/lib/routes.dart index b21e2bc38..9417a3e59 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -115,6 +115,7 @@ class Routes { static const walletConnectConnectionsListing = '/wallet-connect-connections-listing'; static const nftDetailsPage = '/nft_details_page'; static const importNFTPage = '/import_nft_page'; + static const nftSendPage = "/nft_send_page"; static const backgroundSync = '/background_sync'; static const startTor = '/start_tor'; static const receiveAddresses = "/receive_addresses"; diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart index 46edfca2a..d235fc8a1 100644 --- a/lib/solana/cw_solana.dart +++ b/lib/solana/cw_solana.dart @@ -384,6 +384,40 @@ class CWSolana extends Solana { } } + @override + Future sendNFT( + WalletBase wallet, { + required String mintAddress, + required String destinationAddress, + String? name, + }) => + (wallet as SolanaWallet).sendNFT( + mintAddress: mintAddress, + destinationAddress: destinationAddress, + name: name, + ); + + @override + Future 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> getHeldTokenMints(WalletBase wallet) => + (wallet as SolanaWallet).heldTokenMints(); + @override Future discoverAndAddWalletTokens(WalletBase wallet) async { if (wallet is! SolanaWallet) return; diff --git a/lib/src/screens/dashboard/dashboard_page.dart b/lib/src/screens/dashboard/dashboard_page.dart index e2ed2808d..f4f151735 100644 --- a/lib/src/screens/dashboard/dashboard_page.dart +++ b/lib/src/screens/dashboard/dashboard_page.dart @@ -87,7 +87,11 @@ class _DashboardPageState extends State { final dashboardPageView = RefreshIndicator( displacement: responsiveLayoutUtil.screenHeight * 0.1, - onRefresh: () async => await widget.dashboardViewModel.refreshDashboard(), + onRefresh: () async { + unawaited(widget.balancePage.nftViewModel.getNFTAssetByWallet()); + + await widget.dashboardViewModel.refreshDashboard(); + }, child: SingleChildScrollView( physics: AlwaysScrollableScrollPhysics(), child: Container( diff --git a/lib/src/screens/dashboard/pages/nft_details_page.dart b/lib/src/screens/dashboard/pages/nft_details_page.dart index f7076c486..86f281871 100644 --- a/lib/src/screens/dashboard/pages/nft_details_page.dart +++ b/lib/src/screens/dashboard/pages/nft_details_page.dart @@ -2,10 +2,13 @@ import 'package:flutter/material.dart'; import 'package:cake_wallet/entities/solana_nft_asset_model.dart'; import 'package:cake_wallet/entities/wallet_nft_response.dart'; import 'package:cake_wallet/generated/i18n.dart'; +import "package:cake_wallet/routes.dart"; import 'package:cake_wallet/src/screens/base_page.dart'; +import "package:cake_wallet/src/screens/dashboard/pages/nft_send_page.dart"; import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/src/widgets/gradient_background.dart'; +import "package:cake_wallet/src/widgets/primary_button.dart"; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; class NFTDetailsPage extends BasePage { @@ -56,30 +59,56 @@ class NFTDetailsPage extends BasePage { // ); } + bool get _canSend => + arguments.isSolanaNFT && + (arguments.solanaNFTAssetModel?.mint?.isNotEmpty ?? false) && + arguments.solanaNFTAssetModel?.isOwned != false; + @override - Widget body(BuildContext context) { - return SingleChildScrollView( - child: Container( - width: double.infinity, - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24.0), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - color: Theme.of(context).colorScheme.surface, - ), - child: arguments.isSolanaNFT - ? SolanaNFTDetailsWidget( - solanaNftAsset: arguments.solanaNFTAssetModel, - ) - : EVMChainNFTDetailsWidget( - nftAsset: arguments.nftAsset, + Widget body(BuildContext context) => SingleChildScrollView( + child: Column( + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24.0), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + width: 1, + ), + color: Theme.of(context).colorScheme.surface, ), - ), - ); - } + child: arguments.isSolanaNFT + ? SolanaNFTDetailsWidget( + solanaNftAsset: arguments.solanaNFTAssetModel, + ) + : EVMChainNFTDetailsWidget( + nftAsset: arguments.nftAsset, + ), + ), + if (_canSend) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24), + child: PrimaryButton( + text: S.of(context).send, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + onPressed: () async { + final sent = await Navigator.of(context).pushNamed( + Routes.nftSendPage, + arguments: NFTSendPageArguments(asset: arguments.solanaNFTAssetModel!), + ); + + if (sent == true && context.mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ), + ], + ), + ); } class _NFTImageWidget extends StatelessWidget { @@ -162,6 +191,15 @@ class SolanaNFTDetailsWidget extends StatelessWidget { return Center(child: Text(S.current.no_extra_detail)); } + final moreInfoEntries = >[ + MapEntry(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> entries; + + @override + State<_NFTMoreInfoSection> createState() => _NFTMoreInfoSectionState(); +} + +class _NFTMoreInfoSectionState extends State<_NFTMoreInfoSection> { + bool isExpanded = false; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + InkWell( + onTap: () => setState(() => isExpanded = !isExpanded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isExpanded ? S.current.hide_details : S.current.show_details, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + Icon( + isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + if (isExpanded) + for (final entry in widget.entries) ...[ + const SizedBox(height: 16), + _NFTSingleInfoTile(infoType: entry.key, infoValue: entry.value), + ], + ], + ); +} + class _NFTSingleInfoTile extends StatelessWidget { final String infoType; final String infoValue; diff --git a/lib/src/screens/dashboard/pages/nft_import_page.dart b/lib/src/screens/dashboard/pages/nft_import_page.dart index 7efa7bc26..cb78cdb46 100644 --- a/lib/src/screens/dashboard/pages/nft_import_page.dart +++ b/lib/src/screens/dashboard/pages/nft_import_page.dart @@ -89,7 +89,7 @@ class _ImportNFTPage extends BasePage { }, fillColor: Theme.of(context).colorScheme.surfaceContainer, iconColor: Theme.of(context).colorScheme.primary, - placeholder: '0x...', + placeholder: nftViewModel.isSolanaWallet ? S.current.mint_address : "0x...", textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.onSurface, ), diff --git a/lib/src/screens/dashboard/pages/nft_listing_page.dart b/lib/src/screens/dashboard/pages/nft_listing_page.dart index 2eaf01e72..5bf624dd6 100644 --- a/lib/src/screens/dashboard/pages/nft_listing_page.dart +++ b/lib/src/screens/dashboard/pages/nft_listing_page.dart @@ -7,6 +7,7 @@ import 'package:cake_wallet/src/screens/dashboard/widgets/solana_nft_tile_widget import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cw_core/wallet_type.dart'; +import "package:mobx/mobx.dart"; class NFTListingPage extends StatefulWidget { final NFTViewModel nftViewModel; @@ -18,23 +19,34 @@ class NFTListingPage extends StatefulWidget { } class _NFTListingPageState extends State { + 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 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 { ), ), ), + 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 { else NFTListWidget(nftViewModel: widget.nftViewModel) ], - ); - }, - ), - ); - } + ), + ), + ); } class NFTListWidget extends StatelessWidget { @@ -103,7 +124,7 @@ class NFTListWidget extends StatelessWidget { itemCount: nftViewModel.solanaNftAssetModels.length, itemBuilder: (context, index) { final nftAsset = nftViewModel.solanaNftAssetModels[index]; - return SolanaNFTTileWidget(nftAsset: nftAsset); + return SolanaNFTTileWidget(nftAsset: nftAsset, nftViewModel: nftViewModel); }, ), ); diff --git a/lib/src/screens/dashboard/pages/nft_send_page.dart b/lib/src/screens/dashboard/pages/nft_send_page.dart new file mode 100644 index 000000000..558974058 --- /dev/null +++ b/lib/src/screens/dashboard/pages/nft_send_page.dart @@ -0,0 +1,375 @@ +import "package:cake_wallet/core/address_validator.dart"; +import "package:cake_wallet/core/auth_service.dart"; +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/entities/solana_nft_asset_model.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/base_page.dart"; +import "package:cake_wallet/src/widgets/address_text_field.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; +import "package:cake_wallet/src/widgets/alert_with_two_actions.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/src/widgets/gradient_background.dart"; +import "package:cake_wallet/src/widgets/primary_button.dart"; +import "package:cake_wallet/utils/show_pop_up.dart"; +import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/pending_transaction.dart"; +import "package:flutter/material.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; + +class NFTSendPage extends StatefulWidget { + const NFTSendPage({ + required this.nftSendViewModel, + required this.authService, + required this.arguments, + super.key, + }); + + final NFTSendViewModel nftSendViewModel; + final AuthService authService; + final NFTSendPageArguments arguments; + + @override + State createState() => _NFTSendPageState(); +} + +class _NFTSendPageState extends State { + final GlobalKey formKey = GlobalKey(); + 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 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 _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 _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 _confirm( + BuildContext context, + String destinationAddress, + PendingTransaction pendingTransaction, + ) async { + final additionalCost = pendingTransaction.additionalCost; + + var confirmed = false; + + await showPopUp( + 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 _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( + 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 _showError(BuildContext context, String error) => showPopUp( + context: context, + builder: (dialogContext) => AlertWithOneAction( + alertTitle: S.of(context).error, + alertContent: error, + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(dialogContext).pop(), + ), + ); +} + +class NFTSendPageArguments { + NFTSendPageArguments({required this.asset}); + + final SolanaNFTAssetModel asset; +} + +class _NFTSendConfirmation extends StatelessWidget { + const _NFTSendConfirmation({ + required this.name, + required this.mint, + required this.destinationAddress, + required this.fee, + required this.accountCreationFee, + }); + + final String? name; + final String? mint; + final String destinationAddress; + final String fee; + final String? accountCreationFee; + + @override + Widget build(BuildContext context) { + final creationFee = accountCreationFee; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: [ + if (name?.isNotEmpty ?? false) _ConfirmationRow(label: S.of(context).name, value: name!), + if (mint?.isNotEmpty ?? false) + _ConfirmationRow(label: S.of(context).mint_address, value: mint!), + _ConfirmationRow(label: S.of(context).address, value: destinationAddress), + _ConfirmationRow(label: S.of(context).send_estimated_fee, value: fee), + if (creationFee != null) + Text( + S.of(context).recipient_account_creation_fee(creationFee), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ), + ), + ], + ); + } +} + +class _ConfirmationRow extends StatelessWidget { + const _ConfirmationRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + label, + textAlign: TextAlign.center, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ), + ), + const SizedBox(height: 2), + Text( + value, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + decoration: TextDecoration.none, + ), + ), + ], + ); + } +} diff --git a/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart b/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart index 6cf31173e..1609c46c6 100644 --- a/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart +++ b/lib/src/screens/dashboard/widgets/solana_nft_tile_widget.dart @@ -1,87 +1,127 @@ import 'package:flutter/material.dart'; import 'package:cake_wallet/entities/solana_nft_asset_model.dart'; +import "package:cake_wallet/generated/i18n.dart"; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import "package:cake_wallet/view_model/dashboard/nft_view_model.dart"; class SolanaNFTTileWidget extends StatelessWidget { - const SolanaNFTTileWidget({super.key, required this.nftAsset}); + const SolanaNFTTileWidget({ + required this.nftAsset, + required this.nftViewModel, + super.key, + }); final SolanaNFTAssetModel nftAsset; + final NFTViewModel nftViewModel; @override - Widget build(BuildContext context) { - return InkWell( - onTap: () { - Navigator.pushNamed( - context, - Routes.nftDetailsPage, - arguments: NFTDetailsPageArguments( - isSolanaNFT: true, - solanaNFTAssetModel: nftAsset, - ), - ); - }, - child: Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 0.0, - ), - 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, + ), + ), + ); } diff --git a/lib/view_model/dashboard/nft_send_view_model.dart b/lib/view_model/dashboard/nft_send_view_model.dart new file mode 100644 index 000000000..2ebb46a98 --- /dev/null +++ b/lib/view_model/dashboard/nft_send_view_model.dart @@ -0,0 +1,132 @@ +import "dart:async"; + +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/entities/solana_nft_asset_model.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/solana/solana.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cake_wallet/view_model/contact_list/contact_list_view_model.dart"; +import "package:cw_core/exceptions.dart"; +import "package:cw_core/pending_transaction.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:mobx/mobx.dart"; + +part "nft_send_view_model.g.dart"; + +class NFTSendViewModel = NFTSendViewModelBase with _$NFTSendViewModel; + +abstract class NFTSendViewModelBase with Store { + NFTSendViewModelBase(this._appStore, this._contactListViewModel); + + final AppStore _appStore; + final ContactListViewModel _contactListViewModel; + + @observable + ExecutionState state = InitialExecutionState(); + + @observable + PendingTransaction? pendingTransaction; + + bool shouldRequireTOTP2FAFor(String destinationAddress) { + final settingsStore = _appStore.settingsStore; + + final isContact = _contactListViewModel.contactsToShow + .any((contact) => contact.address == destinationAddress); + + if (isContact) { + return settingsStore.shouldRequireTOTP2FAForSendsToContact; + } + + final isInternalWallet = _contactListViewModel.walletContactsToShow + .any((contact) => contact.address == destinationAddress); + + if (isInternalWallet) { + return settingsStore.shouldRequireTOTP2FAForSendsToInternalWallets; + } + + return settingsStore.shouldRequireTOTP2FAForSendsToNonContact; + } + + @action + Future 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 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 _pollForTransaction(WalletBase wallet, String signature) async { + try { + await solana!.pollForTransaction(wallet, signature); + } catch (e) { + printV("Failed to poll for NFT transfer: ${e.toString()}"); + } + } + + String _errorMessage(Object error) { + if (error is NotAnNFTException) { + return S.current.nft_send_not_an_nft; + } + + if (error is NoAssociatedTokenAccountException) { + return S.current.solana_no_associated_token_account_exception; + } + + if (error is SignSPLTokenTransactionRentException) { + return S.current.solana_sign_spl_token_transaction_rent_exception; + } + + if (error is CreateAssociatedTokenAccountException) { + return "${S.current.solana_create_associated_token_account_exception} " + "${S.current.added_message_for_ata_error}"; + } + + printV("NFT send failed: ${error.toString()}"); + + return error.toString(); + } +} diff --git a/lib/view_model/dashboard/nft_view_model.dart b/lib/view_model/dashboard/nft_view_model.dart index dda460b90..0e54b268b 100644 --- a/lib/view_model/dashboard/nft_view_model.dart +++ b/lib/view_model/dashboard/nft_view_model.dart @@ -1,13 +1,18 @@ +import "dart:async"; import 'dart:convert'; -import 'dart:developer'; +import "package:cake_wallet/entities/saved_nfts.dart"; import 'package:cake_wallet/entities/solana_nft_asset_model.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; +import "package:cake_wallet/solana/solana.dart"; import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart'; import 'package:cake_wallet/src/screens/wallet_connect/widgets/bottom_sheet/bottom_sheet_message_display_widget.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/utils/debounce.dart'; +import "package:cw_core/utils/ipfs_url.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_base.dart"; import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:mobx/mobx.dart'; @@ -41,94 +46,96 @@ abstract class NFTViewModelBase with Store { final BottomSheetService bottomSheetService; final Debounce _fetchDebounce = Debounce(const Duration(milliseconds: 500)); + Future _lastFetch = Future.value(); + String? _loadedWalletAddress; + Completer? _queuedFetch; + + final SavedNFTs _savedNFTs = SavedNFTs(); + int _currentRefreshId = 0; + + static const _requestTimeout = Duration(seconds: 20); + static const _runDeadline = Duration(minutes: 2); + + Map get _moralisHeaders => { + "Accept": "application/json", + "X-API-Key": secrets.moralisApiKey, + }; + @observable bool isLoading; @observable bool isImportNFTLoading; + @observable + int unresolvedNFTCount = 0; + ObservableList nftAssetByWalletModels; ObservableList solanaNftAssetModels; + bool get isSolanaWallet => appStore.wallet?.type == WalletType.solana; + + String? get currentWalletName => appStore.wallet?.name; + @action - Future 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 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(); + _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 _fetchAfterPreviousRun(Future waitFor, Completer 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).map( - (x) { - final data = x as Map; - final mint = data['mint'] as String? ?? ''; - return getSolanaNFTDetails(mint, chainName); - }, - ).toList(), - ); - - solanaNftAssetModels.clear(); - - solanaNftAssetModels.addAll(results); - } else { - final result = - WalletNFTsResponseModel.fromJson(decodedResponse as Map).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 getSolanaNFTDetails(String address, String chainName) async { - final uri = Uri.https( - 'solana-gateway.moralis.io', - '/nft/$chainName/$address/metadata', + Future _getEvmNFTAssets( + String walletName, + String walletAddress, + String chainName, + int refreshId, + ) async { + final saved = await _savedNFTs.evmNFTs(walletName, chainName); + + var result = const []; + 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, + ).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 _getSolanaNFTAssets( + WalletBase wallet, + String walletAddress, + String chainName, + int refreshId, + ) async { + final importedAssets = await _savedNFTs.solanaNFTs(wallet.name); + + var indexerMints = const []; + Object? indexerError; + + try { + indexerMints = await _fetchMintsFromIndexer(walletAddress, chainName); + } catch (e) { + indexerError = e; + } + + final heldMints = await _fetchMintsHeldOnChain(wallet); + final failedMints = {}; + + final assets = await _loadAssetsForMints( + wallet: wallet, + chainName: chainName, + mints: {...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> _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) + .map((entry) => (entry as Map)["mint"] as String? ?? "") + .where((mint) => mint.isNotEmpty) + .toList(); + } + + Future?> _fetchMintsHeldOnChain(WalletBase wallet) async { + try { + return await solana!.getHeldTokenMints(wallet); + } catch (e) { + printV("Could not fetch held token mints: ${e.toString()}"); + return null; + } + } + + Future> _loadAssetsForMints({ + required WalletBase wallet, + required String chainName, + required Set mints, + required Set? heldMints, + required Map importedAssets, + required Set failedMints, + }) async { + const batchSize = 5; + final ordered = mints.toList(); + final assets = []; + + 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()); + } + + return assets; + } + + List _assetsToKeepFromPreviousRefresh( + String walletAddress, + List described, + Set 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 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; + Future _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); + } + + @action + Future onNFTSent(String walletName, String mint) async { + await _savedNFTs.removeSolana(walletName, mint); + + if (appStore.wallet?.name == walletName) { + solanaNftAssetModels.removeWhere((asset) => asset.mint == mint); + } + } + + Future _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); + + nftAssetByWalletModels.add(asset); + + await _savedNFTs.addEvm(walletName, chainName, asset); } @action Future 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; - - 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 { diff --git a/lib/view_model/dev/monero_background_sync.dart b/lib/view_model/dev/monero_background_sync.dart index 3eb4292b1..c109b7bba 100644 --- a/lib/view_model/dev/monero_background_sync.dart +++ b/lib/view_model/dev/monero_background_sync.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:cake_wallet/core/key_service.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/monero/monero.dart'; -import 'package:cw_monero/monero_wallet.dart'; import 'package:mobx/mobx.dart'; import 'package:cw_core/wallet_base.dart'; @@ -56,26 +55,24 @@ abstract class DevMoneroBackgroundSyncBase with Store { bool isBackgroundSyncing = false; Future _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 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 stopBackgroundSync() async { - final w = (wallet as MoneroWallet); final keyService = getIt.get(); - await w.stopBackgroundSync(await keyService.getWalletPassword(walletName: wallet.name)); + final password = await keyService.getWalletPassword(walletName: wallet.name); + await monero!.stopBackgroundSync(wallet, password); } } diff --git a/lib/view_model/wallet_keys_view_model.dart b/lib/view_model/wallet_keys_view_model.dart index 73e6a6315..ce10a269b 100644 --- a/lib/view_model/wallet_keys_view_model.dart +++ b/lib/view_model/wallet_keys_view_model.dart @@ -11,13 +11,10 @@ import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; -import 'package:cw_monero/monero_wallet.dart'; import 'package:flutter/foundation.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/decred/decred.dart'; import 'package:polyseed/polyseed.dart'; -import 'package:cake_wallet/evm/evm.dart'; -import 'package:cake_wallet/reactions/wallet_connect.dart'; part 'wallet_keys_view_model.g.dart'; @@ -118,7 +115,7 @@ abstract class WalletKeysViewModelBase with Store { final langName = PolyseedLang.getByPhrase(_wallet.seed!).nameEnglish; if (_wallet.type == WalletType.monero) { - return (_wallet as MoneroWalletBase).seedLegacy(langName); + return monero!.getLegacySeed(_wallet, langName); } else if (_wallet.type == WalletType.wownero) { return wownero!.getLegacySeed(_wallet, langName); } diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 210d3a2a1..63d97de78 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -128,6 +128,11 @@ dependencies: git: url: https://github.com/cake-tech/trezor_connect ref: d1242cea90f84b00200e7bcab914a5af750e23fb + trezor_flutter: + git: + url: https://github.com/cake-tech/trezor-flutter.git + ref: 7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97 + path: trezor-flutter bitbox_flutter: path: ./scripts/bitbox_flutter hashlib: 1.19.2 diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index 3b13b21dc..81497576e 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "إرسال إشعارات حول المعاملات الجديدة", "new_wallet": "محفظة جديدة", "newConnection": "اتصال جديد", + "nft_import_failed": "تعذر استيراد NFT هذا. تحقق من العنوان وحاول مرة أخرى.", + "nft_not_held_by_wallet": "لا تحتفظ بها هذه المحفظة", + "nft_send_not_an_nft": "هذا الأصل ليس NFT، لذا لا يمكن إرساله من هنا.", + "nft_some_not_loaded": "تعذر تحميل بعض NFTs", "nfts": "NFTs", "no": "لا", "no_cards_found": "لم يتم العثور على أي بطاقات", @@ -1405,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "المحافظ", "warning": "تحذير", + "watching": "عرض فقط", "wc_action_approve": "موافقة", "wc_approve_request_title": "الموافقة على الطلب", "wc_connect_request_title": "طلب الاتصال", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index c94868932..9c5ba5e18 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Изпращане на известия за нови транзакции", "new_wallet": "Нов портфейл", "newConnection": "Нова връзка", + "nft_import_failed": "Не може да се импортира този NFT. Проверете адреса и опитайте отново.", + "nft_not_held_by_wallet": "Не се държи от този портфейл", + "nft_send_not_an_nft": "Този актив не е NFT, така че не може да бъде изпратен оттук.", + "nft_some_not_loaded": "Някои NFT не можаха да бъдат заредени", "nfts": "NFT", "no": "Не", "no_cards_found": "Няма намерени карти", @@ -1405,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Портфейли", "warning": "Предупреждение", + "watching": "Само преглед", "wc_action_approve": "Одобряване", "wc_approve_request_title": "Одобряване на заявката", "wc_connect_request_title": "Заявка за свързване", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 2e68008be..eba771745 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Odesílat oznámení o nových transakcích", "new_wallet": "Nová peněženka", "newConnection": "Nové připojení", + "nft_import_failed": "Tento NFT nelze importovat. Zkontrolujte adresu a zkuste to znovu.", + "nft_not_held_by_wallet": "Tato peněženka nedrží", + "nft_send_not_an_nft": "Toto aktivum není NFT, takže jej odtud nelze odeslat.", + "nft_some_not_loaded": "Některé NFT se nepodařilo načíst", "nfts": "NFT", "no": "Ne", "no_cards_found": "Nebyly nalezeny žádné karty", @@ -1405,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Peněženky", "warning": "Varování", + "watching": "Pouze prohlížet", "wc_action_approve": "Schvalovat", "wc_approve_request_title": "Schválit žádost", "wc_connect_request_title": "Žádost o připojení", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 8044948e8..a50caead6 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -700,6 +700,10 @@ "new_transactions_notifications": "Benachrichtigungen über neue Transaktionen senden", "new_wallet": "Neue Wallet", "newConnection": "Neue Verbindung", + "nft_import_failed": "Dieses NFT konnte nicht importiert werden. Überprüfen Sie die Adresse und versuchen Sie es erneut.", + "nft_not_held_by_wallet": "Wird nicht von dieser Wallet gehalten", + "nft_send_not_an_nft": "Dieser Vermögenswert ist kein NFT und kann daher nicht von hier aus gesendet werden.", + "nft_some_not_loaded": "Einige NFTs konnten nicht geladen werden", "nfts": "NFTs", "no": "Nein", "no_cards_found": "Keine Karten gefunden", @@ -1444,6 +1448,7 @@ "walletConnect": "WalletConnect", "wallets": "Wallets", "warning": "Warnung", + "watching": "Nur ansehen", "wc_action_approve": "Genehmigen", "wc_approve_request_title": "Anfrage genehmigen", "wc_connect_request_title": "Verbindungsanfrage", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index b208f6836..6e6d3c487 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -700,6 +700,10 @@ "new_transactions_notifications": "Send notifications about new transactions", "new_wallet": "New Wallet", "newConnection": "New Connection", + "nft_import_failed": "Could not import this NFT. Check the address and try again.", + "nft_not_held_by_wallet": "Not held by this wallet", + "nft_send_not_an_nft": "This asset is not an NFT, so it cannot be sent from here.", + "nft_some_not_loaded": "Some NFTs could not be loaded", "nfts": "NFTs", "no": "No", "no_cards_found": "No cards found", @@ -1446,6 +1450,7 @@ "walletConnect": "WalletConnect", "wallets": "Wallets", "warning": "Warning", + "watching": "View only", "wc_action_approve": "Approve", "wc_approve_request_title": "Approve request", "wc_connect_request_title": "Connect request", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index ac702c0f6..444fd55fa 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -686,6 +686,10 @@ "new_transactions_notifications": "Enviar notificaciones sobre nuevas transacciones", "new_wallet": "Nueva cartera", "newConnection": "Nueva conexión", + "nft_import_failed": "No se pudo importar este NFT. Verifique la dirección e inténtelo nuevamente.", + "nft_not_held_by_wallet": "No retenido por esta billetera", + "nft_send_not_an_nft": "Este activo no es un NFT, por lo que no se puede enviar desde aquí.", + "nft_some_not_loaded": "Algunos NFT no se pudieron cargar", "nfts": "NFTs", "no": "No", "no_cards_found": "No se encontraron tarjetas", @@ -1407,6 +1411,7 @@ "walletConnect": "WalletConnect", "wallets": "Carteras", "warning": "Advertencia", + "watching": "Ver sólo", "wc_action_approve": "Aprobar", "wc_approve_request_title": "Aprobar solicitud", "wc_connect_request_title": "Solicitud de conexión", diff --git a/res/values/strings_fa.arb b/res/values/strings_fa.arb index 17f55785a..5cf611d28 100644 --- a/res/values/strings_fa.arb +++ b/res/values/strings_fa.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "ارسال اعلان برای تراکنش‌های جدید", "new_wallet": "کیف پول جدید", "newConnection": "اتصال جدید", + "nft_import_failed": "این NFT وارد نشد. آدرس را بررسی کنید و دوباره امتحان کنید.", + "nft_not_held_by_wallet": "توسط این کیف پول نگهداری نمی شود", + "nft_send_not_an_nft": "این دارایی یک NFT نیست، بنابراین نمی توان آن را از اینجا ارسال کرد.", + "nft_some_not_loaded": "برخی از NFT ها بارگیری نشدند", "nfts": "NFTها", "no": "خیر", "no_cards_found": "هیچ کارتی پیدا نشد", @@ -1405,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "کیف پول‌ها", "warning": "هشدار", + "watching": "فقط مشاهده کنید", "wc_action_approve": "تایید کنید", "wc_approve_request_title": "درخواست را تایید کنید", "wc_connect_request_title": "درخواست اتصال", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index a8c8df56a..f213e9751 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Envoyer des notifications pour les nouvelles transactions", "new_wallet": "Nouveau portefeuille", "newConnection": "Nouvelle connexion", + "nft_import_failed": "Impossible d'importer ce NFT. Vérifiez l'adresse et réessayez.", + "nft_not_held_by_wallet": "Non détenu par ce portefeuille", + "nft_send_not_an_nft": "Cet actif n'est pas un NFT, il ne peut donc pas être envoyé à partir d'ici.", + "nft_some_not_loaded": "Certains NFT n'ont pas pu être chargés", "nfts": "NFTs", "no": "Non", "no_cards_found": "Aucune carte trouvée", @@ -1405,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Portefeuilles", "warning": "Avertissement", + "watching": "Visualisation uniquement", "wc_action_approve": "Approuver", "wc_approve_request_title": "Approuver la demande", "wc_connect_request_title": "Demande de connexion", diff --git a/res/values/strings_gn.arb b/res/values/strings_gn.arb index c86512e7b..41ae92d9b 100644 --- a/res/values/strings_gn.arb +++ b/res/values/strings_gn.arb @@ -688,6 +688,10 @@ "new_transactions_notifications": "Emondo marandu'i umi transacción pyahúre", "new_wallet": "Billetera pyahu", "newConnection": "Jeike pyahu", + "nft_import_failed": "Ndaikatúi ojegueru ko NFT. Ehecháke pe direksión ha eñehaʼã jey.", + "nft_not_held_by_wallet": "Ndojeguerekói ko billetera rupive", + "nft_send_not_an_nft": "Ko activo ndaha'éi NFT, upévare ndikatúi oñemondo ko'águi.", + "nft_some_not_loaded": "Oĩ NFT ndaikatúiva ojekarga", "nfts": "NFTs", "no": "Nahániri", "no_cards_found": "Ndojejuhúi kuatia'atã", @@ -1412,6 +1416,7 @@ "walletConnect": "WalletConnect", "wallets": "Billetera kuéra", "warning": "Kyhyje", + "watching": "Ojehecha añoite", "wc_action_approve": "Hasapyre", "wc_approve_request_title": "Omoneĩ mba’ejerure", "wc_connect_request_title": "Ombojoaju mba’ejerure", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 29cda58b1..9656357c1 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Aika sanarwa game da sabbin ma'amaloli", "new_wallet": "Sabuwar Walat", "newConnection": "Sabuwar Haɗin", + "nft_import_failed": "Ba za a iya shigo da wannan NFT ba. Duba adireshin kuma a sake gwadawa.", + "nft_not_held_by_wallet": "Ba a riƙe da wannan walat", + "nft_send_not_an_nft": "Wannan kadari ba NFT ba ne, don haka ba za a iya aika shi daga nan ba.", + "nft_some_not_loaded": "Wasu NFT ba za a iya loda su ba", "nfts": "NFTs", "no": "A'a", "no_cards_found": "Ba a sami katuna ba", @@ -1407,6 +1411,7 @@ "walletConnect": "WalletConnect", "wallets": "Walati", "warning": "Gargaɗi", + "watching": "Duba kawai", "wc_action_approve": "Amincewa", "wc_approve_request_title": "Amincewa da buƙata", "wc_connect_request_title": "Buƙatar haɗin kai", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index 13d18ae19..4e5e87371 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "नए लेन-देन के बारे में सूचनाएं भेजें", "new_wallet": "नया वॉलेट", "newConnection": "नया कनेक्शन", + "nft_import_failed": "इस एनएफटी को आयात नहीं किया जा सका. पता जांचें और पुनः प्रयास करें.", + "nft_not_held_by_wallet": "इस बटुए द्वारा नहीं रखा गया", + "nft_send_not_an_nft": "यह संपत्ति एनएफटी नहीं है, इसलिए इसे यहां से नहीं भेजा जा सकता है।", + "nft_some_not_loaded": "कुछ एनएफटी लोड नहीं किये जा सके", "nfts": "एनएफटी", "no": "नहीं", "no_cards_found": "कोई कार्ड नहीं मिले", @@ -1407,6 +1411,7 @@ "walletConnect": "WalletConnect", "wallets": "वॉलेट्स", "warning": "चेतावनी", + "watching": "केवल देखें", "wc_action_approve": "मंज़ूरी देना", "wc_approve_request_title": "अनुरोध स्वीकृत करें", "wc_connect_request_title": "कनेक्ट अनुरोध", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 0e4f55c43..5bce2b969 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Šalji obavijesti o novim transakcijama", "new_wallet": "Novi novčanik", "newConnection": "Nova veza", + "nft_import_failed": "Nije moguće uvesti ovaj NFT. Provjerite adresu i pokušajte ponovo.", + "nft_not_held_by_wallet": "Ne drži ga ovaj novčanik", + "nft_send_not_an_nft": "Ovo sredstvo nije NFT, pa se ne može poslati odavde.", + "nft_some_not_loaded": "Neki NFT-ovi se ne mogu učitati", "nfts": "NFT-ovi", "no": "Ne", "no_cards_found": "Nisu pronađene kartice", @@ -1405,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Novčanici", "warning": "Upozorenje", + "watching": "Samo pogled", "wc_action_approve": "Odobriti", "wc_approve_request_title": "Odobri zahtjev", "wc_connect_request_title": "Zahtjev za povezivanje", diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index e45a24dce..d9bef69ad 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Ուղարկել ծանուցումներ նոր գործարքների մասին", "new_wallet": "Նոր դրամապանակ", "newConnection": "Նոր միացում", + "nft_import_failed": "Չհաջողվեց ներմուծել այս NFT-ը: Ստուգեք հասցեն և նորից փորձեք:", + "nft_not_held_by_wallet": "Այս դրամապանակը չի պահվում", + "nft_send_not_an_nft": "Այս ակտիվը NFT չէ, ուստի այն չի կարող ուղարկվել այստեղից:", + "nft_some_not_loaded": "Որոշ NFT-ներ չհաջողվեց բեռնել", "nfts": "NFT-ներ", "no": "Ոչ", "no_cards_found": "Քարտեր չեն գտնվել", @@ -1405,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Դրամապանակներ", "warning": "Զգուշացում", + "watching": "Միայն դիտել", "wc_action_approve": "Հաստատել", "wc_approve_request_title": "Հաստատել հարցումը", "wc_connect_request_title": "Միացման հարցում", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 478dc7d68..6186f8e89 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Kirim notifikasi tentang transaksi baru", "new_wallet": "Dompet Baru", "newConnection": "Koneksi Baru", + "nft_import_failed": "Tidak dapat mengimpor NFT ini. Periksa alamatnya dan coba lagi.", + "nft_not_held_by_wallet": "Tidak dipegang oleh dompet ini", + "nft_send_not_an_nft": "Aset ini bukan NFT, jadi tidak bisa dikirim dari sini.", + "nft_some_not_loaded": "Beberapa NFT tidak dapat dimuat", "nfts": "NFT", "no": "Tidak", "no_cards_found": "Tidak ada kartu ditemukan", @@ -1408,6 +1412,7 @@ "walletConnect": "WalletConnect", "wallets": "Dompet", "warning": "Peringatan", + "watching": "Lihat saja", "wc_action_approve": "Setujui", "wc_approve_request_title": "Setujui permintaan", "wc_connect_request_title": "Hubungkan permintaan", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 1fc6ff179..b9b5a5cb6 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Invia notifiche per le nuove transazioni", "new_wallet": "Nuovo wallet", "newConnection": "Nuova connessione", + "nft_import_failed": "Impossibile importare questo NFT. Controlla l'indirizzo e riprova.", + "nft_not_held_by_wallet": "Non trattenuto da questo portafoglio", + "nft_send_not_an_nft": "Questa risorsa non è un NFT, quindi non può essere inviata da qui.", + "nft_some_not_loaded": "Impossibile caricare alcuni NFT", "nfts": "NFT", "no": "No", "no_cards_found": "Nessuna carta trovata", @@ -847,6 +851,7 @@ "received": "Ricevuto", "receiving": "Ricezione", "recieving": "Ricezione", + "recipient_account_creation_fee": "È necessario un extra di ${amount} per creare un account token per il destinatario, perché non ne ha ancora uno.", "recipient_address": "Indirizzo del destinatario", "reconnect": "Riconnetti", "reconnect_alert_text": "Sei sicuro di voler riconnetterti?", @@ -1406,6 +1411,7 @@ "walletConnect": "WalletConnect", "wallets": "Portafogli", "warning": "Attenzione", + "watching": "Solo visualizzazione", "wc_action_approve": "Approva", "wc_approve_request_title": "Approva la richiesta", "wc_connect_request_title": "Richiesta di connessione", @@ -1478,6 +1484,5 @@ "zcash_card_enable_later": "Puoi sempre abilitare questa carta in seguito nelle impostazioni", "zcash_card_missing_funds": "Fondi mancanti?", "zcash_card_scan": "Scansiona", - "zcash_card_warning": "Non chiudere l'app finché la procedura non è completata, altrimenti sarà necessario riavviare il processo da zero.", - "recipient_account_creation_fee": "È necessario un extra di ${amount} per creare un account token per il destinatario, perché non ne ha ancora uno." + "zcash_card_warning": "Non chiudere l'app finché la procedura non è completata, altrimenti sarà necessario riavviare il processo da zero." } \ No newline at end of file diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index b6836bf67..3b7a4fd63 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -686,6 +686,10 @@ "new_transactions_notifications": "新しいトランザクションの通知を送信する", "new_wallet": "新しいウォレット", "newConnection": "新しい接続", + "nft_import_failed": "このNFTをインポートできませんでした。アドレスを確認して再試行してください。", + "nft_not_held_by_wallet": "このウォレットには保持されていません", + "nft_send_not_an_nft": "このアセットはNFTではないため、ここから送信することはできません。", + "nft_some_not_loaded": "一部のNFTを読み込めませんでした", "nfts": "NFTs", "no": "いいえ", "no_cards_found": "カードが見つかりません", @@ -847,6 +851,7 @@ "received": "受信", "receiving": "受信", "recieving": "受信中", + "recipient_account_creation_fee": "受信者はまだトークン アカウントを持っていないため、追加の ${amount} が必要です。", "recipient_address": "受取人アドレス", "reconnect": "再接続", "reconnect_alert_text": "再接続してもよろしいですか?", @@ -1405,6 +1410,7 @@ "walletConnect": "WalletConnect", "wallets": "ウォレット", "warning": "警告", + "watching": "閲覧のみ", "wc_action_approve": "承認", "wc_approve_request_title": "リクエストの承認", "wc_connect_request_title": "接続リクエスト", @@ -1476,6 +1482,5 @@ "zcash_card_enable_later": "このカードは後から設定でいつでも有効にできます", "zcash_card_missing_funds": "資金が見当たりませんか?", "zcash_card_scan": "スキャン", - "zcash_card_warning": "手順が完了するまでアプリを閉じないでください。閉じると、このプロセスを最初からやり直す必要があります。", - "recipient_account_creation_fee": "受信者はまだトークン アカウントを持っていないため、追加の ${amount} が必要です。" + "zcash_card_warning": "手順が完了するまでアプリを閉じないでください。閉じると、このプロセスを最初からやり直す必要があります。" } \ No newline at end of file diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 852be390a..8533a9a11 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "새 거래에 대한 알림 보내기", "new_wallet": "새 지갑", "newConnection": "새 연결", + "nft_import_failed": "이 NFT를 가져올 수 없습니다. 주소를 확인하고 다시 시도하세요.", + "nft_not_held_by_wallet": "이 지갑에 보관되지 않음", + "nft_send_not_an_nft": "이 자산은 NFT가 아니므로 여기에서 보낼 수 없습니다.", + "nft_some_not_loaded": "일부 NFT를 로드할 수 없습니다", "nfts": "NFTs", "no": "아니요", "no_cards_found": "카드를 찾을 수 없습니다", @@ -846,6 +850,7 @@ "received": "수신됨", "receiving": "수신", "recieving": "수신 중", + "recipient_account_creation_fee": "아직 토큰 계정이 없기 때문에 수신자를 위한 토큰 계정을 생성하려면 추가 ${amount}가 필요합니다.", "recipient_address": "수신자 주소", "reconnect": "재연결", "reconnect_alert_text": "정말 다시 연결하시겠습니까?", @@ -1404,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "지갑", "warning": "경고", + "watching": "보기 전용", "wc_action_approve": "승인", "wc_approve_request_title": "요청 승인", "wc_connect_request_title": "연결 요청", @@ -1475,6 +1481,5 @@ "zcash_card_enable_later": "설정에서 언제든지 나중에 이 카드를 활성화할 수 있습니다.", "zcash_card_missing_funds": "자금이 사라졌나요?", "zcash_card_scan": "스캔", - "zcash_card_warning": "절차가 완료될 때까지 앱을 닫지 마십시오. 닫으면 이 과정을 처음부터 다시 시작해야 합니다.", - "recipient_account_creation_fee": "아직 토큰 계정이 없기 때문에 수신자를 위한 토큰 계정을 생성하려면 추가 ${amount}가 필요합니다." + "zcash_card_warning": "절차가 완료될 때까지 앱을 닫지 마십시오. 닫으면 이 과정을 처음부터 다시 시작해야 합니다." } \ No newline at end of file diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 879e8382c..4cdccba42 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "အရောင်းအဝယ်အသစ်များအကြောင်း အသိပေးချက်များ ပို့ပါ", "new_wallet": "အိတ်အသစ်", "newConnection": "ချိတ်ဆက်မှုအသစ်", + "nft_import_failed": "ဤ NFT ကို မတင်သွင်းနိုင်ပါ။ လိပ်စာကို စစ်ဆေးပြီး ထပ်စမ်းကြည့်ပါ။", + "nft_not_held_by_wallet": "ဒီပိုက်ဆံအိတ်က မကိုင်ဘူး။", + "nft_send_not_an_nft": "ဤပိုင်ဆိုင်မှုသည် NFT မဟုတ်ပါ။ ထို့ကြောင့် ၎င်းကို ဤနေရာမှ ပေးပို့၍မရပါ။", + "nft_some_not_loaded": "NFT အချို့ကို တင်၍မရပါ။", "nfts": "NFTs", "no": "မဟုတ်ပါ", "no_cards_found": "ကဒ် မတွေ့ပါ", @@ -846,6 +850,7 @@ "received": "လက်ခံရရှိ", "receiving": "လက်ခံနေသည်", "recieving": "လက်ခံနေသည်", + "recipient_account_creation_fee": "လက်ခံသူအတွက် တိုကင်အကောင့်တစ်ခုဖန်တီးရန် ၎င်းတို့တွင် တစ်ခုမရှိသေးသောကြောင့် အပို ${amount} လိုအပ်ပါသည်။", "recipient_address": "လက်ခံသူ၏ လိပ်စာ", "reconnect": "ပြန်လည်ချိတ်ဆက်ရန်", "reconnect_alert_text": "ပြန်လည်ချိတ်ဆက်လိုသည်မှာ သေချာပါသလား?", @@ -1404,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "ဝေါလက်များ", "warning": "သတိပေးချက်", + "watching": "ကြည့်ရန်သာ", "wc_action_approve": "အတည်ပြု", "wc_approve_request_title": "တောင်းဆိုချက်ကို အတည်ပြုပါ။", "wc_connect_request_title": "တောင်းဆိုချက်ကို ချိတ်ဆက်ပါ။", @@ -1475,6 +1481,5 @@ "zcash_card_enable_later": "သင်သည် ဤကတ်ကို ဆက်တင်များတွင် နောက်မှ အချိန်မရွေး ဖွင့်နိုင်ပါသည်။", "zcash_card_missing_funds": "ငွေကြေး ပျောက်ဆုံးနေပါသလား?", "zcash_card_scan": "စကင်", - "zcash_card_warning": "လုပ်ငန်းစဉ် ပြီးဆုံးသည်အထိ အက်ပ်ကို မပိတ်ပါနှင့်။ ပိတ်လိုက်ပါက ဤလုပ်ငန်းစဉ်ကို အစမှ ပြန်လည်စတင်ရမည်ဖြစ်သည်။", - "recipient_account_creation_fee": "လက်ခံသူအတွက် တိုကင်အကောင့်တစ်ခုဖန်တီးရန် ၎င်းတို့တွင် တစ်ခုမရှိသေးသောကြောင့် အပို ${amount} လိုအပ်ပါသည်။" + "zcash_card_warning": "လုပ်ငန်းစဉ် ပြီးဆုံးသည်အထိ အက်ပ်ကို မပိတ်ပါနှင့်။ ပိတ်လိုက်ပါက ဤလုပ်ငန်းစဉ်ကို အစမှ ပြန်လည်စတင်ရမည်ဖြစ်သည်။" } \ No newline at end of file diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 9f7bc6d20..2e804baec 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Stuur meldingen over nieuwe transacties", "new_wallet": "Nieuwe wallet", "newConnection": "Nieuwe verbinding", + "nft_import_failed": "Kan deze NFT niet importeren. Controleer het adres en probeer het opnieuw.", + "nft_not_held_by_wallet": "Niet in handen van deze portemonnee", + "nft_send_not_an_nft": "Dit item is geen NFT en kan dus niet vanaf hier worden verzonden.", + "nft_some_not_loaded": "Sommige NFT's konden niet worden geladen", "nfts": "NFT's", "no": "Nee", "no_cards_found": "Geen kaarten gevonden", @@ -846,6 +850,7 @@ "received": "Ontvangen", "receiving": "Bezig met ontvangen", "recieving": "Bezig met ontvangen", + "recipient_account_creation_fee": "Er is een extra ${amount} nodig om een ​​tokenaccount voor de ontvanger aan te maken, omdat deze er nog geen heeft.", "recipient_address": "Adres ontvanger", "reconnect": "Opnieuw verbinden", "reconnect_alert_text": "Weet je zeker dat je opnieuw verbinding wilt maken?", @@ -1405,6 +1410,7 @@ "walletConnect": "WalletConnect", "wallets": "Wallets", "warning": "Waarschuwing", + "watching": "Alleen bekijken", "wc_action_approve": "Goedkeuren", "wc_approve_request_title": "Aanvraag goedkeuren", "wc_connect_request_title": "Verbind verzoek", @@ -1476,6 +1482,5 @@ "zcash_card_enable_later": "Je kunt deze kaart later altijd inschakelen via de instellingen", "zcash_card_missing_funds": "Fondsen ontbreken?", "zcash_card_scan": "Scannen", - "zcash_card_warning": "Sluit de app niet voordat de procedure is voltooid. Als je dit wel doet, moet dit proces helemaal opnieuw worden gestart.", - "recipient_account_creation_fee": "Er is een extra ${amount} nodig om een ​​tokenaccount voor de ontvanger aan te maken, omdat deze er nog geen heeft." + "zcash_card_warning": "Sluit de app niet voordat de procedure is voltooid. Als je dit wel doet, moet dit proces helemaal opnieuw worden gestart." } \ No newline at end of file diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index 013896b11..082138e3d 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Wysyłaj powiadomienia o nowych transakcjach", "new_wallet": "Nowy portfel", "newConnection": "Nowe połączenie", + "nft_import_failed": "Nie można zaimportować tego NFT. Sprawdź adres i spróbuj ponownie.", + "nft_not_held_by_wallet": "Nie trzyma się tego portfela", + "nft_send_not_an_nft": "Ten zasób nie jest NFT, więc nie można go stąd wysłać.", + "nft_some_not_loaded": "Nie można załadować niektórych plików NFT", "nfts": "NFT-y", "no": "Nie", "no_cards_found": "Nie znaleziono żadnych kart", @@ -846,6 +850,7 @@ "received": "Otrzymane", "receiving": "Odbieranie", "recieving": "Odbieranie", + "recipient_account_creation_fee": "Do założenia konta tokenowego dla odbiorcy potrzebna jest dodatkowa ${amount}, ponieważ jeszcze takiego nie posiada.", "recipient_address": "Adres odbiorcy", "reconnect": "Połącz ponownie", "reconnect_alert_text": "Czy na pewno chcesz połączyć się ponownie?", @@ -1404,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Portfele", "warning": "Ostrzeżenie", + "watching": "Tylko przeglądaj", "wc_action_approve": "Zatwierdź", "wc_approve_request_title": "Zatwierdź żądanie", "wc_connect_request_title": "Połącz żądanie", @@ -1475,6 +1481,5 @@ "zcash_card_enable_later": "Zawsze możesz włączyć tę kartę później w ustawieniach", "zcash_card_missing_funds": "Brakuje środków?", "zcash_card_scan": "Skanuj", - "zcash_card_warning": "Nie zamykaj aplikacji do czasu zakończenia procedury. Jeśli to zrobisz, proces będzie musiał rozpocząć się od nowa.", - "recipient_account_creation_fee": "Do założenia konta tokenowego dla odbiorcy potrzebna jest dodatkowa ${amount}, ponieważ jeszcze takiego nie posiada." + "zcash_card_warning": "Nie zamykaj aplikacji do czasu zakończenia procedury. Jeśli to zrobisz, proces będzie musiał rozpocząć się od nowa." } \ No newline at end of file diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index c3ab4bae7..b3e100be2 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -686,6 +686,10 @@ "new_transactions_notifications": "Enviar notificações sobre novas transações", "new_wallet": "Nova carteira", "newConnection": "Nova conexão", + "nft_import_failed": "Não foi possível importar este NFT. Verifique o endereço e tente novamente.", + "nft_not_held_by_wallet": "Não mantido por esta carteira", + "nft_send_not_an_nft": "Este ativo não é um NFT, portanto não pode ser enviado daqui.", + "nft_some_not_loaded": "Alguns NFTs não puderam ser carregados", "nfts": "NFTs", "no": "Não", "no_cards_found": "Nenhum cartão encontrado", @@ -848,6 +852,7 @@ "received": "Recebido", "receiving": "Recebendo", "recieving": "A receber", + "recipient_account_creation_fee": "É necessário um ${amount} extra para criar uma conta token para o destinatário, porque ele ainda não tem uma.", "recipient_address": "Endereço do destinatário", "reconnect": "Reconectar", "reconnect_alert_text": "Tem certeza de que deseja reconectar?", @@ -1407,6 +1412,7 @@ "walletConnect": "WalletConnect", "wallets": "Carteiras", "warning": "Aviso", + "watching": "Somente visualizar", "wc_action_approve": "Aprovar", "wc_approve_request_title": "Aprovar solicitação", "wc_connect_request_title": "Solicitação de conexão", @@ -1478,6 +1484,5 @@ "zcash_card_enable_later": "Você sempre pode ativar este cartão mais tarde nas configurações", "zcash_card_missing_funds": "Fundos em falta?", "zcash_card_scan": "Escanear", - "zcash_card_warning": "Não feche o aplicativo até que o procedimento seja concluído; se você fizer isso, esse processo precisará ser reiniciado do zero.", - "recipient_account_creation_fee": "É necessário um ${amount} extra para criar uma conta token para o destinatário, porque ele ainda não tem uma." + "zcash_card_warning": "Não feche o aplicativo até que o procedimento seja concluído; se você fizer isso, esse processo precisará ser reiniciado do zero." } \ No newline at end of file diff --git a/res/values/strings_pt_BR.arb b/res/values/strings_pt_BR.arb index c552992e0..b91f6b0cc 100644 --- a/res/values/strings_pt_BR.arb +++ b/res/values/strings_pt_BR.arb @@ -652,6 +652,10 @@ "network_address_detected": "Endereço ${network} detectado", "network_prefix_on": "na", "network_switch_failed": "Falha ao mudar para a rede solicitada. O pagamento não foi aplicado, tente novamente.", + "nft_import_failed": "Não foi possível importar este NFT. Verifique o endereço e tente novamente.", + "nft_not_held_by_wallet": "Não mantido por esta carteira", + "nft_send_not_an_nft": "Este ativo não é um NFT, portanto não pode ser enviado daqui.", + "nft_some_not_loaded": "Alguns NFTs não puderam ser carregados", "unsupported_token_requested": "Este pedido de pagamento é para um token que não conseguimos identificar na rede selecionada.", "new_first_wallet_text": "Manter sua cripto segura é moleza", "new_node_testing": "Teste de novo nó", @@ -1347,6 +1351,7 @@ "walletConnect": "WalletConnect", "wallets": "Carteiras", "warning": "Aviso", + "watching": "Somente visualizar", "wc_approve_request_title": "Aprovar solicitação", "wc_connect_request_title": "Solicitação de conexão", "wc_connected_to": "Conectado a ${name}", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index ca12a089d..696eb803b 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Отправлять уведомления о новых транзакциях", "new_wallet": "Новый кошелек", "newConnection": "Новое подключение", + "nft_import_failed": "Не удалось импортировать этот NFT. Проверьте адрес и повторите попытку.", + "nft_not_held_by_wallet": "Не хранится в этом кошельке", + "nft_send_not_an_nft": "Этот актив не является NFT, поэтому его нельзя отправить отсюда.", + "nft_some_not_loaded": "Некоторые NFT не удалось загрузить.", "nfts": "NFT", "no": "Нет", "no_cards_found": "Карты не найдены", @@ -847,6 +851,7 @@ "received": "Получено", "receiving": "Получение", "recieving": "Получение", + "recipient_account_creation_fee": "Для создания учетной записи токена для получателя требуется дополнительная сумма ${amount}, поскольку у него ее еще нет.", "recipient_address": "Адрес получателя", "reconnect": "Переподключиться", "reconnect_alert_text": "Вы уверены, что хотите переподключиться?", @@ -1405,6 +1410,7 @@ "walletConnect": "WalletConnect", "wallets": "Кошельки", "warning": "Предупреждение", + "watching": "Только просмотр", "wc_action_approve": "Одобрить", "wc_approve_request_title": "Утвердить запрос", "wc_connect_request_title": "Запрос на подключение", @@ -1476,6 +1482,5 @@ "zcash_card_enable_later": "Вы всегда можете включить эту карту позже в настройках", "zcash_card_missing_funds": "Не хватает средств?", "zcash_card_scan": "Сканировать", - "zcash_card_warning": "Не закрывайте приложение до завершения процедуры. Если вы это сделаете, процесс придется перезапустить с нуля.", - "recipient_account_creation_fee": "Для создания учетной записи токена для получателя требуется дополнительная сумма ${amount}, поскольку у него ее еще нет." + "zcash_card_warning": "Не закрывайте приложение до завершения процедуры. Если вы это сделаете, процесс придется перезапустить с нуля." } \ No newline at end of file diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 818a8f6c1..b979ea9d5 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "ส่งการแจ้งเตือนเกี่ยวกับธุรกรรมใหม่", "new_wallet": "กระเป๋าเงินใหม่", "newConnection": "การเชื่อมต่อใหม่", + "nft_import_failed": "ไม่สามารถนำเข้า NFT นี้ ตรวจสอบที่อยู่แล้วลองอีกครั้ง", + "nft_not_held_by_wallet": "ไม่ได้ถือโดยกระเป๋าเงินนี้", + "nft_send_not_an_nft": "เนื้อหานี้ไม่ใช่ NFT ดังนั้นจึงไม่สามารถส่งจากที่นี่ได้", + "nft_some_not_loaded": "NFT บางตัวไม่สามารถโหลดได้", "nfts": "NFTs", "no": "ไม่", "no_cards_found": "ไม่พบการ์ด", @@ -846,6 +850,7 @@ "received": "ได้รับ", "receiving": "กำลังรับ", "recieving": "กำลังรับ", + "recipient_account_creation_fee": "จำเป็นต้องมี ${amount} เพิ่มเติมเพื่อสร้างบัญชีโทเค็นสำหรับผู้รับ เนื่องจากยังไม่มีบัญชี", "recipient_address": "ที่อยู่ผู้รับ", "reconnect": "เชื่อมต่อใหม่", "reconnect_alert_text": "คุณแน่ใจหรือไม่ว่าต้องการเชื่อมต่อใหม่?", @@ -1404,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "กระเป๋าเงิน", "warning": "คำเตือน", + "watching": "ดูอย่างเดียว", "wc_action_approve": "อนุมัติ", "wc_approve_request_title": "อนุมัติคำขอ", "wc_connect_request_title": "เชื่อมต่อคำขอ", @@ -1475,6 +1481,5 @@ "zcash_card_enable_later": "คุณสามารถเปิดใช้งานการ์ดนี้ได้เสมอในภายหลังในการตั้งค่า", "zcash_card_missing_funds": "ยอดเงินหายไป?", "zcash_card_scan": "สแกน", - "zcash_card_warning": "อย่าปิดแอปจนกว่าขั้นตอนจะเสร็จสิ้น หากคุณปิดแอป กระบวนการนี้จะต้องเริ่มใหม่ตั้งแต่ต้น", - "recipient_account_creation_fee": "จำเป็นต้องมี ${amount} เพิ่มเติมเพื่อสร้างบัญชีโทเค็นสำหรับผู้รับ เนื่องจากยังไม่มีบัญชี" + "zcash_card_warning": "อย่าปิดแอปจนกว่าขั้นตอนจะเสร็จสิ้น หากคุณปิดแอป กระบวนการนี้จะต้องเริ่มใหม่ตั้งแต่ต้น" } \ No newline at end of file diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 374cb1616..7582cbe83 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Magpadala ng mga abiso tungkol sa mga bagong transaksyon", "new_wallet": "Bagong Wallet", "newConnection": "Bagong Koneksyon", + "nft_import_failed": "Hindi ma-import ang NFT na ito. Tingnan ang address at subukang muli.", + "nft_not_held_by_wallet": "Hindi hawak ng wallet na ito", + "nft_send_not_an_nft": "Ang asset na ito ay hindi isang NFT, kaya hindi ito maipadala mula rito.", + "nft_some_not_loaded": "Hindi ma-load ang ilang NFT", "nfts": "Mga NFT", "no": "Hindi", "no_cards_found": "Walang nahanap na card", @@ -846,6 +850,7 @@ "received": "Natanggap", "receiving": "Pagtanggap", "recieving": "Tumatanggap", + "recipient_account_creation_fee": "Kailangan ng dagdag na ${amount} para gumawa ng token account para sa tatanggap, dahil wala pa sila nito.", "recipient_address": "Address ng tatanggap", "reconnect": "Muling kumonekta", "reconnect_alert_text": "Sigurado ka bang gusto mong kumonekta muli?", @@ -1404,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Mga Wallet", "warning": "Babala", + "watching": "Tingnan lamang", "wc_action_approve": "Aprubahan", "wc_approve_request_title": "Aprubahan ang kahilingan", "wc_connect_request_title": "Kahilingan sa pagkonekta", @@ -1475,6 +1481,5 @@ "zcash_card_enable_later": "Maaari mo itong paganahin anumang oras sa ibang pagkakataon sa mga setting", "zcash_card_missing_funds": "Nawawalang pondo?", "zcash_card_scan": "I-scan", - "zcash_card_warning": "Huwag isara ang app hanggang sa makumpleto ang proseso; kung gagawin mo ito, kakailanganing magsimula muli mula sa simula.", - "recipient_account_creation_fee": "Kailangan ng dagdag na ${amount} para gumawa ng token account para sa tatanggap, dahil wala pa sila nito." + "zcash_card_warning": "Huwag isara ang app hanggang sa makumpleto ang proseso; kung gagawin mo ito, kakailanganing magsimula muli mula sa simula." } \ No newline at end of file diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 82b27c80f..b4ecc86de 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Yeni işlemler için bildirim gönder", "new_wallet": "Yeni Cüzdan", "newConnection": "Yeni Bağlantı", + "nft_import_failed": "Bu NFT içe aktarılamadı. Adresi kontrol edip tekrar deneyin.", + "nft_not_held_by_wallet": "Bu cüzdanda tutulmuyor", + "nft_send_not_an_nft": "Bu varlık bir NFT olmadığından buradan gönderilemez.", + "nft_some_not_loaded": "Bazı NFT'ler yüklenemedi", "nfts": "NFT'ler", "no": "Hayır", "no_cards_found": "Kart bulunamadı", @@ -846,6 +850,7 @@ "received": "Alındı", "receiving": "Alma", "recieving": "Alınıyor", + "recipient_account_creation_fee": "Alıcının henüz bir token hesabı olmadığı için, alıcı için bir token hesabı oluşturmak için fazladan ${amount} gerekiyor.", "recipient_address": "Alıcı adresi", "reconnect": "Yeniden bağlan", "reconnect_alert_text": "Yeniden bağlanmak istediğinizden emin misiniz?", @@ -1404,6 +1409,7 @@ "walletConnect": "WalletConnect", "wallets": "Cüzdanlar", "warning": "Uyarı", + "watching": "Yalnızca görüntüle", "wc_action_approve": "Onayla", "wc_approve_request_title": "İsteği onayla", "wc_connect_request_title": "Bağlantı isteği", @@ -1475,6 +1481,5 @@ "zcash_card_enable_later": "Bu kartı daha sonra ayarlardan her zaman etkinleştirebilirsiniz", "zcash_card_missing_funds": "Bakiye eksik mi?", "zcash_card_scan": "Tara", - "zcash_card_warning": "İşlem tamamlanana kadar uygulamayı kapatmayın; aksi takdirde bu işlemin baştan yeniden başlatılması gerekecektir.", - "recipient_account_creation_fee": "Alıcının henüz bir token hesabı olmadığı için, alıcı için bir token hesabı oluşturmak için fazladan ${amount} gerekiyor." + "zcash_card_warning": "İşlem tamamlanana kadar uygulamayı kapatmayın; aksi takdirde bu işlemin baştan yeniden başlatılması gerekecektir." } \ No newline at end of file diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index 6558649ff..3c7563455 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "Надсилати сповіщення про нові транзакції", "new_wallet": "Новий гаманець", "newConnection": "Нове з’єднання", + "nft_import_failed": "Не вдалося імпортувати цей NFT. Перевірте адресу та повторіть спробу.", + "nft_not_held_by_wallet": "Не утримується цим гаманцем", + "nft_send_not_an_nft": "Цей актив не є NFT, тому його не можна надіслати звідси.", + "nft_some_not_loaded": "Деякі NFT не вдалося завантажити", "nfts": "NFT", "no": "Ні", "no_cards_found": "Картки не знайдено", @@ -846,6 +850,7 @@ "received": "Отримано", "receiving": "Отримання", "recieving": "Отримання", + "recipient_account_creation_fee": "Потрібна додаткова сума в розмірі ${amount}, щоб створити обліковий запис-токен для одержувача, оскільки він його ще не має.", "recipient_address": "Адреса отримувача", "reconnect": "Перепідключити", "reconnect_alert_text": "Ви впевнені, що хочете перепідключитися?", @@ -1405,6 +1410,7 @@ "walletConnect": "WalletConnect", "wallets": "Гаманці", "warning": "Попередження", + "watching": "Тільки перегляд", "wc_action_approve": "Схвалити", "wc_approve_request_title": "Підтвердити запит", "wc_connect_request_title": "Запит на підключення", @@ -1476,6 +1482,5 @@ "zcash_card_enable_later": "Ви завжди можете ввімкнути цю картку пізніше в налаштуваннях", "zcash_card_missing_funds": "Зникли кошти?", "zcash_card_scan": "Сканувати", - "zcash_card_warning": "Не закривайте застосунок, доки процедура не завершиться. Якщо ви це зробите, цей процес потрібно буде перезапустити з нуля.", - "recipient_account_creation_fee": "Потрібна додаткова сума в розмірі ${amount}, щоб створити обліковий запис-токен для одержувача, оскільки він його ще не має." + "zcash_card_warning": "Не закривайте застосунок, доки процедура не завершиться. Якщо ви це зробите, цей процес потрібно буде перезапустити з нуля." } \ No newline at end of file diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index f241478ab..ec60f8ce1 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -685,6 +685,10 @@ "new_transactions_notifications": "نئے لین دین کے بارے میں اطلاعات بھیجیں", "new_wallet": "نیا والیٹ", "newConnection": "نیا کنکشن", + "nft_import_failed": "اس NFT کو درآمد نہیں کیا جا سکا۔ پتہ چیک کریں اور دوبارہ کوشش کریں۔", + "nft_not_held_by_wallet": "اس بٹوے کے پاس نہیں ہے۔", + "nft_send_not_an_nft": "یہ اثاثہ NFT نہیں ہے، اس لیے اسے یہاں سے نہیں بھیجا جا سکتا۔", + "nft_some_not_loaded": "کچھ NFTs کو لوڈ نہیں کیا جا سکا", "nfts": "NFTs", "no": "نہیں", "no_cards_found": "کوئی کارڈ نہیں ملا", @@ -848,6 +852,7 @@ "received": "موصول ہوا", "receiving": "وصولی", "recieving": "وصولی", + "recipient_account_creation_fee": "وصول کنندہ کے لیے ٹوکن اکاؤنٹ بنانے کے لیے ایک اضافی ${amount} کی ضرورت ہے، کیونکہ ان کے پاس ابھی تک کوئی اکاؤنٹ نہیں ہے۔", "recipient_address": "وصول کنندہ کا پتہ", "reconnect": "دوبارہ جڑیں", "reconnect_alert_text": "کیا آپ واقعی دوبارہ کنیکٹ کرنا چاہتے ہیں؟", @@ -1406,6 +1411,7 @@ "walletConnect": "WalletConnect", "wallets": "والٹس", "warning": "انتباہ", + "watching": "صرف دیکھیں", "wc_action_approve": "منظور کریں", "wc_approve_request_title": "درخواست منظور کریں۔", "wc_connect_request_title": "رابطہ کی درخواست", @@ -1477,6 +1483,5 @@ "zcash_card_enable_later": "آپ اس کارڈ کو بعد میں بھی ترتیبات میں فعال کر سکتے ہیں", "zcash_card_missing_funds": "فنڈز غائب ہیں؟", "zcash_card_scan": "اسکین کریں", - "zcash_card_warning": "طریقہ کار مکمل ہونے تک ایپ بند نہ کریں۔ اگر آپ ایسا کریں گے تو یہ عمل شروع سے دوبارہ شروع کرنا پڑے گا۔", - "recipient_account_creation_fee": "وصول کنندہ کے لیے ٹوکن اکاؤنٹ بنانے کے لیے ایک اضافی ${amount} کی ضرورت ہے، کیونکہ ان کے پاس ابھی تک کوئی اکاؤنٹ نہیں ہے۔" + "zcash_card_warning": "طریقہ کار مکمل ہونے تک ایپ بند نہ کریں۔ اگر آپ ایسا کریں گے تو یہ عمل شروع سے دوبارہ شروع کرنا پڑے گا۔" } \ No newline at end of file diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index 594657af9..d6506bbd1 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -686,6 +686,10 @@ "new_transactions_notifications": "Gửi thông báo về các giao dịch mới", "new_wallet": "Ví mới", "newConnection": "Kết nối mới", + "nft_import_failed": "Không thể nhập NFT này. Hãy kiểm tra địa chỉ và thử lại.", + "nft_not_held_by_wallet": "Không được giữ bởi ví này", + "nft_send_not_an_nft": "Nội dung này không phải là NFT nên không thể gửi nó từ đây.", + "nft_some_not_loaded": "Không thể tải một số NFT", "nfts": "NFT", "no": "Không", "no_cards_found": "Không tìm thấy thẻ nào", @@ -847,6 +851,7 @@ "received": "Đã nhận", "receiving": "Đang nhận", "recieving": "Đang nhận", + "recipient_account_creation_fee": "Cần thêm ${amount} để tạo tài khoản mã thông báo cho người nhận vì họ chưa có tài khoản.", "recipient_address": "Địa chỉ người nhận", "reconnect": "Kết nối lại", "reconnect_alert_text": "Bạn có chắc chắn muốn kết nối lại không?", @@ -1405,6 +1410,7 @@ "walletConnect": "WalletConnect", "wallets": "Ví", "warning": "Cảnh báo", + "watching": "Chỉ xem", "wc_action_approve": "Phê duyệt", "wc_approve_request_title": "Phê duyệt yêu cầu", "wc_connect_request_title": "Yêu cầu kết nối", @@ -1476,6 +1482,5 @@ "zcash_card_enable_later": "Bạn luôn có thể bật thẻ này sau trong phần cài đặt", "zcash_card_missing_funds": "Thiếu tiền?", "zcash_card_scan": "Quét", - "zcash_card_warning": "Không đóng ứng dụng cho đến khi quy trình hoàn tất; nếu bạn làm vậy, quy trình này sẽ phải bắt đầu lại từ đầu.", - "recipient_account_creation_fee": "Cần thêm ${amount} để tạo tài khoản mã thông báo cho người nhận vì họ chưa có tài khoản." + "zcash_card_warning": "Không đóng ứng dụng cho đến khi quy trình hoàn tất; nếu bạn làm vậy, quy trình này sẽ phải bắt đầu lại từ đầu." } \ No newline at end of file diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index def4de265..f0758d0ed 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -686,6 +686,10 @@ "new_transactions_notifications": "Firanṣẹ àwọn ìwífúnni nípa àwọn ìdúnàdúrà tuntun", "new_wallet": "Apamọwọ tuntun", "newConnection": "Asopọ Tuntun", + "nft_import_failed": "Ko le gbe NFT yi wọle. Ṣayẹwo adirẹsi naa ki o tun gbiyanju lẹẹkansi.", + "nft_not_held_by_wallet": "Ko waye nipasẹ yi apamọwọ", + "nft_send_not_an_nft": "Ohun-ini yii kii ṣe NFT, nitorinaa ko le firanṣẹ lati ibi.", + "nft_some_not_loaded": "Diẹ ninu awọn NFT ko le ṣe kojọpọ", "nfts": "NFTs", "no": "Rárá", "no_cards_found": "A kò rí kaadi kankan", @@ -847,6 +851,7 @@ "received": "Ti gbà", "receiving": "Gbigba", "recieving": "Gbigba", + "recipient_account_creation_fee": "A nilo afikun ${amount} lati ṣẹda akọọlẹ ami-ami kan fun olugba, nitori wọn ko ni ọkan sibẹsibẹ.", "recipient_address": "Àdírẹ́sì olùgbà", "reconnect": "Tun so pọ̀", "reconnect_alert_text": "Ṣé ó dá ẹ lójú pé ẹ fẹ́ tún sopọ̀?", @@ -1405,6 +1410,7 @@ "walletConnect": "WalletConnect", "wallets": "Àwọn wálẹ́ẹ̀tì", "warning": "Ìkìlọ̀", + "watching": "Wo nikan", "wc_action_approve": "Fọwọ́sí", "wc_approve_request_title": "fọwọsi ìbéèrè", "wc_connect_request_title": "Sopọ ìbéèrè", @@ -1476,6 +1482,5 @@ "zcash_card_enable_later": "O le ma a mu kaadi yii ṣiṣẹ nigbamii ninu awọn eto", "zcash_card_missing_funds": "Ṣe owó rẹ sọnù?", "zcash_card_scan": "Ṣàwárí", - "zcash_card_warning": "Maṣe pa ohun elo naa titi ilana naa yoo fi pari; bí o bá ṣe bẹ́ẹ̀, ilana yìí yóò ní láti tún bẹ̀rẹ̀ láti ìbẹ̀rẹ̀.", - "recipient_account_creation_fee": "A nilo afikun ${amount} lati ṣẹda akọọlẹ ami-ami kan fun olugba, nitori wọn ko ni ọkan sibẹsibẹ." + "zcash_card_warning": "Maṣe pa ohun elo naa titi ilana naa yoo fi pari; bí o bá ṣe bẹ́ẹ̀, ilana yìí yóò ní láti tún bẹ̀rẹ̀ láti ìbẹ̀rẹ̀." } \ No newline at end of file diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 91e885810..9d4a11e75 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -686,6 +686,10 @@ "new_transactions_notifications": "发送新交易通知", "new_wallet": "新钱包", "newConnection": "新连接", + "nft_import_failed": "无法导入此 NFT。检查地址并重试。", + "nft_not_held_by_wallet": "不属于该钱包", + "nft_send_not_an_nft": "该资产不是 NFT,因此无法从这里发送。", + "nft_some_not_loaded": "部分NFT无法加载", "nfts": "NFTs", "no": "否", "no_cards_found": "未找到卡片", @@ -847,6 +851,7 @@ "received": "已接收", "receiving": "接收", "recieving": "接收中", + "recipient_account_creation_fee": "需要额外的 ${amount} 来为收件人创建一个令牌帐户,因为他们还没有令牌帐户。", "recipient_address": "收款地址", "reconnect": "重新连接", "reconnect_alert_text": "您确定要重新连接吗?", @@ -1405,6 +1410,7 @@ "walletConnect": "WalletConnect", "wallets": "钱包", "warning": "警告", + "watching": "仅供查看", "wc_action_approve": "批准", "wc_approve_request_title": "批准请求", "wc_connect_request_title": "连接请求", @@ -1476,6 +1482,5 @@ "zcash_card_enable_later": "您随时可以在设置中启用此卡", "zcash_card_missing_funds": "资金不见了?", "zcash_card_scan": "扫描", - "zcash_card_warning": "在该过程完成之前请勿关闭应用程序,否则该过程将需要从头重新开始。", - "recipient_account_creation_fee": "需要额外的 ${amount} 来为收件人创建一个令牌帐户,因为他们还没有令牌帐户。" + "zcash_card_warning": "在该过程完成之前请勿关闭应用程序,否则该过程将需要从头重新开始。" } \ No newline at end of file diff --git a/test/entities/saved_nfts_test.dart b/test/entities/saved_nfts_test.dart new file mode 100644 index 000000000..2802e820d --- /dev/null +++ b/test/entities/saved_nfts_test.dart @@ -0,0 +1,225 @@ +import "dart:io"; + +import "package:cake_wallet/entities/saved_nfts.dart"; +import "package:cake_wallet/entities/solana_nft_asset_model.dart"; +import "package:cake_wallet/entities/wallet_nft_response.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:cw_core/imported_nft.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path_provider_platform_interface/path_provider_platform_interface.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +class _FakePathProviderPlatform extends PathProviderPlatform { + _FakePathProviderPlatform(this.root); + + final String root; + + @override + Future getApplicationDocumentsPath() async => root; + + @override + Future getApplicationSupportPath() async => root; +} + +SolanaNFTAssetModel solanaNFT(String mint, {String? name, bool? isOwned}) => + SolanaNFTAssetModel(mint: mint, address: mint, name: name ?? mint, isOwned: isOwned); + +Future main() async { + final dataRoot = Directory("./test/data/saved_nfts"); + + const wallet = "MyWallet"; + const otherWallet = "OtherWallet"; + const otherChain = "eth"; + + final saved = SavedNFTs(); + + setUpAll(() async { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + dataRoot.createSync(recursive: true); + Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true); + + PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path); + + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await initDb(); + }); + + setUp(() async { + await db!.delete(ImportedNFT.tableName); + }); + + tearDownAll(() { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + }); + + group("SavedNFTs, solana", () { + test("keeps what was added and reads it back", () async { + await saved.addSolana(wallet, solanaNFT("MintA", name: "Ape", isOwned: true)); + + final stored = await saved.solanaNFTs(wallet); + + expect(stored.keys, ["MintA"]); + expect(stored["MintA"]!.name, "Ape"); + expect(stored["MintA"]!.isOwned, isTrue); + }); + + test("keeps wallets and chains apart", () async { + await saved.addSolana(wallet, solanaNFT("MintA")); + + expect(await saved.solanaNFTs(otherWallet), isEmpty); + expect(await saved.evmNFTs(wallet, otherChain), isEmpty); + }); + + test("adding the same mint twice replaces it rather than duplicating", () async { + await saved.addSolana(wallet, solanaNFT("MintA", name: "first")); + await saved.addSolana(wallet, solanaNFT("MintA", name: "second")); + + final stored = await saved.solanaNFTs(wallet); + + expect(stored.length, 1); + expect(stored["MintA"]!.name, "second"); + }); + + test("ignores an asset with no mint", () async { + await saved.addSolana(wallet, SolanaNFTAssetModel(address: "x")); + + expect(await saved.solanaNFTs(wallet), isEmpty); + }); + + test("removes what was sent", () async { + await saved.addSolana(wallet, solanaNFT("MintA")); + await saved.removeSolana(wallet, "MintA"); + + expect(await saved.solanaNFTs(wallet), isEmpty); + }); + + test("refresh updates what is still saved and does not add anything new", () async { + await saved.addSolana(wallet, solanaNFT("MintA", name: "old", isOwned: true)); + + await saved.refreshSolana(wallet, [ + solanaNFT("MintA", name: "new", isOwned: false), + solanaNFT("MintB", name: "never imported"), + ]); + + final stored = await saved.solanaNFTs(wallet); + + expect(stored.keys, ["MintA"]); + expect(stored["MintA"]!.name, "new"); + expect(stored["MintA"]!.isOwned, isFalse); + }); + + // A refresh runs for seconds while it fetches metadata per mint, and the + // user can import during it. + test("an import that lands during a refresh is not lost", () async { + await saved.addSolana(wallet, solanaNFT("MintA", name: "old")); + + final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]); + final importing = saved.addSolana(wallet, solanaNFT("MintNew", name: "imported")); + + await Future.wait([refreshing, importing]); + + final stored = await saved.solanaNFTs(wallet); + + expect(stored.keys.toSet(), {"MintA", "MintNew"}); + expect(stored["MintA"]!.name, "refreshed"); + }); + + test("an import during a refresh survives whichever order they run in", () async { + await saved.addSolana(wallet, solanaNFT("MintA")); + + final importing = saved.addSolana(wallet, solanaNFT("MintNew")); + final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]); + + await Future.wait([importing, refreshing]); + + expect((await saved.solanaNFTs(wallet)).keys.toSet(), {"MintA", "MintNew"}); + }); + + test("an NFT sent during a refresh is not put back", () async { + await saved.addSolana(wallet, solanaNFT("MintA")); + + final sending = saved.removeSolana(wallet, "MintA"); + final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]); + + await Future.wait([sending, refreshing]); + + expect(await saved.solanaNFTs(wallet), isEmpty); + }); + + test("an NFT sent after a refresh has already read the rows is not put back", () async { + await saved.addSolana(wallet, solanaNFT("MintA")); + + // The refresh starts first here, so its read of the rows is queued ahead of + // the delete. That is the ordering a real send hits: the user taps send + // while a refresh is already in flight. + final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]); + final sending = saved.removeSolana(wallet, "MintA"); + + await Future.wait([refreshing, sending]); + + expect(await saved.solanaNFTs(wallet), isEmpty); + }); + + test("many overlapping writes all land", () async { + await Future.wait([ + for (var i = 0; i < 20; i++) saved.addSolana(wallet, solanaNFT("Mint$i")), + ]); + + expect((await saved.solanaNFTs(wallet)).length, 20); + }); + }); + + group("SavedNFTs, evm", () { + NFTAssetModel evmNFT(String address, String tokenId, {String? name}) => NFTAssetModel( + tokenAddress: address, + tokenId: tokenId, + name: name ?? address, + symbol: "SYM", + normalizedMetadata: NormalizedMetadata( + name: name, + description: "a description", + image: "ipfs://QmAbC", + ), + ); + + test("an imported evm nft survives a restart", () async { + await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7", name: "Punk")); + + final stored = await saved.evmNFTs(wallet, otherChain); + + expect(stored.length, 1); + expect(stored.first.tokenAddress, "0xabc"); + expect(stored.first.tokenId, "7"); + expect(stored.first.name, "Punk"); + expect(stored.first.normalizedMetadata?.description, "a description"); + }); + + test("stores the ipfs image through the shared gateway", () async { + await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7")); + + final stored = await saved.evmNFTs(wallet, otherChain); + + expect(stored.first.normalizedMetadata?.imageUrl, "https://ipfs.io/ipfs/QmAbC"); + }); + + test("tells two token ids of one contract apart", () async { + await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7")); + await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "8")); + + expect((await saved.evmNFTs(wallet, otherChain)).length, 2); + }); + + test("keeps evm and solana entries apart by chain", () async { + await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7")); + await saved.addSolana(wallet, solanaNFT("MintA")); + + expect((await saved.evmNFTs(wallet, otherChain)).length, 1); + expect((await saved.solanaNFTs(wallet)).length, 1); + }); + }); +} diff --git a/test/entities/solana_nft_asset_model_test.dart b/test/entities/solana_nft_asset_model_test.dart new file mode 100644 index 000000000..45d2c925b --- /dev/null +++ b/test/entities/solana_nft_asset_model_test.dart @@ -0,0 +1,107 @@ +import "package:cake_wallet/entities/solana_nft_asset_model.dart"; +import "package:cw_core/utils/nft_text.dart"; +import "package:cake_wallet/entities/wallet_nft_response.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("SolanaNFTAssetModel", () { + Map 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 evmJson({String name = "Punk", String image = "ipfs://QmAbC"}) => { + "token_address": "0xabc", + "token_id": "7", + "contract_type": "ERC721", + "name": name, + "symbol": "PNK", + "normalized_metadata": { + "name": name, + "description": "a description", + "image": image, + }, + }; + + test("sanitises the text a contract controls", () { + final asset = NFTAssetModel.fromJson(evmJson(name: "Punk\u2028Address: 0xdead")); + + expect(asset.name, "Punk Address: 0xdead"); + expect(asset.normalizedMetadata?.name, "Punk Address: 0xdead"); + }); + + test("routes an ipfs image through the same gateway as solana", () { + expect( + NFTAssetModel.fromJson(evmJson()).normalizedMetadata?.imageUrl, + "https://ipfs.io/ipfs/QmAbC", + ); + }); + + test("leaves a plain gateway url alone instead of mangling it", () { + expect( + NFTAssetModel.fromJson(evmJson(image: "https://example.com/ipfs/QmAbC")) + .normalizedMetadata + ?.imageUrl, + "https://example.com/ipfs/QmAbC", + ); + }); + }); +} diff --git a/tool/configure.dart b/tool/configure.dart index e30bb6e9d..c11d020e1 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -341,9 +341,9 @@ import 'package:cw_core/wallet_service.dart'; import 'package:hive/hive.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart' as ledger; import 'package:trezor_flutter/trezor_flutter.dart' as trezor; +import "package:cw_core/hardware/hardware_wallet_service.dart"; import 'package:polyseed/polyseed.dart';"""; const moneroCWHeaders = """ -import 'package:cw_core/hardware/hardware_wallet_service.dart'; import 'package:cw_core/account.dart' as monero_account; import 'package:cw_core/get_height_by_date.dart'; import 'package:cw_core/monero_amount_format.dart'; @@ -489,6 +489,11 @@ WalletCredentials createMoneroNewWalletCredentials({required String name, requir Future syncTrezor(Object wallet); Map> debugCallLength(); Map getWalletCacheDebug(); + Future getNodeHeight(Object wallet); + String getLegacySeed(Object wallet, String langName); + bool isBackgroundSyncRunning(Object wallet); + Future startBackgroundSync(Object wallet); + Future stopBackgroundSync(Object wallet, String password); } abstract class MoneroSubaddressList { @@ -1020,7 +1025,16 @@ abstract class Solana { }); Future discoverAndAddWalletTokens(WalletBase wallet); - + + Future sendNFT( + WalletBase wallet, { + required String mintAddress, + required String destinationAddress, + String? name, + }); + Future getNFTOnChainMetadata(WalletBase wallet, String mintAddress); + Future> 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; From 73a739411f500249db9dc29a5f9db7dcdba54813 Mon Sep 17 00:00:00 2001 From: Patrick <42653152+lapatric@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:58:45 +0200 Subject: [PATCH 7/7] Delete CakePay Auth-token if the API returns 401 (#3538) * delete cakepay auth token on 401 * fix cakepay auth refresh * fix Cake Pay error handling --- lib/cake_pay/src/cake_pay_exceptions.dart | 6 ++ .../src/cards/cake_pay_buy_card_page.dart | 39 +++++++++--- .../src/cards/cake_pay_cards_page.dart | 17 ++++-- lib/cake_pay/src/services/cake_pay_api.dart | 13 ++++ .../src/services/cake_pay_service.dart | 61 +++++++++++++------ .../order_details/order_details_page.dart | 38 +++++++++++- .../cake_pay_buy_card_view_model.dart | 5 +- .../cake_pay_cards_list_view_model.dart | 13 ++-- lib/view_model/order_details_view_model.dart | 11 +++- res/values/strings_en.arb | 3 +- 10 files changed, 160 insertions(+), 46 deletions(-) create mode 100644 lib/cake_pay/src/cake_pay_exceptions.dart diff --git a/lib/cake_pay/src/cake_pay_exceptions.dart b/lib/cake_pay/src/cake_pay_exceptions.dart new file mode 100644 index 000000000..13d84b56d --- /dev/null +++ b/lib/cake_pay/src/cake_pay_exceptions.dart @@ -0,0 +1,6 @@ +class CakePayUnauthorizedException implements Exception { + const CakePayUnauthorizedException(); + + @override + String toString() => 'Cake Pay session is no longer valid, please log in again.'; +} diff --git a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart index 8e0a470cd..64551ca65 100644 --- a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart +++ b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_alert_modal.dart'; @@ -497,19 +498,37 @@ class CakePayBuyCardPage extends BasePage { } Future purchaseCard(BuildContext context) async { - bool isLogged = await cakePayBuyCardViewModel.isUserLogged; - if (!isLogged) { - cakePayBuyCardViewModel.isSimulatingFlow = false; - Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); - } else { - try { - await cakePayBuyCardViewModel.createOrder(); - } catch (_) { + try { + final isLogged = await cakePayBuyCardViewModel.isUserLogged; + + if (!isLogged) { cakePayBuyCardViewModel.isSimulatingFlow = false; - await cakePayBuyCardViewModel.logout(); + + if (context.mounted) { + Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); + } + return; } + + await cakePayBuyCardViewModel.createOrder(); + } on CakePayUnauthorizedException { + cakePayBuyCardViewModel.isSimulatingFlow = false; + + if (context.mounted) { + Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); + } + } catch (error) { + cakePayBuyCardViewModel.isSimulatingFlow = false; + _sendViewModel.state = FailureState( + _sendViewModel.translateErrorMessage( + error, + cakePayBuyCardViewModel.walletType, + _sendViewModel.wallet.currency, + ), + ); + } finally { + cakePayBuyCardViewModel.isPurchasing = false; } - cakePayBuyCardViewModel.isPurchasing = false; } BuildContext? dialogContext; diff --git a/lib/cake_pay/src/cards/cake_pay_cards_page.dart b/lib/cake_pay/src/cards/cake_pay_cards_page.dart index c0b1b7d17..9caee90ee 100644 --- a/lib/cake_pay/src/cards/cake_pay_cards_page.dart +++ b/lib/cake_pay/src/cards/cake_pay_cards_page.dart @@ -477,11 +477,18 @@ class _ShopTabState extends State<_ShopTab> { if (i >= vendors.length) return const _Loading(); final vendor = vendors[i]; return CardItem( - logoUrl: vendor.card?.cardImageUrl, - title: vendor.name, - subTitle: vendor.card?.description ?? '', - onTap: () => Navigator.pushNamed(context, Routes.cakePayBuyCardPage, - arguments: [vendor])); + logoUrl: vendor.card?.cardImageUrl, + title: vendor.name, + subTitle: vendor.card?.description ?? '', + onTap: () async { + await Navigator.pushNamed( + context, + Routes.cakePayBuyCardPage, + arguments: [vendor], + ); + await viewModel.checkAuth(); + }, + ); }, ), ], diff --git a/lib/cake_pay/src/services/cake_pay_api.dart b/lib/cake_pay/src/services/cake_pay_api.dart index 84cc1f4df..fa3918713 100644 --- a/lib/cake_pay/src/services/cake_pay_api.dart +++ b/lib/cake_pay/src/services/cake_pay_api.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_user_credentials.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart'; @@ -129,6 +130,10 @@ class CakePayApi { body: body, ); + if (response.statusCode == 401) { + throw const CakePayUnauthorizedException(); + } + if (response.statusCode == 201) { final data = json.decode(response.body) as Map; return CakePayOrder.fromMap(data); @@ -168,6 +173,10 @@ class CakePayApi { headers: headers, ); + if (response.statusCode == 401) { + throw const CakePayUnauthorizedException(); + } + if (response.statusCode != 200) { throw Exception('Unexpected http status: ${response.statusCode}'); } @@ -203,6 +212,10 @@ class CakePayApi { printV('Response: ${response.statusCode}'); + if (response.statusCode == 401) { + throw const CakePayUnauthorizedException(); + } + if (response.statusCode != 200) { throw Exception('Unexpected http status: ${response.statusCode}'); } diff --git a/lib/cake_pay/src/services/cake_pay_service.dart b/lib/cake_pay/src/services/cake_pay_service.dart index 8166b52c2..d5e8a75af 100644 --- a/lib/cake_pay/src/services/cake_pay_service.dart +++ b/lib/cake_pay/src/services/cake_pay_service.dart @@ -1,4 +1,5 @@ import 'package:cake_wallet/.secrets.g.dart' as secrets; +import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart'; import 'package:cake_wallet/cake_pay/src/services/cake_pay_api.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart'; @@ -95,29 +96,51 @@ class CakePayService { required bool confirmsTermsAgreed, }) async { final userEmail = (await secureStorage.read(key: cakePayEmailStorageKey))!; - final token = (await secureStorage.read(key: cakePayUserTokenKey))!; - return await cakePayApi.createOrder( - apiKey: cakePayApiKey, - cardId: cardId, - price: price, - quantity: quantity, - token: token, - userEmail: userEmail, - confirmsNoVpn: confirmsNoVpn, - confirmsVoidedRefund: confirmsVoidedRefund, - confirmsTermsAgreed: confirmsTermsAgreed, + return _withUserToken( + (token) => cakePayApi.createOrder( + apiKey: cakePayApiKey, + cardId: cardId, + price: price, + quantity: quantity, + token: token, + userEmail: userEmail, + confirmsNoVpn: confirmsNoVpn, + confirmsVoidedRefund: confirmsVoidedRefund, + confirmsTermsAgreed: confirmsTermsAgreed, + ), ); } - Future findOrderById({required String orderId}) async { - final token = (await secureStorage.read(key: cakePayUserTokenKey))!; - return await cakePayApi.getOrderById(orderId: orderId, token: token); - } + Future findOrderById({required String orderId}) => _withUserToken( + (token) => cakePayApi.getOrderById( + orderId: orderId, + token: token, + ), + ); ///Simulate Purchase Gift Card - Future simulatePayment({required String orderId}) async { - final token = (await secureStorage.read(key: cakePayUserTokenKey))!; - return await cakePayApi.simulatePayment( - CSRFToken: CSRFToken, authorization: authorization, token: token, orderId: orderId); + Future simulatePayment({required String orderId}) => _withUserToken( + (token) => cakePayApi.simulatePayment( + CSRFToken: CSRFToken, + authorization: authorization, + token: token, + orderId: orderId, + ), + ); + + /// Wrapper for requests with user token + Future _withUserToken(Future Function(String token) request) async { + try { + final token = await secureStorage.read(key: cakePayUserTokenKey) ?? ''; + + if (token.isEmpty) { + throw const CakePayUnauthorizedException(); + } + + return await request(token); + } on CakePayUnauthorizedException { + await logout(); + rethrow; + } } } diff --git a/lib/src/screens/order_details/order_details_page.dart b/lib/src/screens/order_details/order_details_page.dart index c1463a7cb..45d354424 100644 --- a/lib/src/screens/order_details/order_details_page.dart +++ b/lib/src/screens/order_details/order_details_page.dart @@ -1,10 +1,12 @@ import 'package:cake_wallet/src/screens/order_details/cake_pay_detail_list_card_item.dart'; import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/cake_pay_order_list_card.dart'; import 'package:cake_wallet/src/widgets/standard_list.dart'; import 'package:cake_wallet/src/widgets/standard_list_status_row.dart'; import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:cake_wallet/utils/show_bar.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/order_details_view_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; @@ -14,6 +16,7 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/widgets/list_row.dart'; import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart'; +import 'package:mobx/mobx.dart'; class OrderDetailsPage extends BasePage { OrderDetailsPage(this.orderDetailsViewModel); @@ -43,11 +46,44 @@ class OrderDetailsPageBodyState extends State { final OrderDetailsViewModel orderDetailsViewModel; final MaterialThemeBase currentTheme; + ReactionDisposer? _sessionExpiredReaction; + + @override + void initState() { + super.initState(); + + _sessionExpiredReaction = reaction( + (_) => orderDetailsViewModel.isCakePaySessionExpired, + (isExpired) { + if (!isExpired) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + + showPopUp( + context: context, + builder: (dialogContext) => AlertWithOneAction( + alertTitle: S.of(dialogContext).error, + alertContent: S.of(dialogContext).cake_pay_session_expired, + buttonText: S.of(dialogContext).ok, + buttonAction: () => Navigator.of(dialogContext).pop(), + ), + ); + }); + }, + fireImmediately: true, + ); + } @override void dispose() { - super.dispose(); + _sessionExpiredReaction?.call(); orderDetailsViewModel.timer?.cancel(); + super.dispose(); } @override diff --git a/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart b/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart index a95e2353d..311354ba1 100644 --- a/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart'; import 'package:cake_wallet/order/order_provider_description.dart'; import 'package:cake_wallet/order/order.dart'; import 'package:cake_wallet/order/order_source_description.dart'; @@ -221,6 +222,8 @@ abstract class CakePayBuyCardViewModelBase with Store { orders.add(orderRecord); updateRemainingTime(); _startExpirationTimer(); + } on CakePayUnauthorizedException { + rethrow; } catch (e) { sendViewModel.state = FailureState( sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency)); @@ -275,8 +278,6 @@ abstract class CakePayBuyCardViewModelBase with Store { } } - Future logout() async => await _cakePayService.logout(); - void _startExpirationTimer() { _timer?.cancel(); _timer = Timer.periodic(Duration(seconds: 1), (_) { diff --git a/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart b/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart index 54ecdf794..a2fba48fb 100644 --- a/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart @@ -78,7 +78,6 @@ abstract class CakePayCardsListViewModelBase with Store { }; String searchString; - String? username; int page; late Country _initialSelectedCountry; @@ -87,6 +86,9 @@ abstract class CakePayCardsListViewModelBase with Store { late bool _initialDisplayDenominationsCards; late bool _initialDisplayCustomValueCards; + @observable + String? username; + @observable List userCards; @@ -189,12 +191,9 @@ abstract class CakePayCardsListViewModelBase with Store { @action Future checkAuth() async { - authFuture = ObservableFuture(cakePayService.isLogged()); - - final logged = await authFuture!; - if (logged) { - username = await cakePayService.getUserEmail(); - } + final logged = await cakePayService.isLogged(); + username = logged ? await cakePayService.getUserEmail() : null; + authFuture = ObservableFuture.value(logged); } @action diff --git a/lib/view_model/order_details_view_model.dart b/lib/view_model/order_details_view_model.dart index 2edb4db22..25b61b79e 100644 --- a/lib/view_model/order_details_view_model.dart +++ b/lib/view_model/order_details_view_model.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cake_wallet/buy/buy_provider_description.dart'; +import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart'; import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; import 'package:cake_wallet/core/utilities.dart'; @@ -64,6 +65,9 @@ abstract class OrderDetailsViewModelBase with Store { @observable ObservableList items; + @observable + bool isCakePaySessionExpired = false; + final CakePayService cakePayService; final Box orders; OrderProvider? _provider; @@ -74,7 +78,9 @@ abstract class OrderDetailsViewModelBase with Store { @action Future _updateOrder() async { try { - if (_provider == null) return; + if (_provider == null || isCakePaySessionExpired) { + return; + } final updatedOrderObj = await _provider!.findOrderById(order.id); final updatedOrder = updatedOrderObj.$1; @@ -95,6 +101,9 @@ abstract class OrderDetailsViewModelBase with Store { } _updateItems(); + } on CakePayUnauthorizedException { + timer?.cancel(); + isCakePaySessionExpired = true; } catch (e) { printV(e.toString()); } diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 6e6d3c487..1ea0cf8a2 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -152,6 +152,7 @@ "cake_pay_card_email_delivered_message": "Your gift card will be delivered via email after the necessary confirmations.", "cake_pay_learn_more": "Instantly purchase and redeem gift cards in the app!\nSwipe left to right to learn more.", "cake_pay_save_order": "The card should be sent to your e-mail within 1 business day \n Save your Order ID:", + "cake_pay_session_expired": "Your Cake Pay session is no longer valid. Please log in again.", "cake_pay_subtitle": "Buy worldwide prepaid cards and gift cards", "cake_pay_web_cards_subtitle": "Buy worldwide prepaid cards and gift cards", "cake_pay_web_cards_title": "Cake Pay Web Cards", @@ -1527,4 +1528,4 @@ "zcash_network_upgrade_desc_1": "Zcash has undergone a network upgrade called Ironwood that requires migration of funds before they can be spent. This migration is automatic, privacy-preserving, and starts as soon as you open your Zcash wallet.", "zcash_network_upgrade_desc_2": "When the migration finishes, your Available Balance will return to normal, and you can continue to send and receive Zcash as usual.", "zcash_network_upgrade_desc_3": "Please allow your Zcash wallet to finishing syncing, and then leave the app open until migration finishes which can take a few minutes depending on the balance." -} \ No newline at end of file +}