From 0d353a0932150fd10c9b25cfbad48a52c9796c88 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Tue, 1 Sep 2026 13:42:33 +0200 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 +} From 3fabbc5c5e31d1e480465354ab701dc1761b5716 Mon Sep 17 00:00:00 2001 From: Blazebrain Date: Thu, 3 Sep 2026 13:40:26 +0100 Subject: [PATCH 08/15] fix: brazillian localle bug that makes app unresponsive --- lib/entities/language_service.dart | 13 +++++++++++++ lib/main.dart | 2 +- tool/generate_localization.dart | 16 ++++++++++++---- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/entities/language_service.dart b/lib/entities/language_service.dart index 11c892df3..5331b1e83 100644 --- a/lib/entities/language_service.dart +++ b/lib/entities/language_service.dart @@ -1,7 +1,20 @@ import 'package:cake_wallet/generated/locales.dart'; import 'package:devicelocale/devicelocale.dart'; +import "package:flutter/widgets.dart"; import 'package:intl/intl.dart'; +// This will turn stored language code into Flutter Locale. +// Some of our language codes uses an underscore (like "pt_BR") and this must be split into language +// and country subtags, if we pass the whole string as the languageCode, GlobalMaterialLocalizations +// would reject it as an invalid ISO 639-1 code and crash the app on locale switch +Locale localeFromLanguageCode(String code) { + final parts = code.split("_"); + if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return Locale(parts[0], parts[1]); + } + return Locale(code); +} + class LanguageService { static const Map supportedLocales = { 'en': 'English', diff --git a/lib/main.dart b/lib/main.dart index 02229c80a..eeb9e0394 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -426,7 +426,7 @@ class AppState extends State with SingleTickerProviderStateMixin { themeMode: themeMode, localizationsDelegates: localizationDelegates, supportedLocales: S.delegate.supportedLocales, - locale: Locale(appStore.settingsStore.languageCode), + locale: localeFromLanguageCode(appStore.settingsStore.languageCode), onGenerateRoute: (settings) => Router.createRoute(settings), initialRoute: initialRoute, scrollBehavior: AppScrollBehavior(), diff --git a/tool/generate_localization.dart b/tool/generate_localization.dart index 719cf4c5c..5bbf016a4 100644 --- a/tool/generate_localization.dart +++ b/tool/generate_localization.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'dart:convert'; -import './print_verbose_dummy.dart'; import 'localization/localization_constants.dart'; import 'utils/utils.dart'; @@ -49,7 +48,7 @@ Future main(List args) async { return; } - final localePath = {}; + final arbFiles = {}; await dir.list(recursive: false).forEach((element) { // Parse the locale from the file name (e.g. strings_pt_br.arb -> pt_BR), // normalizing the case so keys match LanguageService.supportedLocales. @@ -63,9 +62,12 @@ Future main(List args) async { final locale = parts.length > 1 ? '${parts.first.toLowerCase()}_${parts.sublist(1).join('_').toUpperCase()}' : parts.first.toLowerCase(); - localePath[locale] = element.path; + arbFiles[locale] = element.path; }); + final sortedLocales = arbFiles.keys.toList()..sort(); + final localePath = {for (final key in sortedLocales) key: arbFiles[key]}; + if (!localePath.keys.contains(defaultLocale)) { print("Locale list doesn't contain $defaultLocale"); return; @@ -103,8 +105,14 @@ Future main(List args) async { output += classDeclaration; + // for handling keys like pt_BR so entry becomes Locale("pt", "BR") and doesn't crash app localePath.keys.forEach((key) { - output += ' Locale("$key", ""),' + '\n'; + final parts = key.split("_"); + if (parts.length == 2) { + output += " Locale(\"${parts[0]}\", \"${parts[1]}\"),\n"; + } else { + output += " Locale(\"$key\", \"\"),\n"; + } }); output += part2; From 93201806847ba5965a85ed9f44c45b69cd5257f0 Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Sat, 5 Sep 2026 00:16:01 +0800 Subject: [PATCH 09/15] v6.4.4 Release Candidate (#3594) - Monero sync patches - Trezor bitcoin - Solana NFTs sending - Bug fixes --- .../new-ui/changelog/text/changelog_ar.json | 2 +- .../new-ui/changelog/text/changelog_bg.json | 2 +- .../new-ui/changelog/text/changelog_cs.json | 2 +- .../new-ui/changelog/text/changelog_de.json | 2 +- .../new-ui/changelog/text/changelog_en.json | 14 +++++---- .../new-ui/changelog/text/changelog_es.json | 2 +- .../new-ui/changelog/text/changelog_fa.json | 2 +- .../new-ui/changelog/text/changelog_fr.json | 2 +- .../new-ui/changelog/text/changelog_gn.json | 2 +- .../new-ui/changelog/text/changelog_ha.json | 2 +- .../new-ui/changelog/text/changelog_hi.json | 2 +- .../new-ui/changelog/text/changelog_hr.json | 2 +- .../new-ui/changelog/text/changelog_hy.json | 2 +- .../new-ui/changelog/text/changelog_id.json | 2 +- .../new-ui/changelog/text/changelog_it.json | 2 +- .../new-ui/changelog/text/changelog_ja.json | 2 +- .../new-ui/changelog/text/changelog_ko.json | 2 +- .../new-ui/changelog/text/changelog_my.json | 2 +- .../new-ui/changelog/text/changelog_nl.json | 2 +- .../new-ui/changelog/text/changelog_pl.json | 2 +- .../new-ui/changelog/text/changelog_pt.json | 2 +- .../new-ui/changelog/text/changelog_ru.json | 2 +- .../new-ui/changelog/text/changelog_th.json | 2 +- .../new-ui/changelog/text/changelog_tl.json | 2 +- .../new-ui/changelog/text/changelog_tr.json | 2 +- .../new-ui/changelog/text/changelog_uk.json | 2 +- .../new-ui/changelog/text/changelog_ur.json | 2 +- .../new-ui/changelog/text/changelog_vi.json | 2 +- .../new-ui/changelog/text/changelog_yo.json | 2 +- .../changelog/text/changelog_zh-cn.json | 2 +- lib/entities/default_settings_migration.dart | 7 +++++ lib/main.dart | 2 +- .../dashboard/pages/nft_send_page.dart | 29 +++++++++++-------- .../dashboard/nft_send_view_model.dart | 18 ++++++++++-- macos/Podfile.lock | 4 +-- pubspec.lock | 14 ++++----- pubspec_base.yaml | 2 +- scripts/android/app_env.sh | 8 ++--- scripts/ios/app_env.sh | 8 ++--- scripts/linux/app_env.sh | 4 +-- scripts/macos/app_env.sh | 8 ++--- 41 files changed, 103 insertions(+), 73 deletions(-) diff --git a/assets/new-ui/changelog/text/changelog_ar.json b/assets/new-ui/changelog/text/changelog_ar.json index 08a75f976..83e71af67 100644 --- a/assets/new-ui/changelog/text/changelog_ar.json +++ b/assets/new-ui/changelog/text/changelog_ar.json @@ -1 +1 @@ -[{"title":"تحسينات AnyPay","description":"الآن، سيؤدي لصق عنوان ما أو مسحه ضوئيًا من أي محفظة إلى توجيهك بشكل صحيح إما للتبديل أو الإرسال من المحفظة الصحيحة، والمزيد من التحسينات"},{"title":"تحسينات واجهة المستخدم","description":"تمت إضافة بعض عناصر واجهة المستخدم الجديدة خصيصًا لك"},{"title":"إصلاحات الشوائب","description":"سحق المزيد من الأخطاء"}] \ No newline at end of file +[{"title":"تريزور أصلا لبيتكوين","description":"قم بإقران جهازك وتوقيع معاملات Bitcoin مباشرة في التطبيق"},{"title":"تحسينات Solana وNFTs","description":"أرسل Solana NFTs الخاصة بك، واحتفظ بالبطاقات المستوردة المحفوظة، واطلع في لمحة سريعة على تلك التي تحتوي عليها هذه المحفظة"},{"title":"تحسينات مزامنة مونيرو","description":"يستمر تقدم المزامنة في التحرك أثناء تسخين محفظتك، حتى تعرف دائمًا مكانها"},{"title":"إصلاحات الشوائب","description":"مزامنة جهات الاتصال، واختيار العقدة، وتسجيل الدخول إلى Cake Pay، والمزيد، تم سحقها جميعًا"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_bg.json b/assets/new-ui/changelog/text/changelog_bg.json index f7946afc9..751659dac 100644 --- a/assets/new-ui/changelog/text/changelog_bg.json +++ b/assets/new-ui/changelog/text/changelog_bg.json @@ -1 +1 @@ -[{"title":"Подобрения на AnyPay","description":"Сега поставянето или сканирането на адрес от който и да е портфейл ще ви насочи правилно към размяна или изпращане от правилния портфейл и много повече подобрения"},{"title":"Подобрения на потребителския интерфейс","description":"Добавени са някои нови елементи на потребителския интерфейс само за вас"},{"title":"Поправки на грешки","description":"Смачкани още грешки"}] \ No newline at end of file +[{"title":"Trezor първоначално за биткойн","description":"Сдвоете вашето устройство и подписвайте биткойн транзакции направо в приложението"},{"title":"Solana и подобрения на NFT","description":"Изпратете вашите Solana NFT, запазете импортираните и вижте с един поглед кои съхранява този портфейл"},{"title":"Подобрения в синхронизирането на Monero","description":"Прогресът на синхронизирането продължава да се движи, докато портфейлът ви загрява, така че винаги да знаете къде се намира"},{"title":"Поправки на грешки","description":"Синхронизиране на контакти, избор на възел, влизане в Cake Pay и други, всички смачкани"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_cs.json b/assets/new-ui/changelog/text/changelog_cs.json index 52d5e7797..f663d6d33 100644 --- a/assets/new-ui/changelog/text/changelog_cs.json +++ b/assets/new-ui/changelog/text/changelog_cs.json @@ -1 +1 @@ -[{"title":"Vylepšení AnyPay","description":"Nyní vložení nebo skenování adresy z libovolné peněženky vás správně nasměruje buď na výměnu nebo odeslání ze správné peněženky, a k mnoha dalším vylepšením"},{"title":"vylepšení uživatelského rozhraní","description":"Přidali jsme několik nových prvků uživatelského rozhraní jen pro vás"},{"title":"Opravy chyb","description":"Zmáčkl další brouky"}] \ No newline at end of file +[{"title":"Trezor nativně pro bitcoiny","description":"Spárujte své zařízení a podepište bitcoinové transakce přímo v aplikaci"},{"title":"Solana a vylepšení NFT","description":"Pošlete své Solana NFT, mějte importované uložené a na první pohled uvidíte, které z nich tato peněženka obsahuje"},{"title":"Vylepšení synchronizace Monero","description":"Zatímco se vaše peněženka zahřívá, postup synchronizace se neustále pohybuje, takže vždy víte, kde se nachází"},{"title":"Opravy chyb","description":"Synchronizace kontaktů, výběr uzlů, přihlášení do Cake Pay a další, vše zmačkané"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_de.json b/assets/new-ui/changelog/text/changelog_de.json index bbbf2d6e2..380bf0f97 100644 --- a/assets/new-ui/changelog/text/changelog_de.json +++ b/assets/new-ui/changelog/text/changelog_de.json @@ -1 +1 @@ -[{"title":"AnyPay-Verbesserungen","description":"Wenn Sie jetzt eine Adresse aus einem beliebigen Wallet einfügen oder scannen, werden Sie direkt zum Tauschen oder Senden von dem richtigen Wallet weitergeleitet und es gibt noch viele weitere Verbesserungen"},{"title":"Verbesserungen der Benutzeroberfläche","description":"Einige neue UI-Elemente wurden speziell für Sie hinzugefügt"},{"title":"Fehlerbehebungen","description":"Weitere Bugs beseitigt"}] \ No newline at end of file +[{"title":"Trezor nativ für Bitcoin","description":"Koppeln Sie Ihr Gerät und signieren Sie Bitcoin-Transaktionen direkt in der App"},{"title":"Verbesserungen bei Solana und NFTs","description":"Senden Sie Ihre Solana-NFTs, bewahren Sie importierte NFTs auf und sehen Sie auf einen Blick, welche in dieser Wallet gespeichert sind"},{"title":"Verbesserungen bei der Monero-Synchronisierung","description":"Der Synchronisierungsfortschritt läuft weiter, während sich Ihr Geldbeutel aufwärmt, sodass Sie immer wissen, wo er steht"},{"title":"Fehlerbehebungen","description":"Kontaktsynchronisierung, Knotenauswahl, Cake Pay-Anmeldung und mehr, alles gequetscht"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_en.json b/assets/new-ui/changelog/text/changelog_en.json index bb20670d2..8388cf1d0 100644 --- a/assets/new-ui/changelog/text/changelog_en.json +++ b/assets/new-ui/changelog/text/changelog_en.json @@ -1,14 +1,18 @@ [ { - "title": "AnyPay Improvements", - "description": "Now pasting or scanning an address from any wallet will point you correctly to either swapping or sending from the correct wallet, and much more improvements" + "title": "Trezor natively for Bitcoin", + "description": "Pair your device and sign Bitcoin transactions right in the app" }, { - "title": "UI improvements", - "description": "Added some fresh UI elements just for you" + "title": "Solana and NFTs Improvements", + "description": "Send your Solana NFTs, keep imported ones saved, and see at a glance which ones this wallet holds" + }, + { + "title": "Monero sync improvements", + "description": "Sync progress keeps moving while your wallet warms up, so you always know where it stands" }, { "title": "Bug fixes", - "description": "Squashed more bugs" + "description": "Contact syncing, node selection, Cake Pay sign-in and more, all squashed" } ] diff --git a/assets/new-ui/changelog/text/changelog_es.json b/assets/new-ui/changelog/text/changelog_es.json index ebff860ec..0732b13fb 100644 --- a/assets/new-ui/changelog/text/changelog_es.json +++ b/assets/new-ui/changelog/text/changelog_es.json @@ -1 +1 @@ -[{"title":"Mejoras en cualquier pago","description":"Ahora, pegar o escanear una dirección desde cualquier billetera le indicará correctamente si desea intercambiar o enviar desde la billetera correcta, y muchas más mejoras."},{"title":"Mejoras en la interfaz de usuario","description":"Se agregaron algunos elementos nuevos de la interfaz de usuario solo para ti."},{"title":"Corrección de errores","description":"Aplastado más errores"}] \ No newline at end of file +[{"title":"Trezor de forma nativa para Bitcoin","description":"Empareja tu dispositivo y firma transacciones de Bitcoin directamente en la aplicación"},{"title":"Mejoras en Solana y NFT","description":"Envíe sus NFT de Solana, guarde los importados y vea de un vistazo cuáles contiene esta billetera"},{"title":"Mejoras en la sincronización de Monero","description":"El progreso de la sincronización continúa mientras tu billetera se calienta, para que siempre sepas dónde se encuentra"},{"title":"Corrección de errores","description":"Sincronización de contactos, selección de nodos, inicio de sesión de Cake Pay y más, todo aplastado"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_fa.json b/assets/new-ui/changelog/text/changelog_fa.json index 780416e12..4ccdeaad1 100644 --- a/assets/new-ui/changelog/text/changelog_fa.json +++ b/assets/new-ui/changelog/text/changelog_fa.json @@ -1 +1 @@ -[{"title":"AnyPay Improvements","description":"اکنون چسباندن یا اسکن یک آدرس از هر کیف پولی، شما را به درستی به تعویض یا ارسال از کیف پول صحیح و پیشرفت های بسیار بیشتر راهنمایی می کند."},{"title":"بهبودهای رابط کاربری","description":"برخی از عناصر رابط کاربری جدید را فقط برای شما اضافه کرد"},{"title":"رفع اشکال","description":"اشکالات بیشتری را له کرد"}] \ No newline at end of file +[{"title":"Trezor به صورت بومی برای بیت کوین","description":"دستگاه خود را جفت کنید و معاملات بیت کوین را مستقیماً در برنامه امضا کنید"},{"title":"بهبود سولانا و NFTs","description":"NFT های Solana خود را ارسال کنید، موارد وارداتی را ذخیره کنید و در یک نگاه ببینید که این کیف پول کدام یک را در خود جای می دهد."},{"title":"بهبود همگام سازی مونرو","description":"پیشرفت همگام‌سازی تا زمانی که کیف پول شما گرم می‌شود به حرکت خود ادامه می‌دهد، بنابراین همیشه می‌دانید کجا قرار دارد"},{"title":"رفع اشکال","description":"همگام‌سازی مخاطب، انتخاب گره، ورود به سیستم Cake Pay و موارد دیگر، همه له شده‌اند"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_fr.json b/assets/new-ui/changelog/text/changelog_fr.json index 8fa44d463..6eb325e5d 100644 --- a/assets/new-ui/changelog/text/changelog_fr.json +++ b/assets/new-ui/changelog/text/changelog_fr.json @@ -1 +1 @@ -[{"title":"Améliorations d'AnyPay","description":"Désormais, coller ou scanner une adresse à partir de n'importe quel portefeuille vous indiquera correctement soit l'échange, soit l'envoi à partir du bon portefeuille, et bien d'autres améliorations."},{"title":"Améliorations de l'interface utilisateur","description":"Ajout de nouveaux éléments d'interface utilisateur juste pour vous"},{"title":"Corrections de bugs","description":"Écrasé plus de bugs"}] \ No newline at end of file +[{"title":"Trezor nativement pour Bitcoin","description":"Associez votre appareil et signez des transactions Bitcoin directement dans l'application"},{"title":"Améliorations de Solana et des NFT","description":"Envoyez vos NFT Solana, conservez ceux importés et voyez en un coup d'œil lesquels contiennent ce portefeuille"},{"title":"Améliorations de la synchronisation Monero","description":"La progression de la synchronisation continue pendant que votre portefeuille se réchauffe, afin que vous sachiez toujours où il en est"},{"title":"Corrections de bugs","description":"Synchronisation des contacts, sélection des nœuds, connexion à Cake Pay et bien plus encore, le tout écrasé"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_gn.json b/assets/new-ui/changelog/text/changelog_gn.json index 118c019c8..df6933ba6 100644 --- a/assets/new-ui/changelog/text/changelog_gn.json +++ b/assets/new-ui/changelog/text/changelog_gn.json @@ -1 +1 @@ -[{"title":"AnyPay Ñemyatyrõ","description":"Ko’áĝa emboja térã eescanea peteĩ dirección oimeraẽva billetera-gui nde señaláta hekopete taha’e intercambio térã emondo haĝua billetera hekopeteguágui, ha hetaiteve ñemyatyrõ"},{"title":"UI ñemyatyrõ","description":"Oñemoĩve peteĩ elemento UI pyahu ndéve g̃uarãnte"},{"title":"Umi mba’e’oka ñemyatyrõ","description":"Oñembopupu hetave mba’e’oka"}] \ No newline at end of file +[{"title":"Trezor nativamente Bitcoin-pe g̃uarã","description":"Embojoaju nde tembipuru ha efirma umi transacción Bitcoin rehegua pe app-pe voi"},{"title":"Solana ha NFTs Ñemyatyrõ","description":"Emondo nde NFT Solana, eñongatu umi ojeguerúva, ha ehecha peteĩ jesarekópe mávapa oguereko ko billetera"},{"title":"Monero sincronización ñemyatyrõ","description":"Progreso sincronización rehegua osegi omýi nde billetera oñembopiro’y aja, upéicha akóinte reikuaa moõpa oĩ"},{"title":"Umi mba’e’oka ñemyatyrõ","description":"Contacto ñembojoaju, nodo jeporavo, Cake Pay jeike ha hetave, opaite oñembotapykue"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ha.json b/assets/new-ui/changelog/text/changelog_ha.json index e652ce4ae..ac90aaf7b 100644 --- a/assets/new-ui/changelog/text/changelog_ha.json +++ b/assets/new-ui/changelog/text/changelog_ha.json @@ -1 +1 @@ -[{"title":"AnyBiyan Ingantawa","description":"Yanzu liƙa ko duba adireshi daga kowane walat zai nuna maka daidai ga ko dai musanya ko aikawa daga ainihin walat ɗin, da ƙarin haɓakawa."},{"title":"UI inganta","description":"An ƙara wasu sabbin abubuwan UI don ku kawai"},{"title":"Gyaran kwaro","description":"Squashed karin kwari"}] \ No newline at end of file +[{"title":"Trezor na asali don Bitcoin","description":"Haɗa na'urarka kuma sanya hannu kan ma'amalar Bitcoin daidai a cikin ƙa'idar"},{"title":"Solana da NFTs Ingantawa","description":"Aika Solana NFT ɗinku, adana waɗanda aka shigo da su, sannan ku duba ɗaya daga cikin wallet ɗin."},{"title":"Monero sync inganta","description":"Ci gaban daidaitawa yana ci gaba da motsawa yayin da walat ɗin ku ke yin dumi, don haka koyaushe ku san inda yake tsaye"},{"title":"Gyaran kwaro","description":"Daidaita lamba, zaɓin kumburi, shigar da Biyan Kek da ƙari, duk an murƙushe"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_hi.json b/assets/new-ui/changelog/text/changelog_hi.json index 3a7653bc9..25a54c5d7 100644 --- a/assets/new-ui/changelog/text/changelog_hi.json +++ b/assets/new-ui/changelog/text/changelog_hi.json @@ -1 +1 @@ -[{"title":"AnyPay सुधार","description":"अब किसी भी वॉलेट से किसी पते को चिपकाने या स्कैन करने से आपको स्वैपिंग या सही वॉलेट से भेजने का सही संकेत मिलेगा, और भी बहुत कुछ सुधार होगा"},{"title":"यूआई सुधार","description":"केवल आपके लिए कुछ ताज़ा यूआई तत्व जोड़े गए हैं"},{"title":"कंप्यूटर प्रोग्राम या प्रणाली में बग को दूर करना","description":"अधिक कीड़ों को कुचल दिया"}] \ No newline at end of file +[{"title":"ट्रेज़ोर मूल रूप से बिटकॉइन के लिए है","description":"अपने डिवाइस को पेयर करें और ऐप में ही बिटकॉइन लेनदेन पर हस्ताक्षर करें"},{"title":"सोलाना और एनएफटी सुधार","description":"अपने सोलाना एनएफटी भेजें, आयातित एनएफटी को सहेज कर रखें और एक नज़र में देखें कि इस वॉलेट में कौन से एनएफटी हैं"},{"title":"मोनेरो सिंक में सुधार","description":"जब आपका बटुआ गर्म हो जाता है तो सिंक प्रगति चलती रहती है, इसलिए आप हमेशा जानते हैं कि यह कहां खड़ा है"},{"title":"कंप्यूटर प्रोग्राम या प्रणाली में बग को दूर करना","description":"संपर्क सिंकिंग, नोड चयन, केक पे साइन-इन और बहुत कुछ, सब खत्म हो गया"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_hr.json b/assets/new-ui/changelog/text/changelog_hr.json index 2142a6759..409f08526 100644 --- a/assets/new-ui/changelog/text/changelog_hr.json +++ b/assets/new-ui/changelog/text/changelog_hr.json @@ -1 +1 @@ -[{"title":"AnyPay poboljšanja","description":"Sada će vas lijepljenje ili skeniranje adrese iz bilo kojeg novčanika ispravno usmjeriti na zamjenu ili slanje iz ispravnog novčanika i još mnogo poboljšanja"},{"title":"Poboljšanja korisničkog sučelja","description":"Dodani su novi elementi korisničkog sučelja samo za vas"},{"title":"Ispravci grešaka","description":"Zgnječio još kukaca"}] \ No newline at end of file +[{"title":"Trezor izvorno za Bitcoin","description":"Uparite svoj uređaj i potpisujte Bitcoin transakcije izravno u aplikaciji"},{"title":"Solana i poboljšanja NFT-a","description":"Pošaljite svoje Solana NFT-ove, sačuvajte uvezene i na prvi pogled pogledajte koje ovaj novčanik ima"},{"title":"Poboljšanja sinkronizacije Monera","description":"Napredak sinkronizacije nastavlja se kretati dok se vaš novčanik zagrijava, tako da uvijek znate gdje stoji"},{"title":"Ispravci grešaka","description":"Sinkronizacija kontakata, odabir čvora, Cake Pay prijava i više, sve zgnječeno"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_hy.json b/assets/new-ui/changelog/text/changelog_hy.json index f5939617b..830d688fd 100644 --- a/assets/new-ui/changelog/text/changelog_hy.json +++ b/assets/new-ui/changelog/text/changelog_hy.json @@ -1 +1 @@ -[{"title":"AnyPay բարելավումներ","description":"Այժմ ցանկացած դրամապանակից հասցե կպցնելը կամ սկանավորումը ձեզ ճիշտ կուղղորդի՝ կա՛մ փոխանակելու, կա՛մ ճիշտ դրամապանակից ուղարկելու և շատ ավելի բարելավումներ:"},{"title":"UI բարելավումներ","description":"Ավելացրել է մի քանի թարմ UI տարրեր հենց ձեզ համար"},{"title":"Սխալների շտկում","description":"Ճզմեց ավելի շատ սխալներ"}] \ No newline at end of file +[{"title":"Trezor բնիկ համար Bitcoin","description":"Զուգավորեք ձեր սարքը և ստորագրեք Bitcoin գործարքները հենց հավելվածում"},{"title":"Սոլանայի և NFT-ների բարելավումներ","description":"Ուղարկեք ձեր Solana NFT-ները, պահեք ներմուծվածները և մի հայացքով տեսեք, թե որոնք են պահում այս դրամապանակը"},{"title":"Monero-ի համաժամացման բարելավումներ","description":"Համաժամացման առաջընթացը շարունակվում է, մինչ ձեր դրամապանակը տաքանում է, այնպես որ դուք միշտ իմանաք, թե որտեղ է այն կանգնած"},{"title":"Սխալների շտկում","description":"Կոնտակտների համաժամացում, հանգույցների ընտրություն, Cake Pay-ի մուտք և ավելին, բոլորը սեղմված են"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_id.json b/assets/new-ui/changelog/text/changelog_id.json index 162470665..13b8fe9e4 100644 --- a/assets/new-ui/changelog/text/changelog_id.json +++ b/assets/new-ui/changelog/text/changelog_id.json @@ -1 +1 @@ -[{"title":"Peningkatan AnyPay","description":"Sekarang menempelkan atau memindai alamat dari dompet mana pun akan mengarahkan Anda dengan benar untuk menukar atau mengirim dari dompet yang benar, dan banyak lagi peningkatan lainnya"},{"title":"Peningkatan UI","description":"Menambahkan beberapa elemen UI baru hanya untuk Anda"},{"title":"Perbaikan bug","description":"Membasmi lebih banyak bug"}] \ No newline at end of file +[{"title":"Trezor aslinya untuk Bitcoin","description":"Pasangkan perangkat Anda dan tanda tangani transaksi Bitcoin langsung di aplikasi"},{"title":"Peningkatan Solana dan NFT","description":"Kirim NFT Solana Anda, simpan NFT yang diimpor, dan lihat sekilas mana yang disimpan dalam dompet ini"},{"title":"Peningkatan sinkronisasi Monero","description":"Kemajuan sinkronisasi terus berjalan saat dompet Anda memanas, sehingga Anda selalu tahu di mana posisinya"},{"title":"Perbaikan bug","description":"Sinkronisasi kontak, pemilihan node, proses masuk Cake Pay, dan banyak lagi, semuanya terhenti"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_it.json b/assets/new-ui/changelog/text/changelog_it.json index 9de58b171..7eb3c436e 100644 --- a/assets/new-ui/changelog/text/changelog_it.json +++ b/assets/new-ui/changelog/text/changelog_it.json @@ -1 +1 @@ -[{"title":"Miglioramenti di AnyPay","description":"Ora incollare o scansionare un indirizzo da qualsiasi portafoglio ti indicherà correttamente lo scambio o l'invio dal portafoglio corretto e molti altri miglioramenti"},{"title":"Miglioramenti dell'interfaccia utente","description":"Aggiunti alcuni nuovi elementi dell'interfaccia utente solo per te"},{"title":"Correzioni di bug","description":"Eliminati altri bug"}] \ No newline at end of file +[{"title":"Trezor nativo per Bitcoin","description":"Accoppia il tuo dispositivo e firma le transazioni Bitcoin direttamente nell'app"},{"title":"Miglioramenti di Solana e NFT","description":"Invia i tuoi NFT Solana, mantieni salvati quelli importati e scopri a colpo d'occhio quali sono contenuti in questo portafoglio"},{"title":"Miglioramenti alla sincronizzazione di Monero","description":"I progressi della sincronizzazione continuano a muoversi mentre il tuo portafoglio si riscalda, così sai sempre a che punto è"},{"title":"Correzioni di bug","description":"Sincronizzazione dei contatti, selezione dei nodi, accesso a Cake Pay e altro ancora, tutto schiacciato"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ja.json b/assets/new-ui/changelog/text/changelog_ja.json index a798267f8..ef26f06c6 100644 --- a/assets/new-ui/changelog/text/changelog_ja.json +++ b/assets/new-ui/changelog/text/changelog_ja.json @@ -1 +1 @@ -[{"title":"AnyPayの改善","description":"任意のウォレットからアドレスを貼り付けるかスキャンすると、正しいウォレットからの交換または送信が正しく示されるようになり、さらに多くの改善が行われました。"},{"title":"UIの改善","description":"あなたのためだけにいくつかの新しい UI 要素を追加しました"},{"title":"バグ修正","description":"さらに多くのバグを潰しました"}] \ No newline at end of file +[{"title":"Trezor はビットコインにネイティブ対応","description":"デバイスをペアリングして、アプリ内で直接ビットコイン取引に署名します"},{"title":"Solana と NFT の改善","description":"Solana NFT を送信し、インポートされた NFT を保存し、このウォレットにどの NFT が保持されているかを一目で確認できます"},{"title":"Monero 同期の改善","description":"ウォレットがウォームアップしている間も同期の進行状況は継続するため、ウォレットの状態を常に把握できます"},{"title":"バグ修正","description":"連絡先の同期、ノードの選択、Cake Pay のサインインなど、すべてが潰されています"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ko.json b/assets/new-ui/changelog/text/changelog_ko.json index aceb6a4be..f13e4f4a2 100644 --- a/assets/new-ui/changelog/text/changelog_ko.json +++ b/assets/new-ui/changelog/text/changelog_ko.json @@ -1 +1 @@ -[{"title":"애니페이 개선","description":"이제 지갑에서 주소를 붙여넣거나 스캔하면 올바른 지갑에서 주소를 교환하거나 보내는 것을 올바르게 가리킬 수 있으며 훨씬 더 많은 개선 사항이 있습니다."},{"title":"UI 개선","description":"당신을 위한 새로운 UI 요소를 추가했습니다."},{"title":"버그 수정","description":"더 많은 버그를 해결했습니다."}] \ No newline at end of file +[{"title":"기본적으로 Bitcoin용 Trezor","description":"기기를 페어링하고 앱에서 바로 비트코인 ​​거래에 서명하세요."},{"title":"솔라나 및 NFT 개선","description":"솔라나 NFT를 보내고, 가져온 NFT를 저장하고, 이 지갑에 어떤 NFT가 들어 있는지 한눈에 확인하세요"},{"title":"모네로 동기화 개선","description":"지갑이 준비되는 동안 동기화 진행이 계속 진행되므로 항상 현재 상태를 알 수 있습니다."},{"title":"버그 수정","description":"연락처 동기화, 노드 선택, Cake Pay 로그인 등이 모두 중단되었습니다."}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_my.json b/assets/new-ui/changelog/text/changelog_my.json index f25bc55d9..8342e6c1c 100644 --- a/assets/new-ui/changelog/text/changelog_my.json +++ b/assets/new-ui/changelog/text/changelog_my.json @@ -1 +1 @@ -[{"title":"AnyPay တိုးတက်မှုများ","description":"ယခု မည်သည့်ပိုက်ဆံအိတ်မှ လိပ်စာတစ်ခုကို ကူးထည့်ခြင်း သို့မဟုတ် စကင်န်ဖတ်ခြင်းသည် သင့်အား မှန်ကန်သောပိုက်ဆံအိတ်မှ လဲလှယ်ရန် သို့မဟုတ် ပေးပို့ခြင်းတို့ကို မှန်ကန်စွာ ညွှန်ပြပေးမည်ဖြစ်ပြီး ပိုမိုကောင်းမွန်လာစေရန်၊"},{"title":"UI တိုးတက်မှုများ","description":"သင့်အတွက်သာ လတ်ဆတ်သော UI အစိတ်အပိုင်းအချို့ကို ထည့်ထားသည်။"},{"title":"ချွတ်ယွင်းချက်ပြင်ဆင်မှုများ","description":"နောက်ထပ် ပိုးကောင်များကို နှိမ်နင်းခဲ့သည်။"}] \ No newline at end of file +[{"title":"Bitcoin အတွက် မူရင်း Trezor","description":"သင့်စက်ကို တွဲချိတ်ပြီး အက်ပ်တွင် Bitcoin ငွေပေးငွေယူများကို လက်မှတ်ထိုးပါ။"},{"title":"Solana နှင့် NFTs တိုးတက်မှုများ","description":"သင်၏ Solana NFTs များကို ပေးပို့ပါ၊ တင်သွင်းထားသည့်အရာများကို သိမ်းဆည်းထားပါ၊ ဤပိုက်ဆံအိတ်တွင် မည်သည့်အရာများ ကိုင်ဆောင်ထားသည်ကို တစ်ချက်ကြည့်လိုက်ပါ။"},{"title":"Monero စင့်ခ်လုပ်ခြင်း တိုးတက်မှု","description":"သင့်ပိုက်ဆံအိတ် ပူနွေးလာချိန်တွင် စင့်ခ်လုပ်ဆောင်မှုသည် ဆက်လက်ရွေ့လျားနေသဖြင့် ၎င်းသည် မည်သည့်နေရာတွင် ရှိနေသည်ကို သင်အမြဲသိပါသည်။"},{"title":"ချွတ်ယွင်းချက်ပြင်ဆင်မှုများ","description":"အဆက်အသွယ်စင့်ခ်လုပ်ခြင်း၊ node ရွေးချယ်ခြင်း၊ Cake Pay လက်မှတ်ထိုးဝင်ခြင်းနှင့် အခြားအရာများအားလုံးကို ဖြိုခွဲလိုက်ပါပြီ။"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_nl.json b/assets/new-ui/changelog/text/changelog_nl.json index 22e5a608c..95d0958b5 100644 --- a/assets/new-ui/changelog/text/changelog_nl.json +++ b/assets/new-ui/changelog/text/changelog_nl.json @@ -1 +1 @@ -[{"title":"AnyPay-verbeteringen","description":"Als u nu een adres uit een willekeurige portemonnee plakt of scant, wordt u correct verwezen naar het wisselen of verzenden vanuit de juiste portemonnee, en nog veel meer verbeteringen"},{"title":"UI-verbeteringen","description":"Speciaal voor jou een aantal nieuwe UI-elementen toegevoegd"},{"title":"Bugfixes","description":"Nog meer bugs geplet"}] \ No newline at end of file +[{"title":"Trezor oorspronkelijk voor Bitcoin","description":"Koppel uw apparaat en onderteken Bitcoin-transacties rechtstreeks in de app"},{"title":"Verbeteringen aan Solana en NFT's","description":"Verstuur uw Solana NFT's, bewaar geïmporteerde NFT's en zie in één oogopslag welke deze portemonnee bevat"},{"title":"Verbeteringen voor de synchronisatie van Monero","description":"De voortgang van de synchronisatie blijft in beweging terwijl uw portemonnee opwarmt, zodat u altijd weet waar hij aan toe is"},{"title":"Bugfixes","description":"Contactsynchronisatie, knooppuntselectie, aanmelden bij Cake Pay en meer, allemaal platgedrukt"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_pl.json b/assets/new-ui/changelog/text/changelog_pl.json index 68b526716..0a169bb8a 100644 --- a/assets/new-ui/changelog/text/changelog_pl.json +++ b/assets/new-ui/changelog/text/changelog_pl.json @@ -1 +1 @@ -[{"title":"Ulepszenia AnyPay","description":"Teraz wklejenie lub zeskanowanie adresu z dowolnego portfela wskaże prawidłowo opcję wymiany lub wysłania z odpowiedniego portfela oraz wiele innych ulepszeń"},{"title":"Ulepszenia interfejsu użytkownika","description":"Dodano kilka świeżych elementów interfejsu użytkownika specjalnie dla Ciebie"},{"title":"Poprawki błędów","description":"Zmiażdżono więcej błędów"}] \ No newline at end of file +[{"title":"Trezor natywnie dla Bitcoin","description":"Sparuj swoje urządzenie i podpisuj transakcje Bitcoin bezpośrednio w aplikacji"},{"title":"Ulepszenia Solany i NFT","description":"Wysyłaj swoje Solana NFT, przechowuj zaimportowane i od razu zobacz, które z nich przechowuje ten portfel"},{"title":"Ulepszenia synchronizacji Monero","description":"Postęp synchronizacji stale się zmienia, podczas gdy Twój portfel się nagrzewa, więc zawsze wiesz, gdzie się znajduje"},{"title":"Poprawki błędów","description":"Synchronizacja kontaktów, wybór węzła, logowanie do Cake Pay i wiele więcej – wszystko to zmiażdżone"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_pt.json b/assets/new-ui/changelog/text/changelog_pt.json index 02f3e18c9..43f728578 100644 --- a/assets/new-ui/changelog/text/changelog_pt.json +++ b/assets/new-ui/changelog/text/changelog_pt.json @@ -1 +1 @@ -[{"title":"Melhorias no AnyPay","description":"Agora, colar ou digitalizar um endereço de qualquer carteira indicará corretamente a troca ou o envio da carteira correta e muito mais melhorias"},{"title":"Melhorias na interface do usuário","description":"Adicionados alguns novos elementos de UI só para você"},{"title":"Correções de bugs","description":"Esmagou mais bugs"}] \ No newline at end of file +[{"title":"Trezor nativamente para Bitcoin","description":"Emparelhe seu dispositivo e assine transações Bitcoin diretamente no aplicativo"},{"title":"Melhorias em Solana e NFTs","description":"Envie seus NFTs Solana, mantenha salvos os importados e veja rapidamente quais esta carteira contém"},{"title":"Melhorias na sincronização do Monero","description":"O progresso da sincronização continua avançando enquanto sua carteira aquece, para que você sempre saiba onde ela está"},{"title":"Correções de bugs","description":"Sincronização de contatos, seleção de nós, login no Cake Pay e muito mais, tudo esmagado"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ru.json b/assets/new-ui/changelog/text/changelog_ru.json index 7b90b12a2..d1a209266 100644 --- a/assets/new-ui/changelog/text/changelog_ru.json +++ b/assets/new-ui/changelog/text/changelog_ru.json @@ -1 +1 @@ -[{"title":"Улучшения AnyPay","description":"Теперь при вставке или сканировании адреса из любого кошелька вы сможете правильно выбрать обмен или отправку из правильного кошелька, а также множество других улучшений."},{"title":"Улучшения пользовательского интерфейса","description":"Специально для вас добавлено несколько свежих элементов пользовательского интерфейса."},{"title":"Исправления ошибок","description":"Устранено больше ошибок"}] \ No newline at end of file +[{"title":"Trezor изначально для биткойнов","description":"Подключите свое устройство и подпишите транзакции Bitcoin прямо в приложении."},{"title":"Улучшения Соланы и NFT","description":"Отправляйте свои NFT Solana, сохраняйте импортированные и сразу смотрите, какие из них хранятся в этом кошельке."},{"title":"Улучшения синхронизации Monero","description":"Процесс синхронизации продолжается, пока ваш кошелек нагревается, поэтому вы всегда знаете, где он находится."},{"title":"Исправления ошибок","description":"Синхронизация контактов, выбор узла, вход в систему Cake Pay и многое другое — все раздавлено"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_th.json b/assets/new-ui/changelog/text/changelog_th.json index 2406e587d..e47bfd8a1 100644 --- a/assets/new-ui/changelog/text/changelog_th.json +++ b/assets/new-ui/changelog/text/changelog_th.json @@ -1 +1 @@ -[{"title":"การปรับปรุง AnyPay","description":"ตอนนี้การวางหรือสแกนที่อยู่จากกระเป๋าเงินใด ๆ จะนำคุณไปสู่การแลกเปลี่ยนหรือส่งจากกระเป๋าเงินที่ถูกต้องอย่างถูกต้อง และการปรับปรุงอื่น ๆ อีกมากมาย"},{"title":"การปรับปรุง UI","description":"เพิ่มองค์ประกอบ UI ใหม่สำหรับคุณโดยเฉพาะ"},{"title":"แก้ไขข้อบกพร่อง","description":"กำจัดข้อบกพร่องเพิ่มเติม"}] \ No newline at end of file +[{"title":"Trezor โดยกำเนิดสำหรับ Bitcoin","description":"จับคู่อุปกรณ์ของคุณและลงนามธุรกรรม Bitcoin ในแอป"},{"title":"การปรับปรุง Solana และ NFT","description":"ส่ง Solana NFT ของคุณ เก็บรายการที่นำเข้าบันทึกไว้ และดูได้อย่างรวดเร็วว่ากระเป๋าสตางค์นี้เก็บรายการใดบ้าง"},{"title":"การปรับปรุงการซิงค์ Monero","description":"ความคืบหน้าในการซิงค์จะดำเนินต่อไปในขณะที่กระเป๋าเงินของคุณอุ่นขึ้น ดังนั้นคุณจะรู้อยู่เสมอว่ามันอยู่ที่ไหน"},{"title":"แก้ไขข้อบกพร่อง","description":"การซิงค์ผู้ติดต่อ การเลือกโหนด การลงชื่อเข้าใช้ Cake Pay และอื่นๆ ทั้งหมดถูกแบน"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_tl.json b/assets/new-ui/changelog/text/changelog_tl.json index da2dc181b..e1f62390e 100644 --- a/assets/new-ui/changelog/text/changelog_tl.json +++ b/assets/new-ui/changelog/text/changelog_tl.json @@ -1 +1 @@ -[{"title":"Mga Pagpapabuti ng AnyPay","description":"Ngayon, ang pag-paste o pag-scan ng isang address mula sa anumang pitaka ay ituturo sa iyo nang tama sa alinman sa pagpapalit o pagpapadala mula sa tamang pitaka, at marami pang mga pagpapabuti"},{"title":"Mga pagpapabuti ng UI","description":"Nagdagdag ng ilang sariwang elemento ng UI para lang sa iyo"},{"title":"Mga pag-aayos ng bug","description":"Nilapit ang higit pang mga bug"}] \ No newline at end of file +[{"title":"Trezor natively para sa Bitcoin","description":"Ipares ang iyong device at lagdaan ang mga transaksyon sa Bitcoin sa app mismo"},{"title":"Mga Pagpapabuti ng Solana at NFT","description":"Ipadala ang iyong mga Solana NFT, panatilihing naka-save ang mga na-import, at tingnan sa isang sulyap kung alin ang hawak ng wallet na ito"},{"title":"Mga pagpapabuti sa pag-sync ng Monero","description":"Patuloy na gumagalaw ang pag-unlad ng pag-sync habang umiinit ang iyong wallet, para lagi mong alam kung saan ito nakatayo"},{"title":"Mga pag-aayos ng bug","description":"Ang pag-sync ng contact, pagpili ng node, pag-sign in sa Cake Pay at higit pa, lahat ay lapirat"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_tr.json b/assets/new-ui/changelog/text/changelog_tr.json index 2487474f9..1bcf4cabf 100644 --- a/assets/new-ui/changelog/text/changelog_tr.json +++ b/assets/new-ui/changelog/text/changelog_tr.json @@ -1 +1 @@ -[{"title":"AnyPay İyileştirmeleri","description":"Artık herhangi bir cüzdandan bir adresi yapıştırmak veya taramak, sizi doğru cüzdandan değiştirmeye veya göndermeye yönlendirecek ve çok daha fazla iyileştirme yapacak"},{"title":"Kullanıcı arayüzü iyileştirmeleri","description":"Size özel bazı yeni kullanıcı arayüzü öğeleri eklendi"},{"title":"Hata düzeltmeleri","description":"Daha fazla hata giderildi"}] \ No newline at end of file +[{"title":"Bitcoin için yerel olarak Trezor","description":"Cihazınızı eşleştirin ve Bitcoin işlemlerini doğrudan uygulamada imzalayın"},{"title":"Solana ve NFT İyileştirmeleri","description":"Solana NFT'lerinizi gönderin, içe aktarılanları saklayın ve bu cüzdanda hangilerinin bulunduğunu bir bakışta görün"},{"title":"Monero senkronizasyon iyileştirmeleri","description":"Cüzdanınız ısınırken senkronizasyon ilerlemesi devam eder, böylece nerede olduğunu her zaman bilirsiniz"},{"title":"Hata düzeltmeleri","description":"Kişi senkronizasyonu, düğüm seçimi, Cake Pay'de oturum açma ve daha fazlası, hepsi engellendi"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_uk.json b/assets/new-ui/changelog/text/changelog_uk.json index 0c018e2e8..213dc57c7 100644 --- a/assets/new-ui/changelog/text/changelog_uk.json +++ b/assets/new-ui/changelog/text/changelog_uk.json @@ -1 +1 @@ -[{"title":"Покращення AnyPay","description":"Тепер вставлення або сканування адреси з будь-якого гаманця правильно вкаже вас на обмін або надсилання з правильного гаманця, а також багато інших покращень"},{"title":"Покращення інтерфейсу користувача","description":"Додано кілька свіжих елементів інтерфейсу спеціально для вас"},{"title":"Виправлення помилок","description":"Роздавив більше помилок"}] \ No newline at end of file +[{"title":"Trezor спочатку для Bitcoin","description":"Підключіть свій пристрій і підписуйте транзакції Bitcoin прямо в додатку"},{"title":"Покращення Solana та NFT","description":"Надсилайте свої NFT Solana, зберігайте імпортовані та одразу дивіться, які з них зберігає цей гаманець"},{"title":"Покращення синхронізації Monero","description":"Процес синхронізації продовжується, поки ваш гаманець розігрівається, тож ви завжди знаєте, де він стоїть"},{"title":"Виправлення помилок","description":"Синхронізація контактів, вибір вузла, вхід у систему Cake Pay тощо – усе зіпсовано"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ur.json b/assets/new-ui/changelog/text/changelog_ur.json index 18a221bca..b30f7878f 100644 --- a/assets/new-ui/changelog/text/changelog_ur.json +++ b/assets/new-ui/changelog/text/changelog_ur.json @@ -1 +1 @@ -[{"title":"AnyPay میں بہتری","description":"اب کسی بھی بٹوے سے ایڈریس پیسٹ یا اسکین کرنا آپ کو صحیح بٹوے سے تبدیل کرنے یا بھیجنے کی طرف صحیح طریقے سے اشارہ کرے گا، اور بہت زیادہ بہتری"},{"title":"UI میں بہتری","description":"صرف آپ کے لیے کچھ تازہ UI عناصر شامل کیے گئے۔"},{"title":"بگ کی اصلاحات","description":"مزید کیڑے کو کچل دیا۔"}] \ No newline at end of file +[{"title":"Trezor مقامی طور پر Bitcoin کے لیے","description":"اپنے آلے کو جوڑیں اور ایپ میں ہی بٹ کوائن کے لین دین پر دستخط کریں۔"},{"title":"سولانا اور NFTs میں بہتری","description":"اپنے سولانا NFTs بھیجیں، درآمد شدہ کو محفوظ رکھیں، اور ایک نظر میں دیکھیں کہ اس پرس میں کون سا ہے"},{"title":"Monero مطابقت پذیری میں بہتری","description":"آپ کے بٹوے کے گرم ہونے کے دوران مطابقت پذیری کی پیشرفت جاری رہتی ہے، لہذا آپ کو ہمیشہ معلوم ہوتا ہے کہ یہ کہاں کھڑا ہے۔"},{"title":"بگ کی اصلاحات","description":"رابطہ کی مطابقت پذیری، نوڈ کا انتخاب، کیک پے سائن ان اور بہت کچھ، سب کو کچل دیا گیا۔"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_vi.json b/assets/new-ui/changelog/text/changelog_vi.json index 5d0798ae4..9a001f2ab 100644 --- a/assets/new-ui/changelog/text/changelog_vi.json +++ b/assets/new-ui/changelog/text/changelog_vi.json @@ -1 +1 @@ -[{"title":"Cải tiến AnyPay","description":"Giờ đây, việc dán hoặc quét địa chỉ từ bất kỳ ví nào sẽ chỉ cho bạn cách hoán đổi hoặc gửi từ đúng ví và nhiều cải tiến hơn nữa"},{"title":"Cải tiến giao diện người dùng","description":"Đã thêm một số thành phần UI mới dành riêng cho bạn"},{"title":"Sửa lỗi","description":"Dẹp nhiều lỗi hơn"}] \ No newline at end of file +[{"title":"Trezor vốn dành cho Bitcoin","description":"Ghép nối thiết bị của bạn và ký các giao dịch Bitcoin ngay trong ứng dụng"},{"title":"Cải tiến Solana và NFT","description":"Gửi NFT Solana của bạn, lưu những cái đã nhập và xem nhanh những cái nào trong ví này chứa"},{"title":"Cải tiến đồng bộ hóa Monero","description":"Tiến trình đồng bộ hóa tiếp tục di chuyển trong khi ví của bạn ấm lên, vì vậy bạn luôn biết vị trí của nó"},{"title":"Sửa lỗi","description":"Đồng bộ hóa liên hệ, chọn nút, đăng nhập Cake Pay và hơn thế nữa, tất cả đều bị nén"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_yo.json b/assets/new-ui/changelog/text/changelog_yo.json index 388cbdb7a..2d1d0c023 100644 --- a/assets/new-ui/changelog/text/changelog_yo.json +++ b/assets/new-ui/changelog/text/changelog_yo.json @@ -1 +1 @@ -[{"title":"Awọn ilọsiwaju AnyPay","description":"Ni bayi titọ tabi ṣiṣayẹwo adirẹsi kan lati eyikeyi apamọwọ yoo tọka si ni deede si boya paarọ tabi firanṣẹ lati apamọwọ to pe, ati awọn ilọsiwaju pupọ diẹ sii."},{"title":"UI awọn ilọsiwaju","description":"Ṣafikun diẹ ninu awọn eroja UI tuntun kan fun ọ"},{"title":"Awọn atunṣe kokoro","description":"Squashed diẹ idun"}] \ No newline at end of file +[{"title":"Trezor abinibi fun Bitcoin","description":"So ẹrọ rẹ pọ ki o forukọsilẹ awọn iṣowo Bitcoin ni ọtun ninu ohun elo naa"},{"title":"Solana ati NFTs Awọn ilọsiwaju","description":"Firanṣẹ awọn NFT Solana rẹ, tọju awọn ti a ko wọle si, ki o wo ni iwo kan kini awọn ti apamọwọ yii mu"},{"title":"Monero ìsiṣẹpọ","description":"Ilọsiwaju mimuṣiṣẹpọ n tẹsiwaju lakoko ti apamọwọ rẹ n gbona, nitorinaa o mọ ibiti o duro nigbagbogbo"},{"title":"Awọn atunṣe kokoro","description":"Imuṣiṣẹpọ olubasọrọ, yiyan ipade, Wọle isanwo Cake Pay ati diẹ sii, gbogbo wọn ti fọ"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_zh-cn.json b/assets/new-ui/changelog/text/changelog_zh-cn.json index c5a8fa4a8..dc0285390 100644 --- a/assets/new-ui/changelog/text/changelog_zh-cn.json +++ b/assets/new-ui/changelog/text/changelog_zh-cn.json @@ -1 +1 @@ -[{"title":"AnyPay 改进","description":"现在,从任何钱包粘贴或扫描地址将正确地指示您从正确的钱包进行交换或发送,以及更多改进"},{"title":"用户界面改进","description":"为您添加了一些新鲜的 UI 元素"},{"title":"错误修复","description":"消除了更多错误"}] \ No newline at end of file +[{"title":"Trezor 原生适用于比特币","description":"配对您的设备并直接在应用程序中签署比特币交易"},{"title":"Solana 和 NFT 的改进","description":"发送您的 Solana NFT,保存导入的 NFT,并一目了然地查看该钱包包含哪些 NFT"},{"title":"门罗币同步改进","description":"当您的钱包热身时,同步进度会不断变化,因此您始终知道进展情况"},{"title":"错误修复","description":"联系人同步、节点选择、Cake Pay 登录等全部被压缩"}] \ No newline at end of file diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index 4696fab40..8590afc8b 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -641,6 +641,13 @@ Future defaultSettingsMigration( case 70: await _addTbbTokenToExistingSolanaWallets(); break; + case 71: + _changeExchangeProviderAvailability( + sharedPreferences, + providerName: "Swaps.XYZ", + enabled: false, + ); + break; default: break; } diff --git a/lib/main.dart b/lib/main.dart index 02229c80a..28fed70d6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -308,7 +308,7 @@ Future initializeAppConfigs({bool loadWallet = true}) async { payjoinSessionSource: payjoinSessionSource, anonpayInvoiceInfo: anonpayInvoiceInfo, havenSeedStore: havenSeedStore, - initialMigrationVersion: 70, + initialMigrationVersion: 71, ); } diff --git a/lib/src/screens/dashboard/pages/nft_send_page.dart b/lib/src/screens/dashboard/pages/nft_send_page.dart index 558974058..ef8f5cd9b 100644 --- a/lib/src/screens/dashboard/pages/nft_send_page.dart +++ b/lib/src/screens/dashboard/pages/nft_send_page.dart @@ -151,7 +151,7 @@ class _NFTSendPage extends BasePage { } Future _onSendPressed(BuildContext context) async { - if (nftSendViewModel.state is IsExecutingState) { + if (nftSendViewModel.isStartingSend || nftSendViewModel.state is IsExecutingState) { return; } @@ -163,18 +163,23 @@ class _NFTSendPage extends BasePage { final destinationAddress = addressController.text.trim(); - await authService.authenticateAction( - context, - conditionToDetermineIfToUse2FA: nftSendViewModel.shouldRequireTOTP2FAFor(destinationAddress), - onAuthSuccess: (isAuthenticatedSuccessfully) async { - if (!isAuthenticatedSuccessfully || !context.mounted) { - nftSendViewModel.reset(); - return; - } + try { + await authService.authenticateAction( + context, + conditionToDetermineIfToUse2FA: + nftSendViewModel.shouldRequireTOTP2FAFor(destinationAddress), + onAuthSuccess: (isAuthenticatedSuccessfully) async { + if (!isAuthenticatedSuccessfully || !context.mounted) { + nftSendViewModel.reset(); + return; + } - await _createAndConfirm(context, destinationAddress); - }, - ); + await _createAndConfirm(context, destinationAddress); + }, + ); + } finally { + nftSendViewModel.markSendSettled(); + } } Future _createAndConfirm(BuildContext context, String destinationAddress) async { diff --git a/lib/view_model/dashboard/nft_send_view_model.dart b/lib/view_model/dashboard/nft_send_view_model.dart index 2ebb46a98..9ab104805 100644 --- a/lib/view_model/dashboard/nft_send_view_model.dart +++ b/lib/view_model/dashboard/nft_send_view_model.dart @@ -90,11 +90,25 @@ abstract class NFTSendViewModelBase with Store { } } - @action - void markSendStarted() => state = IsExecutingState(); + /// Re-entrancy latch for the send button, held only while the tap handler is + /// inside [AuthService.authenticateAction]. + /// + /// Deliberately not [state]: the auth sheet is pushed closable, and closing it + /// pops the route without ever invoking onAuthenticationFinished, so a flag + /// that waits on the auth result would never be cleared. The caller clears + /// this in a finally instead. Once auth succeeds, createTransaction takes over + /// the guard by setting IsExecutingState synchronously. + bool _isStartingSend = false; + + bool get isStartingSend => _isStartingSend; + + void markSendStarted() => _isStartingSend = true; + + void markSendSettled() => _isStartingSend = false; @action void reset() { + _isStartingSend = false; pendingTransaction = null; state = InitialExecutionState(); } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 6c07ce789..d35ba142d 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,7 +1,7 @@ PODS: - bitbox_flutter (0.0.1): - FlutterMacOS - - breez_sdk_spark_flutter (0.14.0): + - breez_sdk_spark_flutter (0.23.0): - FlutterMacOS - connectivity_plus (0.0.1): - FlutterMacOS @@ -162,7 +162,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: bitbox_flutter: b3c6d3c4970b134a78a17fddef0ef429cfec2593 - breez_sdk_spark_flutter: 0da35c86276ec740d938139262e198a475586548 + breez_sdk_spark_flutter: 0041ddadcea42244824bdc3b436d44ecd2fa27ae connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e cw_mweb: 4746a3b5cc5bd4afe6fadd2481c4f7b239e391d0 device_info_plus: b0fafc687fb901e2af612763340f1b0d4352f8e5 diff --git a/pubspec.lock b/pubspec.lock index 90493aca9..f37ff20e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -3095,21 +3095,21 @@ packages: source: git version: "0.0.1" trezor_flutter: - dependency: "direct overridden" + dependency: "direct main" description: path: trezor-flutter - ref: "7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97" - resolved-ref: "7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97" - url: "https://github.com/cake-tech/trezor-flutter.git" + ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 + resolved-ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 + url: "https://github.com/cake-tech/trezor-flutter" source: git version: "0.1.0" trezor_usb_transport: dependency: "direct overridden" description: path: trezor_usb_transport - ref: "7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97" - resolved-ref: "7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97" - url: "https://github.com/cake-tech/trezor-flutter.git" + ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 + resolved-ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 + url: "https://github.com/cake-tech/trezor-flutter" source: git version: "0.0.1" tuple: diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 63d97de78..8aae31be0 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -131,7 +131,7 @@ dependencies: trezor_flutter: git: url: https://github.com/cake-tech/trezor-flutter.git - ref: 7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97 + ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487 path: trezor-flutter bitbox_flutter: path: ./scripts/bitbox_flutter diff --git a/scripts/android/app_env.sh b/scripts/android/app_env.sh index e69e908aa..1b257c5e0 100644 --- a/scripts/android/app_env.sh +++ b/scripts/android/app_env.sh @@ -14,15 +14,15 @@ TYPES=($MONERO_COM $CAKEWALLET) APP_ANDROID_TYPE=$1 MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="6.4.2" -MONERO_COM_BUILD_NUMBER=4167 +MONERO_COM_VERSION="6.4.4" +MONERO_COM_BUILD_NUMBER=4168 MONERO_COM_BUNDLE_ID="com.monero.app" MONERO_COM_PACKAGE="com.monero.app" MONERO_COM_SCHEME="monero.com" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="6.4.3" -CAKEWALLET_BUILD_NUMBER=4423 +CAKEWALLET_VERSION="6.4.4" +CAKEWALLET_BUILD_NUMBER=4424 CAKEWALLET_BUNDLE_ID="com.cakewallet.cake_wallet" CAKEWALLET_PACKAGE="com.cakewallet.cake_wallet" CAKEWALLET_SCHEME="cakewallet" diff --git a/scripts/ios/app_env.sh b/scripts/ios/app_env.sh index 33dedff14..f538ff3d7 100644 --- a/scripts/ios/app_env.sh +++ b/scripts/ios/app_env.sh @@ -12,13 +12,13 @@ TYPES=($MONERO_COM $CAKEWALLET) APP_IOS_TYPE=$1 MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="6.4.2" -MONERO_COM_BUILD_NUMBER=173 +MONERO_COM_VERSION="6.4.4" +MONERO_COM_BUILD_NUMBER=174 MONERO_COM_BUNDLE_ID="com.cakewallet.monero" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="6.4.3" -CAKEWALLET_BUILD_NUMBER=437 +CAKEWALLET_VERSION="6.4.4" +CAKEWALLET_BUILD_NUMBER=438 CAKEWALLET_BUNDLE_ID="com.fotolockr.cakewallet" diff --git a/scripts/linux/app_env.sh b/scripts/linux/app_env.sh index ba08cabd9..5f6197964 100755 --- a/scripts/linux/app_env.sh +++ b/scripts/linux/app_env.sh @@ -14,8 +14,8 @@ if [ -n "$1" ]; then fi CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="6.4.1" -CAKEWALLET_BUILD_NUMBER=81 +CAKEWALLET_VERSION="6.4.4" +CAKEWALLET_BUILD_NUMBER=82 if ! [[ " ${TYPES[*]} " =~ " ${APP_LINUX_TYPE} " ]]; then echo "Wrong app type." diff --git a/scripts/macos/app_env.sh b/scripts/macos/app_env.sh index 2683183af..25ff92dee 100755 --- a/scripts/macos/app_env.sh +++ b/scripts/macos/app_env.sh @@ -16,13 +16,13 @@ if [ -n "$1" ]; then fi MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="6.4.2" -MONERO_COM_BUILD_NUMBER=83 +MONERO_COM_VERSION="6.4.4" +MONERO_COM_BUILD_NUMBER=85 MONERO_COM_BUNDLE_ID="com.cakewallet.monero" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="6.4.2" -CAKEWALLET_BUILD_NUMBER=151 +CAKEWALLET_VERSION="6.4.4" +CAKEWALLET_BUILD_NUMBER=153 CAKEWALLET_BUNDLE_ID="com.fotolockr.cakewallet" if ! [[ " ${TYPES[*]} " =~ " ${APP_MACOS_TYPE} " ]]; then From 9d6adcfe682c1d76cbb7390da51f7ca087033439 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Tue, 8 Sep 2026 06:36:25 +0200 Subject: [PATCH 10/15] Use baseunit provider (#3582) * feat: introduce BaseUnit provider for money settings with aspect-based notifications and add unit tests * test: expand use_base_unit_provider_test with comprehensive scenarios for BaseUnitConfig behaviors --- .../money/money_settings_provider.dart | 13 +- .../widgets/money/use_base_unit_provider.dart | 63 +++ .../money/use_base_unit_provider_test.dart | 377 ++++++++++++++++++ 3 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 lib/new-ui/widgets/money/use_base_unit_provider.dart create mode 100644 test/new-ui/widgets/money/use_base_unit_provider_test.dart diff --git a/lib/new-ui/widgets/money/money_settings_provider.dart b/lib/new-ui/widgets/money/money_settings_provider.dart index 17831a28a..93f6b21cd 100644 --- a/lib/new-ui/widgets/money/money_settings_provider.dart +++ b/lib/new-ui/widgets/money/money_settings_provider.dart @@ -1,15 +1,20 @@ import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cake_wallet/new-ui/widgets/money/use_base_unit_provider.dart"; import "package:cake_wallet/store/settings_store.dart"; import "package:flutter/material.dart"; import "package:flutter_bloc/flutter_bloc.dart"; class MoneySettingsProvider extends StatelessWidget { - const MoneySettingsProvider({required this.settingsStore, this.child, super.key}); + const MoneySettingsProvider({required this.settingsStore, required this.child, super.key}); - final Widget? child; + final Widget child; final SettingsStore settingsStore; @override - Widget build(BuildContext context) => - BlocProvider(create: (_) => MoneySettingsCubit(settingsStore), child: child); + Widget build(BuildContext context) => BlocProvider( + create: (_) => MoneySettingsCubit(settingsStore), + child: BaseUnitScope( + child: child, + ), + ); } diff --git a/lib/new-ui/widgets/money/use_base_unit_provider.dart b/lib/new-ui/widgets/money/use_base_unit_provider.dart new file mode 100644 index 000000000..e04e47350 --- /dev/null +++ b/lib/new-ui/widgets/money/use_base_unit_provider.dart @@ -0,0 +1,63 @@ +import "dart:async"; + +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cw_core/currency.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; + +/// Read-only view of the money display settings for the current subtree. +/// +/// ```dart +/// final useBaseUnit = BaseUnit.useBaseUnitOf(context, CryptoCurrency.btc); +/// ``` +class BaseUnit extends InheritedModel { + const BaseUnit({required this.state, required super.child}); + + final MoneySettingsState state; + + static bool useBaseUnitOf(BuildContext context, Currency currency) => + InheritedModel.inheritFrom(context, aspect: currency)!.state.useBaseUnit(currency); + + static String getSymbolOf(BuildContext context, Currency currency) => + InheritedModel.inheritFrom(context, aspect: currency)!.state.getSymbol(currency); + + @override + bool updateShouldNotify(BaseUnit old) => + state.bitcoinAmountDisplayMode != old.state.bitcoinAmountDisplayMode; + + @override + bool updateShouldNotifyDependent(BaseUnit old, Set aspects) => aspects.any((c) => + state.useBaseUnit(c) != old.state.useBaseUnit(c) || + state.getSymbol(c) != old.state.getSymbol(c)); +} + +class BaseUnitScope extends StatefulWidget { + const BaseUnitScope({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _BaseUnitScopeState(); +} + +class _BaseUnitScopeState extends State { + late MoneySettingsState _state; + late final StreamSubscription _sub; + + @override + void initState() { + super.initState(); + final cubit = context.read(); + _state = cubit.state; + _sub = cubit.stream.listen((s) => setState(() => _state = s)); + } + + @override + void dispose() { + _sub.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BaseUnit(state: _state, child: widget.child); +} diff --git a/test/new-ui/widgets/money/use_base_unit_provider_test.dart b/test/new-ui/widgets/money/use_base_unit_provider_test.dart new file mode 100644 index 000000000..ea340cc2d --- /dev/null +++ b/test/new-ui/widgets/money/use_base_unit_provider_test.dart @@ -0,0 +1,377 @@ +import "package:cake_wallet/entities/balance_display_mode.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cake_wallet/new-ui/widgets/money/use_base_unit_provider.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:flutter_test/flutter_test.dart"; + +class FakeMoneySettingsCubit extends Cubit implements MoneySettingsCubit { + FakeMoneySettingsCubit(super.initialState); + + void setState(MoneySettingsState state) => emit(state); +} + +MoneySettingsState _moneySettingsState({ + BitcoinAmountDisplayMode bitcoin = BitcoinAmountDisplayMode.bitcoin, + BalanceDisplayMode display = BalanceDisplayMode.availableBalance, +}) => + MoneySettingsState( + bitcoinAmountDisplayMode: bitcoin, + displayMode: display, + ); + +Future _pumpEmission(WidgetTester tester) => tester.pump(Duration.zero); + +class _Probe extends StatelessWidget { + const _Probe({required this.currencies, required this.log}); + + _Probe.single(Currency currency, {required this.log, super.key}) + : currencies = [currency]; + + final List currencies; + final List> log; + + @override + Widget build(BuildContext context) { + final observed = + currencies.map((c) => BaseUnit.useBaseUnitOf(context, c)).toList(growable: false); + log.add(observed); + return const SizedBox.shrink(); + } +} + +class _SymbolProbe extends StatelessWidget { + const _SymbolProbe(this.currency, this.log, {super.key}); + + final Currency currency; + final List log; + + @override + Widget build(BuildContext context) { + log.add(BaseUnit.getSymbolOf(context, currency)); + return const SizedBox.shrink(); + } +} + +class _SubtreeCounter extends StatelessWidget { + const _SubtreeCounter(this.log, {required this.child}); + + final List log; + final Widget child; + + @override + Widget build(BuildContext context) { + log.add(null); + return child; + } +} + +Widget _host(FakeMoneySettingsCubit cubit, Widget child) => MaterialApp( + home: + BlocProvider.value(value: cubit, child: BaseUnitScope(child: child)), + ); + +void main() { + group("BaseUnit", () { + group("BaseUnit.useBaseUnitOf", () { + testWidgets("reads the value from the nearest ancestor", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final log = >[]; + + await tester.pumpWidget( + _host(cubit, _Probe.single(CryptoCurrency.btc, log: log)), + ); + + expect(log, [[true]]); + }); + + testWidgets("nearest ancestor wins when models are nested", (tester) async { + final log = >[]; + + await tester.pumpWidget( + BaseUnit( + state: _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + child: BaseUnit( + state: _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + child: _Probe.single(CryptoCurrency.btc, log: log), + ), + ), + ); + + expect(log.single, [false]); + }); + + testWidgets("throws when no BaseUnit ancestor exists", (tester) async { + await tester.pumpWidget(_Probe.single(CryptoCurrency.btc, log: >[])); + + expect(tester.takeException(), isA()); + }); + }); + + group("BaseUnit.getSymbolOf", () { + testWidgets("returns the ticker under whole-coin mode", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = []; + + await tester.pumpWidget(_host(cubit, _SymbolProbe(CryptoCurrency.btc, log))); + + expect(log.single, CryptoCurrency.btc.symbol); + }); + + testWidgets("flips to sats when the mode changes", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = []; + + await tester.pumpWidget(_host(cubit, _SymbolProbe(CryptoCurrency.btc, log))); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(log, [CryptoCurrency.btc.symbol, "sats"]); + }); + + testWidgets("spares a symbol reader whose currency did not change", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final btcLog = []; + final btclnLog = []; + + await tester.pumpWidget( + _host( + cubit, + Row( + children: [ + _SymbolProbe(CryptoCurrency.btc, btcLog, key: const ValueKey("btc")), + _SymbolProbe(CryptoCurrency.btcln, btclnLog, key: const ValueKey("btcln")), + ], + ), + ), + ); + expect(btcLog.single, "sats"); + expect(btclnLog.single, "sats"); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshiForLightning)); + await _pumpEmission(tester); + + expect(btcLog.length, 2); + expect(btcLog.last, CryptoCurrency.btc.symbol); + + expect(btclnLog.length, 1, reason: "btcln symbol did not change"); + }); + + testWidgets("a non-bitcoin symbol is stable across every mode", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = []; + + await tester.pumpWidget(_host(cubit, _SymbolProbe(CryptoCurrency.xmr, log))); + + for (final mode in BitcoinAmountDisplayMode.all) { + cubit.setState(_moneySettingsState(bitcoin: mode)); + await _pumpEmission(tester); + } + + expect(log.single, CryptoCurrency.xmr.symbol); + }); + }); + + group("BaseUnit aspect notification", () { + testWidgets("rebuilds a dependent whose currency flipped", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = >[]; + + await tester.pumpWidget(_host(cubit, _Probe.single(CryptoCurrency.btc, log: log))); + expect(log.length, 1); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(log.length, 2); + expect(log.last, [true]); + }); + + testWidgets("satoshi -> satoshiForLightning rebuilds btc but not btcln", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final btcLog = >[]; + final btclnLog = >[]; + + await tester.pumpWidget( + _host( + cubit, + Row( + children: [ + _Probe.single(CryptoCurrency.btc, log: btcLog, key: const ValueKey("btc")), + _Probe.single(CryptoCurrency.btcln, log: btclnLog, key: const ValueKey("btcln")), + ], + ), + ), + ); + expect(btcLog.single, [true]); + expect(btclnLog.single, [true]); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshiForLightning)); + await _pumpEmission(tester); + + // btc: true -> false, so it must rebuild. + expect(btcLog.length, 2); + expect(btcLog.last, [false]); + + // btcln: true -> true. The aspect filter should have spared it. + expect(btclnLog.length, 1, reason: "btcln value did not change"); + }); + + testWidgets("a currency outside the bitcoin family never rebuilds", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = >[]; + + await tester.pumpWidget(_host(cubit, _Probe.single(CryptoCurrency.xmr, log: log))); + + for (final mode in BitcoinAmountDisplayMode.all) { + cubit.setState(_moneySettingsState(bitcoin: mode)); + await _pumpEmission(tester); + } + + expect(log.length, 1); + }); + + testWidgets("a dependent on several currencies rebuilds if any changed", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final log = >[]; + + await tester.pumpWidget( + _host( + cubit, + _Probe(currencies: [CryptoCurrency.xmr, CryptoCurrency.btc], log: log), + ), + ); + expect(log.single, [false, true]); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshiForLightning)); + await _pumpEmission(tester); + + expect(log.length, 2); + expect(log.last, [false, false]); + }); + + testWidgets("a value-equal but distinct state does not reach dependents", (tester) async { + final initial = _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi); + final cubit = FakeMoneySettingsCubit(initial); + final log = >[]; + + await tester.pumpWidget(_host(cubit, _Probe.single(CryptoCurrency.btc, log: log))); + expect(log.length, 1); + + cubit.setState(initial.copyWith()); + await _pumpEmission(tester); + + expect(log.length, 1); + }); + + testWidgets("changing only displayMode does not reach useBaseUnit dependents", + (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState( + bitcoin: BitcoinAmountDisplayMode.satoshi, + display: BalanceDisplayMode.availableBalance, + ), + ); + final log = >[]; + + await tester.pumpWidget( + _host(cubit, _Probe.single(CryptoCurrency.btc, log: log)), + ); + + cubit.setState( + _moneySettingsState( + bitcoin: BitcoinAmountDisplayMode.satoshi, + display: BalanceDisplayMode.hiddenBalance, + ), + ); + await _pumpEmission(tester); + + expect(log.length, 1); + }); + }); + + group("BaseUnitScope subtree isolation", () { + testWidgets("an emission does not rebuild the subtree", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final subtreeLog = []; + final probeLog = >[]; + + await tester.pumpWidget( + _host( + cubit, + _SubtreeCounter( + subtreeLog, + child: _Probe.single(CryptoCurrency.btc, log: probeLog), + ), + ), + ); + expect(subtreeLog.length, 1); + expect(probeLog.length, 1); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(probeLog.length, 2); + expect(probeLog.last, [true]); + + expect(subtreeLog.length, 1, reason: "the subtree was rebuilt"); + }); + + testWidgets("nested subtrees below the boundary are untouched", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final outerLog = []; + final innerLog = []; + final probeLog = >[]; + + await tester.pumpWidget( + _host( + cubit, + _SubtreeCounter( + outerLog, + child: Padding( + padding: EdgeInsets.zero, + child: _SubtreeCounter( + innerLog, + child: _Probe.single(CryptoCurrency.btc, log: probeLog), + ), + ), + ), + ), + ); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(probeLog.length, 2); + expect(outerLog.length, 1); + expect(innerLog.length, 1); + }); + }); + }); +} From 7e0f0fc3cd909d90d0c4b91b9dd3f8158771a5be Mon Sep 17 00:00:00 2001 From: Blazebrain Date: Wed, 9 Sep 2026 02:35:27 +0100 Subject: [PATCH 11/15] refactor: improve locale handling fixing issues with yoruba, hausa and guarani languages --- cw_core/lib/amount/money_local.dart | 7 +-- cw_core/lib/crypto_amount_format.dart | 5 ++- cw_core/test/amount/money_local_test.dart | 4 ++ cw_core/test/crypto_amount_format.dart | 9 ++++ lib/entities/language_service.dart | 11 ++--- lib/main.dart | 2 + .../list_item_selector_widget.dart | 43 +++++++++++-------- lib/store/settings_store.dart | 17 ++++++-- tool/generate_localization.dart | 14 +++--- 9 files changed, 68 insertions(+), 44 deletions(-) diff --git a/cw_core/lib/amount/money_local.dart b/cw_core/lib/amount/money_local.dart index c5a3c0ea3..8f88985c7 100644 --- a/cw_core/lib/amount/money_local.dart +++ b/cw_core/lib/amount/money_local.dart @@ -1,5 +1,5 @@ import "package:cw_core/amount/money.dart"; -import "package:intl/intl.dart"; +import "package:cw_core/crypto_amount_format.dart"; extension WithLocalSeparator on Money { String toLocalStringWithSymbol({ @@ -37,10 +37,7 @@ extension WithLocalSeparator on Money { String _withLocalSeparator(String amount, {String? locale}) { final isNegative = amount.startsWith("-"); - final formater = NumberFormat("#,###", locale); - final parts = (isNegative ? amount.substring(1) : amount).split("."); - final formatted = [formater.format(int.tryParse(parts.first) ?? 0), ...parts.sublist(1)] - .join(formater.symbols.DECIMAL_SEP); + final formatted = (isNegative ? amount.substring(1) : amount).withLocalSeperator(locale); return isNegative ? "-$formatted" : formatted; } diff --git a/cw_core/lib/crypto_amount_format.dart b/cw_core/lib/crypto_amount_format.dart index 2c1d96f4e..ddefd9eec 100644 --- a/cw_core/lib/crypto_amount_format.dart +++ b/cw_core/lib/crypto_amount_format.dart @@ -67,7 +67,10 @@ extension MaxDecimals on String { return [parts.first.withLocalSeperator(locale), ...parts.sublist(1)].join(" "); } - final formater = NumberFormat("#,###", locale); + final formater = NumberFormat( + "#,###", + Intl.verifiedLocale(locale, NumberFormat.localeExists, onFailure: (_) => "en"), + ); final parts = replaceAll(",", "").split("."); if (parts.first.contains("< 0")) parts.first = "0"; diff --git a/cw_core/test/amount/money_local_test.dart b/cw_core/test/amount/money_local_test.dart index 24a4fffac..15aeaea0e 100644 --- a/cw_core/test/amount/money_local_test.dart +++ b/cw_core/test/amount/money_local_test.dart @@ -23,6 +23,10 @@ void main() { expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5"); }); + test("formats as English when intl has no number data for the locale (yo)", () { + expect(_btcGrouping.toLocalStringWithPrecision(locale: "yo"), "1,234.5"); + }); + test("base unit renders integer sats with grouping", () { expect( _btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"), diff --git a/cw_core/test/crypto_amount_format.dart b/cw_core/test/crypto_amount_format.dart index c611ad7d8..fa0458bde 100644 --- a/cw_core/test/crypto_amount_format.dart +++ b/cw_core/test/crypto_amount_format.dart @@ -108,6 +108,15 @@ void main() { }); }); + group("Locale without intl number data", () { + test("should format as English instead of throwing (ha)", () { + const input = "1123.4567"; + final result = input.withLocalSeperator("ha"); + + expect(result, equals("1,123.4567")); + }); + }); + group('With XMR Suffix', () { const locale = 'de_CH'; diff --git a/lib/entities/language_service.dart b/lib/entities/language_service.dart index 5331b1e83..95200fbcc 100644 --- a/lib/entities/language_service.dart +++ b/lib/entities/language_service.dart @@ -3,16 +3,11 @@ import 'package:devicelocale/devicelocale.dart'; import "package:flutter/widgets.dart"; import 'package:intl/intl.dart'; -// This will turn stored language code into Flutter Locale. -// Some of our language codes uses an underscore (like "pt_BR") and this must be split into language -// and country subtags, if we pass the whole string as the languageCode, GlobalMaterialLocalizations -// would reject it as an invalid ISO 639-1 code and crash the app on locale switch +// Flutter's Material delegates match on the language subtag, so "pt_BR" must be split into two Locale localeFromLanguageCode(String code) { final parts = code.split("_"); - if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { - return Locale(parts[0], parts[1]); - } - return Locale(code); + + return parts.length == 2 ? Locale(parts[0], parts[1]) : Locale(code); } class LanguageService { diff --git a/lib/main.dart b/lib/main.dart index eeb9e0394..300fb19d4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -65,6 +65,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_daemon/flutter_daemon.dart'; +import "package:flutter_localizations/flutter_localizations.dart"; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:hive/hive.dart'; import 'package:quick_actions/quick_actions.dart'; @@ -329,6 +330,7 @@ Future initialSetup({ required int initialMigrationVersion, }) async { LanguageService.loadLocaleList(); + await GlobalMaterialLocalizations.delegate.load(const Locale("en")); await defaultSettingsMigration( secureStorage: secureStorage, version: initialMigrationVersion, diff --git a/lib/src/widgets/new_list_row/list_item_selector_widget.dart b/lib/src/widgets/new_list_row/list_item_selector_widget.dart index fc7071445..7989fdbd3 100644 --- a/lib/src/widgets/new_list_row/list_item_selector_widget.dart +++ b/lib/src/widgets/new_list_row/list_item_selector_widget.dart @@ -32,30 +32,35 @@ class ListItemSelectorWidget extends StatelessWidget { onTap: onTap, isFirstInSection: isFirstInSection, isLastInSection: isLastInSection, - builder: (context, textStyle, labelStyle) { - return Row( + builder: (context, textStyle, labelStyle) => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(label, style: textStyle)), - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - spacing: 8, - children: [ - Text( - options[selectedIndex], - style: labelStyle, - ), - CakeImageWidget( - imageUrl: "assets/new-ui/chooser.svg", - colorFilter: - ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn), - ), - ], + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Flexible( + child: Text( + options[selectedIndex], + style: labelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + CakeImageWidget( + imageUrl: "assets/new-ui/chooser.svg", + colorFilter: + ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn), + ), + ], + ), ), ), ], - ); - }); + )); } } diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index f845cf86a..bbbffa0c0 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -1378,8 +1378,8 @@ abstract class SettingsStoreBase with Store { pinLength = defaultPinLength; } - final savedLanguageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ?? - await LanguageService.localeDetection(); + final savedLanguageCode = + _offeredLanguageCode(sharedPreferences) ?? await LanguageService.localeDetection(); final nodeId = sharedPreferences.getInt(PreferencesKey.currentNodeIdKey); final bitcoinElectrumServerId = sharedPreferences.getInt(PreferencesKey.currentBitcoinElectrumSererIdKey); @@ -1938,7 +1938,12 @@ abstract class SettingsStoreBase with Store { } pinCodeLength = pinLength; - languageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ?? languageCode; + final restoredLanguageCode = _offeredLanguageCode(sharedPreferences); + if (restoredLanguageCode == null) { + await sharedPreferences.setString(PreferencesKey.currentLanguageCode, languageCode); + } else { + languageCode = restoredLanguageCode; + } shouldShowYatPopup = sharedPreferences.getBool(PreferencesKey.shouldShowYatPopup) ?? shouldShowYatPopup; shouldShowDEuroDisclaimer = @@ -2315,6 +2320,12 @@ abstract class SettingsStoreBase with Store { await _sharedPreferences.setString(key, serializedData); } + static String? _offeredLanguageCode(SharedPreferences sharedPreferences) { + final code = sharedPreferences.getString(PreferencesKey.currentLanguageCode); + + return LanguageService.list.containsKey(code) ? code : null; + } + static Future _getDeviceName() async { String? deviceName = ''; final deviceInfoPlugin = DeviceInfoPlugin(); diff --git a/tool/generate_localization.dart b/tool/generate_localization.dart index 5bbf016a4..ed2a4679f 100644 --- a/tool/generate_localization.dart +++ b/tool/generate_localization.dart @@ -1,3 +1,4 @@ +import "dart:collection"; import 'dart:io'; import 'dart:convert'; @@ -48,7 +49,7 @@ Future main(List args) async { return; } - final arbFiles = {}; + final localePath = SplayTreeMap(); await dir.list(recursive: false).forEach((element) { // Parse the locale from the file name (e.g. strings_pt_br.arb -> pt_BR), // normalizing the case so keys match LanguageService.supportedLocales. @@ -62,12 +63,9 @@ Future main(List args) async { final locale = parts.length > 1 ? '${parts.first.toLowerCase()}_${parts.sublist(1).join('_').toUpperCase()}' : parts.first.toLowerCase(); - arbFiles[locale] = element.path; + localePath[locale] = element.path; }); - final sortedLocales = arbFiles.keys.toList()..sort(); - final localePath = {for (final key in sortedLocales) key: arbFiles[key]}; - if (!localePath.keys.contains(defaultLocale)) { print("Locale list doesn't contain $defaultLocale"); return; @@ -105,13 +103,13 @@ Future main(List args) async { output += classDeclaration; - // for handling keys like pt_BR so entry becomes Locale("pt", "BR") and doesn't crash app + // Flutter matches on the language subtag, so pt_BR is emitted as Locale("pt", "BR") localePath.keys.forEach((key) { final parts = key.split("_"); if (parts.length == 2) { - output += " Locale(\"${parts[0]}\", \"${parts[1]}\"),\n"; + output += ' Locale("${parts[0]}", "${parts[1]}"),\n'; } else { - output += " Locale(\"$key\", \"\"),\n"; + output += ' Locale("$key", ""),\n'; } }); From be16d531ae8d1309337c1ac780b2b62186c246a5 Mon Sep 17 00:00:00 2001 From: Blazebrain Date: Wed, 9 Sep 2026 05:36:04 +0100 Subject: [PATCH 12/15] feat: enhance locale detection --- lib/entities/language_service.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/entities/language_service.dart b/lib/entities/language_service.dart index 95200fbcc..6da74346a 100644 --- a/lib/entities/language_service.dart +++ b/lib/entities/language_service.dart @@ -93,12 +93,16 @@ class LanguageService { static Future localeDetection() async { try { - var locale = await Devicelocale.currentLocale ?? ''; - locale = Intl.shortLocale(locale); - + final locale = Intl.canonicalizedLocale(await Devicelocale.currentLocale ?? ""); if (list.keys.contains(locale)) { return locale; } + + final language = Intl.shortLocale(locale); + if (list.keys.contains(language)) { + return language; + } + return LanguageService.defaultLocale; } catch (_) { return LanguageService.defaultLocale; From 0b3ac17660d45414f4802d478e29d58252e2a56b Mon Sep 17 00:00:00 2001 From: cyan Date: Wed, 9 Sep 2026 18:02:19 +0800 Subject: [PATCH 13/15] createTransaction use BigInt instead of double (#3608) --- lib/view_model/send/send_view_model.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 9e3fa7f55..48ba722a9 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -907,9 +907,11 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor final isSendAll = outputs.any((output) => output.sendAll); if (!isSendAll) { - final estimateTxAmountDouble = outputs.fold( - 0, (acc, output) => acc + (double.tryParse(output.cryptoAmount) ?? 0)); - if (estimateTxAmountDouble <= 0) throw Exception('Amount must be greater than 0'); + final estimateTxAmount = outputs.fold( + BigInt.zero, (acc, output) => acc + output.cryptoAmountMoney.amount); + if (estimateTxAmount <= BigInt.zero) { + throw Exception('Amount must be greater than 0'); + } } pendingTransaction = await wallet.createTransaction(_credentials(provider)); From 09fdded7a637c6dad36ecc9795c5e2d8a795e30b Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:34:19 -0400 Subject: [PATCH 14/15] docs: update build instructions and fix stale doc links (#3559) (#3561) Replace the README's placeholder build section and the stale howto-build-android.md reference with links to the current platform-specific guides in docs/builds/. Also fix broken links in docs/ADDING_NEW_WALLET_TYPES.md that pointed to a non-existent adding_new_l2_network_guide.md file and align the README/docs copyright years with LICENSE.md (2018-2025). --- README.md | 10 +++++++--- docs/ADDING_NEW_WALLET_TYPES.md | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fa98091c6..b1db44a88 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,13 @@ We have excellent user guides, which are also open-source and open for contribut # Build Instructions -More instructions to follow +Building Cake Wallet for each platform is covered in the platform-specific guides under `docs/builds/`: -For instructions on how to build for Android: please view file `howto-build-android.md` +- [Building for Android](docs/builds/ANDROID.md) +- [Building for iOS](docs/builds/IOS.md) +- [Building for Linux](docs/builds/LINUX.md) +- [Building for macOS](docs/builds/MACOS.md) +- [Building for Windows](docs/builds/WINDOWS.md) # Contributing @@ -186,4 +190,4 @@ The only parts to be translated, if needed, are the values m and s after the var --- -Copyright (C) 2018-2023 Cake Labs LLC +Copyright (C) 2018-2025 Cake Labs LLC diff --git a/docs/ADDING_NEW_WALLET_TYPES.md b/docs/ADDING_NEW_WALLET_TYPES.md index 4beecba8a..dac56e056 100644 --- a/docs/ADDING_NEW_WALLET_TYPES.md +++ b/docs/ADDING_NEW_WALLET_TYPES.md @@ -2,7 +2,7 @@ ## Important: EVM-Compatible Wallets -**If you're adding an EVM-compatible wallet (Ethereum, Polygon, Base, Arbitrum, Optimism, etc.)**, please refer to the **[Adding New L2 Network Guide](./adding_new_l2_network_guide.md)** instead. EVM wallets use a unified architecture and don't require the full setup described in this guide. +**If you're adding an EVM-compatible wallet (Ethereum, Polygon, Base, Arbitrum, Optimism, etc.)**, please refer to the **[Adding New L2 Network Guide](./ADDING_EVM_L2_WALLET_NETWORK_TYPES.md)** instead. EVM wallets use a unified architecture and don't require the full setup described in this guide. **For EVM wallets:** - Use `WalletType.evm` (unified type for all EVM chains) @@ -984,7 +984,7 @@ Add: dart run build_runner build --delete-conflicting-outputs ``` -4. **EVM Wallets**: If adding an EVM-compatible wallet, use the [L2 Network Guide](./adding_new_l2_network_guide.md) instead. +4. **EVM Wallets**: If adding an EVM-compatible wallet, use the [L2 Network Guide](./ADDING_EVM_L2_WALLET_NETWORK_TYPES.md) instead. 5. **Token Support**: If your wallet supports tokens (like ERC20, SPL, TRC20): - Add token checks in `exchange_trade_view_model.dart` @@ -1029,4 +1029,4 @@ Add: --- -Copyright (C) 2018-2023 Cake Labs LLC +Copyright (C) 2018-2025 Cake Labs LLC From 191d7492283b6af6c37d2b038ddb34a038686c14 Mon Sep 17 00:00:00 2001 From: cyan Date: Thu, 10 Sep 2026 19:29:37 +0800 Subject: [PATCH 15/15] fix: disable prebuilt libraries from cargokit (#3610) --- cargokit_options.yaml | 1 + 1 file changed, 1 insertion(+) create mode 100644 cargokit_options.yaml diff --git a/cargokit_options.yaml b/cargokit_options.yaml new file mode 100644 index 000000000..1527430bd --- /dev/null +++ b/cargokit_options.yaml @@ -0,0 +1 @@ +use_precompiled_binaries: false \ No newline at end of file