Merge branch 'dev' of https://github.com/cake-tech/cake_wallet into CW-1529-Improved-Seed-UI

This commit is contained in:
Blazebrain
2026-09-04 11:30:51 +01:00
100 changed files with 3636 additions and 788 deletions
+48 -16
View File
@@ -430,7 +430,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
.isNotEmpty;
Future<PsbtV2> buildPsbt({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required List<OutputInfo> 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<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
+5 -2
View File
@@ -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<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
+2 -2
View File
@@ -106,11 +106,11 @@ class LitecoinBitboxService extends HardwareWalletService
@override
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> 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()
@@ -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<Uint8List> getMasterFingerprint() async => Uint8List.fromList([0, 0, 0, 0]);
@@ -10,7 +9,7 @@ mixin BitcoinHardwareWalletService {
mixin LitecoinHardwareWalletService on BitcoinHardwareWalletService {
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
});
@@ -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<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> 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,
);
}
}
@@ -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<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
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<String> signLitecoinTransaction({
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> 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");
}
}
+128 -140
View File
@@ -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<Uint8List> 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<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final accounts = <HardwareAccountData>[];
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
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<Uint8List> signTransaction({required String transaction}) async {
final psbt = PsbtV2()..deserialize(base64Decode(transaction));
final masterFingerprint = await getMasterFingerprint();
final inputs = <sdk.TrezorTxInput>[];
final prevTxs = <String, sdk.TrezorPrevTx>{};
final inputs = <TrezorTxInput>[];
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 = <TrezorTxOutput>[];
final outputs = <sdk.TrezorTxOutput>[];
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<Uint8List> 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<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
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<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> 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<Uint8List> 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<String, Uint8List> 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)));
}
}
+34 -14
View File
@@ -1556,7 +1556,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
@override
Future<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> 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 = <PSBTReadyUtxoWithAddress>[];
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);
}
+112 -63
View File
@@ -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<PSBTReadyUtxoWithAddress> inputs,
required List<BitcoinBaseOutput> outputs,
required List<OutputInfo> cwOutputs,
bool enableRBF = true,
int locktime = 0}) {
PSBTTransactionBuild({
required List<PSBTReadyUtxoWithAddress> inputs,
required List<PSBTReadyBitcoinOutput> 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;
}
+5
View File
@@ -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:
+25 -1
View File
@@ -63,7 +63,7 @@ Future<void> _initDb({String? pathOverride}) async {
}
}
await db?.close();
db = await openDatabase(dbFile.path, version: 11,
db = await openDatabase(dbFile.path, version: 12,
onUpgrade: (Database db, int oldVersion, int newVersion) async {
printV("migrating: $oldVersion, $newVersion");
if (oldVersion <= 1) {
@@ -153,6 +153,9 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
await _createTronTokenTable(db);
}
if (oldVersion <= 10) {
await _createImportedNFTTable(db);
}
if (oldVersion <= 11) {
await _addColumnIfNotExists(
db,
table: 'WalletInfo',
@@ -259,6 +262,7 @@ CREATE TABLE BalanceCardStyleSettings (
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
await _createImportedNFTTable(db);
});
}
@@ -431,6 +435,26 @@ ON SPLToken (walletName, mintAddress);
""");
}
Future<void> _createImportedNFTTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS ImportedNFT (
ImportedNFTId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
walletName TEXT NOT NULL,
chain TEXT NOT NULL,
identifier TEXT NOT NULL,
name TEXT,
symbol TEXT,
description TEXT,
imageUrl TEXT,
isOwned INTEGER
);
""");
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_importednft_wallet_chain_identifier
ON ImportedNFT (walletName, chain, identifier);
""");
}
Future<void> _createTronTokenTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS TronToken (
+2
View File
@@ -55,6 +55,8 @@ class SignSPLTokenTransactionRentException implements Exception {}
class NoAssociatedTokenAccountException implements Exception {}
class NotAnNFTException implements Exception {}
class AmbiguousTokenSymbolException implements Exception {
AmbiguousTokenSymbolException(this.symbol);
+138
View File
@@ -0,0 +1,138 @@
import "package:cw_core/db/sqlite.dart";
import "package:sqflite/sqflite.dart";
class ImportedNFT {
ImportedNFT({
required this.walletName,
required this.chain,
required this.identifier,
this.name,
this.symbol,
this.description,
this.imageUrl,
this.isOwned,
this.id = 0,
});
ImportedNFT.copyWith(ImportedNFT other, {String? walletName})
: id = 0,
walletName = walletName ?? other.walletName,
chain = other.chain,
identifier = other.identifier,
name = other.name,
symbol = other.symbol,
description = other.description,
imageUrl = other.imageUrl,
isOwned = other.isOwned;
factory ImportedNFT.fromMap(Map<String, Object?> map) => ImportedNFT(
id: map[selfIdColumn] as int? ?? 0,
walletName: map["walletName"]! as String,
chain: map["chain"]! as String,
identifier: map["identifier"]! as String,
name: map["name"] as String?,
symbol: map["symbol"] as String?,
description: map["description"] as String?,
imageUrl: map["imageUrl"] as String?,
isOwned: map["isOwned"] == null ? null : map["isOwned"] == 1,
);
int id;
final String walletName;
final String chain;
final String identifier;
final String? name;
final String? symbol;
final String? description;
final String? imageUrl;
final bool? isOwned;
static String get tableName => "ImportedNFT";
static String get selfIdColumn => "${tableName}Id";
static const solanaChain = "solana";
Map<String, Object?> toMap() => {
selfIdColumn: id,
"walletName": walletName,
"chain": chain,
"identifier": identifier,
"name": name,
"symbol": symbol,
"description": description,
"imageUrl": imageUrl,
"isOwned": isOwned == null ? null : (isOwned! ? 1 : 0),
};
Future<int> save() async {
final row = toMap();
if (row[selfIdColumn] == 0) {
row[selfIdColumn] = null;
}
id = await db!.insert(tableName, row, conflictAlgorithm: ConflictAlgorithm.replace);
return id;
}
static Future<int> updateMetadata(ImportedNFT nft) => db!.update(
tableName,
nft.toMap()..remove(selfIdColumn),
where: "walletName = ? AND chain = ? AND identifier = ?",
whereArgs: [nft.walletName, nft.chain, nft.identifier],
);
static Future<List<ImportedNFT>> getAllForWallet(String walletName, [String? chain]) async {
final rows = await db!.query(
tableName,
where: chain == null ? "walletName = ?" : "walletName = ? AND chain = ?",
whereArgs: chain == null ? [walletName] : [walletName, chain],
orderBy: selfIdColumn,
);
return rows.map(ImportedNFT.fromMap).toList();
}
static Future<int> deleteOne(String walletName, String chain, String identifier) => db!.delete(
tableName,
where: "walletName = ? AND chain = ? AND identifier = ?",
whereArgs: [walletName, chain, identifier],
);
static String _scope(List<String>? chains) =>
chains == null ? "" : " AND chain IN (${List.filled(chains.length, "?").join(", ")})";
static Future<int> deleteAllForWallet(String walletName, {List<String>? chains}) async {
if (chains != null && chains.isEmpty) {
return 0;
}
return db!.delete(
tableName,
where: "walletName = ?${_scope(chains)}",
whereArgs: [walletName, ...?chains],
);
}
static Future<void> renameWallet(
String oldName,
String newName, {
List<String>? chains,
}) async {
if (chains != null && chains.isEmpty) {
return;
}
await db!.delete(
tableName,
where: "walletName = ?${_scope(chains)}",
whereArgs: [newName, ...?chains],
);
await db!.update(
tableName,
{"walletName": newName},
where: "walletName = ?${_scope(chains)}",
whereArgs: [oldName, ...?chains],
);
}
}
+25
View File
@@ -0,0 +1,25 @@
const _ipfsGateway = "https://ipfs.io/ipfs/";
const _ipfsScheme = "ipfs://";
String? tryNormalizeIpfsUrl(String? url) {
if (url == null) {
return null;
}
if (!url.toLowerCase().startsWith(_ipfsScheme)) {
return url.toLowerCase().startsWith("https://") ? url : null;
}
var cid = url.substring(_ipfsScheme.length);
// Some minters write ipfs://ipfs/<cid>, which would double the gateway path.
if (cid.toLowerCase().startsWith("ipfs/")) {
cid = cid.substring(5);
}
if (cid.isEmpty) {
return null;
}
return "$_ipfsGateway$cid";
}
+25
View File
@@ -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";
}
+6
View File
@@ -1,6 +1,7 @@
import "dart:convert";
import "dart:io";
import "package:cw_core/imported_nft.dart";
import "package:cw_core/pathForWallet.dart";
import "package:cw_core/spl_token.dart";
import "package:cw_core/tron_token.dart";
@@ -62,6 +63,11 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
Future<void> _renameTokenRows(String currentName, String newName) async {
if (getType() == WalletType.solana) {
await SPLToken.renameWallet(currentName, newName);
await ImportedNFT.renameWallet(
currentName,
newName,
chains: const [ImportedNFT.solanaChain],
);
}
if (getType() == WalletType.tron) {
+176
View File
@@ -0,0 +1,176 @@
import "dart:io";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/imported_nft.dart";
import "package:flutter_test/flutter_test.dart";
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
import "package:sqflite_common_ffi/sqflite_ffi.dart";
// Faking the documents dir keeps getAppDir() off the platform channel, so the
// test runs with a plain `flutter test` on any host and in CI.
class _FakePathProviderPlatform extends PathProviderPlatform {
_FakePathProviderPlatform(this.root);
final String root;
@override
Future<String?> getApplicationDocumentsPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
}
Future<void> main() async {
final dataRoot = Directory("./test/data/imported_nft");
Future<void> insert(String walletName, String chain, String identifier) =>
ImportedNFT(walletName: walletName, chain: chain, identifier: identifier, name: identifier)
.save();
Future<List<String>> identifiersFor(String walletName, [String? chain]) async =>
(await ImportedNFT.getAllForWallet(walletName, chain)).map((nft) => nft.identifier).toList();
setUpAll(() async {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
dataRoot.createSync(recursive: true);
Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
await initDb();
});
setUp(() async {
await db!.delete(ImportedNFT.tableName);
});
tearDownAll(() {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
});
group("ImportedNFT wallet lifecycle", () {
test("reading without a chain returns every chain the wallet imported on", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "polygon", "0xother:7");
expect(await identifiersFor("Main", "eth"), ["0xcontract:1"]);
expect(
(await identifiersFor("Main"))..sort(),
["0xcontract:1", "0xother:7", "SolMint"],
);
});
test("rename moves every chain and leaves other wallets alone", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Sidekick", "eth", "0xkeep:9");
await ImportedNFT.renameWallet("Main", "Renamed");
expect(await identifiersFor("Main"), isEmpty);
expect((await identifiersFor("Renamed"))..sort(), ["0xcontract:1", "SolMint"]);
expect(await identifiersFor("Sidekick"), ["0xkeep:9"]);
});
test("renaming into a name that still has orphan rows does not trip the unique index",
() async {
await insert("Orphaned", "eth", "0xcontract:1");
await insert("Orphaned", "polygon", "0xsurvivor:5");
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "eth", "0xsecond:2");
await ImportedNFT.renameWallet("Main", "Orphaned", chains: const ["eth"]);
// Only the colliding chain is cleared. A row the destination holds on
// another chain is not ours to delete.
expect(
(await identifiersFor("Orphaned"))..sort(),
["0xcontract:1", "0xsecond:2", "0xsurvivor:5"],
);
});
test("renaming a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
await insert("Sol", ImportedNFT.solanaChain, "SolMint");
await insert("Vault", "eth", "0xEthNFT:1");
await ImportedNFT.renameWallet(
"Sol",
"Vault",
chains: const [ImportedNFT.solanaChain],
);
expect((await identifiersFor("Vault"))..sort(), ["0xEthNFT:1", "SolMint"]);
});
test("deleting a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
await insert("Vault", ImportedNFT.solanaChain, "SolMint");
await insert("Vault", "eth", "0xEthNFT:1");
await ImportedNFT.deleteAllForWallet(
"Vault",
chains: const [ImportedNFT.solanaChain],
);
expect(await identifiersFor("Vault"), ["0xEthNFT:1"]);
});
test("an empty chain scope deletes nothing rather than everything", () async {
await insert("Main", "eth", "0xcontract:1");
await ImportedNFT.deleteAllForWallet("Main", chains: const []);
expect(await identifiersFor("Main"), ["0xcontract:1"]);
});
test("delete removes every chain for that wallet only", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Sidekick", ImportedNFT.solanaChain, "OtherMint");
await ImportedNFT.deleteAllForWallet("Main");
expect(await identifiersFor("Main"), isEmpty);
expect(await identifiersFor("Sidekick"), ["OtherMint"]);
});
test("deleting one leaves the wallet's other imports in place", () async {
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "eth", "0xsecond:2");
await insert("Main", "polygon", "0xcontract:1");
await ImportedNFT.deleteOne("Main", "eth", "0xcontract:1");
expect(await identifiersFor("Main", "eth"), ["0xsecond:2"]);
expect(await identifiersFor("Main", "polygon"), ["0xcontract:1"]);
});
test("copying to a new name keeps the rows under the old one", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
for (final nft in await ImportedNFT.getAllForWallet("Main")) {
await ImportedNFT.copyWith(nft, walletName: "Copy").save();
}
expect((await identifiersFor("Main"))..sort(), ["0xcontract:1", "SolMint"]);
expect((await identifiersFor("Copy"))..sort(), ["0xcontract:1", "SolMint"]);
});
test("a copy is a new row, so saving it does not overwrite the original", () async {
await insert("Main", "eth", "0xcontract:1");
final original = (await ImportedNFT.getAllForWallet("Main")).single;
final copy = ImportedNFT.copyWith(original, walletName: "Copy");
await copy.save();
expect(copy.id, isNot(original.id));
expect((await ImportedNFT.getAllForWallet("Main")).single.id, original.id);
});
});
}
+55
View File
@@ -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");
});
});
}
+89
View File
@@ -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",
);
}
});
});
}
+12
View File
@@ -3,6 +3,7 @@ import "dart:io";
import 'package:bip39/bip39.dart' as bip39;
import 'package:cw_core/encryption_file_utils.dart';
import "package:cw_core/erc20_token.dart";
import "package:cw_core/imported_nft.dart";
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
@@ -36,6 +37,9 @@ class EVMChainWalletService extends WalletService<
return _registry.getRegisteredWalletTypes();
}
List<String> get _importedNFTChains =>
_registry.getAllChains().map((chain) => chain.shortCode).toList();
Future<WalletInfo?> _findWalletByName(String name) async {
for (final type in _evmWalletTypes) {
final walletInfo = await WalletInfo.get(name, type);
@@ -192,8 +196,15 @@ class EVMChainWalletService extends WalletService<
final copiedToken = Erc20Token.copyWith(token, walletName: newName);
await copiedToken.save();
}
final ownNFTs = (await ImportedNFT.getAllForWallet(currentName))
.where((nft) => _importedNFTChains.contains(nft.chain));
for (final nft in ownNFTs) {
await ImportedNFT.copyWith(nft, walletName: newName).save();
}
} else {
await Erc20Token.renameWallet(currentName, newName);
await ImportedNFT.renameWallet(currentName, newName, chains: _importedNFTChains);
}
final oldDir = Directory(p.join(await pathForWalletTypeDir(type: type), currentName));
@@ -351,6 +362,7 @@ class EVMChainWalletService extends WalletService<
final nameStillUsed = (await _findWalletByName(wallet)) != null;
if (!nameStillUsed) {
await Erc20Token.deleteAllForWallet(wallet);
await ImportedNFT.deleteAllForWallet(wallet, chains: _importedNFTChains);
}
}
+50 -14
View File
@@ -165,23 +165,44 @@ int getCurrentHeight() => currentWallet?.blockChainHeight() ?? 0;
int cachedNodeHeight = 0;
bool isHeightRefreshing = false;
Future<int>? _nodeHeightInFlight;
Future<int> _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<int> 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<void> store() async => _storeSync(0);
Future<int> getNodeHeight() async => getNodeHeightSync();
Future<int> getNodeHeight() async {
final cached = getNodeHeightSync();
if (cached != 0) return cached;
return _refreshNodeHeight();
}
void rescanBlockchainAsync() => currentWallet!.rescanBlockchainAsync();
+2 -2
View File
@@ -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:
+263 -12
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import "dart:typed_data";
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_core/amount/money.dart';
@@ -7,6 +8,7 @@ import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/solana_rpc_http_service.dart';
import "package:cw_core/utils/ipfs_url.dart";
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_solana/pending_solana_transaction.dart';
import 'package:cw_solana/solana_balance.dart';
@@ -110,6 +112,62 @@ class SolanaWalletClient {
}
}
Future<Set<String>> _mintsToWithholdFromTokenList(Set<String> mints) async {
const batchSize = 10;
final ordered = mints.toList();
final withheld = <String>{};
Future<bool> isUnconfirmedFungible(String mint) async {
try {
return await isSupplyOfOne(mint, throwOnError: true);
} catch (_) {
return true;
}
}
for (var i = 0; i < ordered.length; i += batchSize) {
final batch = ordered.skip(i).take(batchSize).toList();
final results = await Future.wait(batch.map(isUnconfirmedFungible));
for (var j = 0; j < batch.length; j++) {
if (results[j]) {
withheld.add(batch[j]);
}
}
}
return withheld;
}
Future<Set<String>> fetchHeldTokenMints(String walletAddress) async {
final owner = SolAddress(walletAddress);
final responses = await Future.wait([
_provider!.request(
SolanaRPCGetTokenAccountsByOwner(
account: owner,
programId: SPLTokenProgramConst.tokenProgramId,
commitment: Commitment.confirmed,
encoding: SolanaRPCEncoding.base64,
),
),
_provider!.request(
SolanaRPCGetTokenAccountsByOwner(
account: owner,
programId: SPLTokenProgramConst.token2022ProgramId,
commitment: Commitment.confirmed,
encoding: SolanaRPCEncoding.base64,
),
),
]);
return {
for (final accounts in responses)
for (final account in accounts)
if (account.tokenAccount.amount > BigInt.zero) account.tokenAccount.mint.address,
};
}
Future<SolanaBalance?> getSplTokenBalance(SPLToken token, String walletAddress,
{bool throwOnError = false}) async {
try {
@@ -1162,6 +1220,43 @@ class SolanaWalletClient {
}
}
int _parseMoralisDecimals(Object? raw) {
if (raw == null) {
return 0;
}
if (raw is num) {
return raw.toInt();
}
final parsed = int.tryParse(raw.toString());
if (parsed == null) {
printV("Unexpected decimals value from Moralis: $raw");
return 0;
}
return parsed;
}
Future<bool> isSupplyOfOne(String mintAddress, {bool throwOnError = false}) async {
try {
final supply = await _provider!.request(
SolanaRPCGetTokenSupply(account: SolAddress(mintAddress)),
);
return supply.amount == "1" && supply.decimals == 0;
} catch (e) {
printV("Could not read supply for mint $mintAddress: ${e.toString()}");
if (throwOnError) {
rethrow;
}
return false;
}
}
Future<SPLToken?> fetchSPLTokenInfo(String mintAddress) async {
try {
final uri = Uri.https(
@@ -1252,6 +1347,101 @@ class SolanaWalletClient {
}
}
// This is the exact size for an spl token, anything larger is Token-2022
// carrying extensions
static const _baseTokenAccountSize = 165;
static const _maxOffChainMetadataBytes = 256 * 1024;
Future<String?> _readOffChainMetadata(Uri uri) async {
final httpClient = ProxyWrapper().getHttpClient();
try {
final request = await httpClient.getUrl(uri);
request.maxRedirects = 3;
final response = await request.close().timeout(const Duration(seconds: 30));
if (response.redirects.any((redirect) => redirect.location.scheme != "https")) {
return null;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
if (response.contentLength > _maxOffChainMetadataBytes) {
return null;
}
final bytes = BytesBuilder(copy: false);
await for (final chunk in response) {
bytes.add(chunk);
if (bytes.length > _maxOffChainMetadataBytes) {
return null;
}
}
return utf8.decode(bytes.takeBytes(), allowMalformed: true);
} finally {
httpClient.close(force: true);
}
}
Future<NFTOnChainMetadata?> getNFTOnChainMetadata(String mintAddress) async {
try {
final programAddress =
MetaplexTokenMetaDataProgramUtils.findMetadataPda(mint: SolAddress(mintAddress));
final token = await _provider!.request(
SolanaRPCGetMetadataAccount(
account: programAddress.address,
commitment: Commitment.confirmed,
),
);
if (token == null) {
return null;
}
final metadata = token.data;
String stripNulls(String value) => value.replaceAll("\u0000", "").trim();
String? stringOrNull(Object? value) => value is String ? value : null;
final metadataUri = stripNulls(metadata.uri);
final metadataUrl = tryNormalizeIpfsUrl(metadataUri);
final offChainUri = metadataUrl == null ? null : Uri.tryParse(metadataUrl);
String? imageUrl;
if (offChainUri != null && offChainUri.scheme == "https") {
try {
final body = await _readOffChainMetadata(offChainUri);
if (body != null) {
imageUrl = stringOrNull((jsonDecode(body) as Map<String, dynamic>)["image"]);
}
} catch (e) {
printV("Off-chain NFT metadata fetch failed: ${e.toString()}");
}
}
return NFTOnChainMetadata(
mint: mintAddress,
name: stripNulls(metadata.name),
symbol: stripNulls(metadata.symbol),
metadataUri: metadataUri,
imageUrl: imageUrl,
);
} catch (e) {
printV("Error fetching on-chain NFT metadata: $e");
return null;
}
}
void stop() => _isStopped = true;
SolanaRPC? get getSolanaProvider => _provider;
@@ -1263,6 +1453,7 @@ class SolanaWalletClient {
required bool isSendAll,
required Money solBalance,
String? tokenMint,
bool closeSenderAccountWhenEmptied = false,
List<String> references = const [],
}) async {
const commitment = Commitment.confirmed;
@@ -1285,6 +1476,7 @@ class SolanaWalletClient {
destinationAddress: destinationAddress,
commitment: commitment,
solBalance: solBalance,
closeSenderAccountWhenEmptied: closeSenderAccountWhenEmptied,
);
}
}
@@ -1589,6 +1781,7 @@ class SolanaWalletClient {
required SolanaPrivateKey ownerPrivateKey,
required Commitment commitment,
required Money solBalance,
bool closeSenderAccountWhenEmptied = false,
}) async {
final mintAddress = SolAddress(tokenMint);
final tokenProgramId = await _getTokenProgramId(mintAddress);
@@ -1716,6 +1909,30 @@ class SolanaWalletClient {
transferInstructions,
];
final senderAccountHasExtensions = senderAccountSpace != _baseTokenAccountSize;
if (closeSenderAccountWhenEmptied && !senderAccountHasExtensions) {
try {
final senderBalance = await _provider!.request(
SolanaRPCGetTokenAccountBalance(
account: associatedSenderAccount.address,
commitment: commitment,
),
);
if (BigInt.tryParse(senderBalance.amount) == inputAmount.amount) {
instructions.add(SPLTokenProgram.closeAccount(
account: associatedSenderAccount.address,
destination: ownerPrivateKey.publicKey().toAddress(),
authority: ownerPrivateKey.publicKey().toAddress(),
programId: senderTokenProgramId,
));
}
} catch (e) {
printV("Skipping rent reclaim, could not check sender balance: ${e.toString()}");
}
}
final latestBlockHash = await _getLatestBlockhash(commitment);
final transaction = SolanaTransaction(
@@ -1838,24 +2055,42 @@ class SolanaWalletClient {
final decodedResponse = jsonDecode(response.body) as List;
final List<MoralisSolanaTokenBalance> tokens = [];
final nftCandidates = <String>{};
for (final item in decodedResponse) {
final tokenData = item as Map<String, dynamic>;
try {
final tokenData = item as Map<String, dynamic>;
final amountStr = tokenData['amount'] as String? ?? '0';
final amount = double.tryParse(amountStr) ?? 0.0;
final amountStr = tokenData["amount"] as String? ?? "0";
final amount = double.tryParse(amountStr) ?? 0.0;
if (amount <= 0) continue;
if (amount <= 0) continue;
final mint = tokenData['mint'] as String? ?? '';
if (mint.isEmpty) continue;
final mint = tokenData["mint"] as String? ?? "";
if (mint.isEmpty) continue;
tokens.add(
MoralisSolanaTokenBalance(
mint: mint,
amount: amount,
),
);
final amountRaw = tokenData["amountRaw"]?.toString() ?? "0";
final decimals = _parseMoralisDecimals(tokenData["decimals"]);
if (decimals == 0 && amountRaw == "1") {
nftCandidates.add(mint);
}
tokens.add(
MoralisSolanaTokenBalance(
mint: mint,
amount: amount,
),
);
} catch (e) {
printV("Skipping malformed Moralis token entry: ${e.toString()}");
}
}
if (nftCandidates.isNotEmpty) {
final withheld = await _mintsToWithholdFromTokenList(nftCandidates);
tokens.removeWhere((token) => withheld.contains(token.mint));
}
return tokens;
@@ -1908,6 +2143,22 @@ class SolanaWalletClient {
}
}
class NFTOnChainMetadata {
const NFTOnChainMetadata({
required this.mint,
required this.name,
required this.symbol,
required this.metadataUri,
this.imageUrl,
});
final String mint;
final String name;
final String symbol;
final String metadataUri;
final String? imageUrl;
}
class MoralisSolanaTokenBalance {
final String mint;
final double amount;
+2
View File
@@ -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);
+32
View File
@@ -323,6 +323,38 @@ abstract class SolanaWalletBase
return matches.first;
}
Future<Set<String>> heldTokenMints() => _client.fetchHeldTokenMints(walletAddresses.address);
Future<PendingTransaction> sendNFT({
required String mintAddress,
required String destinationAddress,
String? name,
}) async {
if (!await _client.isSupplyOfOne(mintAddress, throwOnError: true)) {
throw SolanaNotAnNFTException();
}
final nftCurrency = SPLToken(
name: name ?? "NFT",
symbol: "NFT",
mintAddress: mintAddress,
decimal: 0,
mint: mintAddress,
);
final solBalance = await _client.getBalance(walletAddresses.address, throwOnError: true);
return _client.signSolanaTransaction(
tokenMint: mintAddress,
inputAmount: Money(BigInt.one, nftCurrency),
ownerPrivateKey: _solanaPrivateKey,
destinationAddress: destinationAddress,
isSendAll: false,
solBalance: solBalance,
closeSenderAccountWhenEmptied: true,
);
}
@override
Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
+2
View File
@@ -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();
+3 -3
View File
@@ -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
@@ -0,0 +1,6 @@
class CakePayUnauthorizedException implements Exception {
const CakePayUnauthorizedException();
@override
String toString() => 'Cake Pay session is no longer valid, please log in again.';
}
@@ -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<void> 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;
@@ -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();
},
);
},
),
],
@@ -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<String, dynamic>;
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}');
}
+42 -19
View File
@@ -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<CakePayOrder> findOrderById({required String orderId}) async {
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.getOrderById(orderId: orderId, token: token);
}
Future<CakePayOrder> findOrderById({required String orderId}) => _withUserToken(
(token) => cakePayApi.getOrderById(
orderId: orderId,
token: token,
),
);
///Simulate Purchase Gift Card
Future<String> 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<String> simulatePayment({required String orderId}) => _withUserToken(
(token) => cakePayApi.simulatePayment(
CSRFToken: CSRFToken,
authorization: authorization,
token: token,
orderId: orderId,
),
);
/// Wrapper for requests with user token
Future<T> _withUserToken<T>(Future<T> 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;
}
}
}
+4
View File
@@ -224,6 +224,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';
@@ -1704,6 +1705,9 @@ Future<void> setup({
getIt.registerFactory(() => NFTViewModel(appStore, getIt.get<BottomSheetService>()));
getIt.registerFactory(() =>
NFTSendViewModel(appStore, getIt.get<ContactListViewModel>(param1: CryptoCurrency.sol)));
getIt.registerFactory(() => SignViewModel(getIt.get<AppStore>().wallet!));
getIt.registerFactoryParam<SeedVerificationPage, bool, void>((bool isNewWalletCreated, _) =>
+116
View File
@@ -0,0 +1,116 @@
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cake_wallet/entities/wallet_nft_response.dart";
import "package:cw_core/imported_nft.dart";
class SavedNFTs {
Future<Map<String, SolanaNFTAssetModel>> solanaNFTs(String walletName) async {
final rows = await ImportedNFT.getAllForWallet(walletName, ImportedNFT.solanaChain);
return {
for (final row in rows)
row.identifier: SolanaNFTAssetModel(
address: row.identifier,
mint: row.identifier,
name: row.name,
symbol: row.symbol,
description: row.description,
imageOriginalUrl: row.imageUrl,
isOwned: row.isOwned,
),
};
}
Future<void> addSolana(String walletName, SolanaNFTAssetModel asset) async {
final mint = asset.mint;
if (mint == null || mint.isEmpty) {
return;
}
await _rowForSolana(walletName, mint, asset).save();
}
Future<void> refreshSolana(String walletName, List<SolanaNFTAssetModel> assets) async {
for (final asset in assets) {
final mint = asset.mint;
if (mint == null) {
continue;
}
await ImportedNFT.updateMetadata(_rowForSolana(walletName, mint, asset));
}
}
Future<List<NFTAssetModel>> evmNFTs(String walletName, String chain) async {
final rows = await ImportedNFT.getAllForWallet(walletName, chain);
return rows
.map(
(row) => NFTAssetModel(
tokenAddress: _tokenAddressOf(row.identifier),
tokenId: _tokenIdOf(row.identifier),
name: row.name,
symbol: row.symbol,
normalizedMetadata: NormalizedMetadata(
name: row.name,
description: row.description,
image: row.imageUrl,
),
),
)
.toList();
}
Future<void> addEvm(String walletName, String chain, NFTAssetModel asset) async {
final identifier = evmIdentifier(asset);
if (identifier == null) {
return;
}
await ImportedNFT(
walletName: walletName,
chain: chain,
identifier: identifier,
name: asset.name,
symbol: asset.symbol,
description: asset.normalizedMetadata?.description,
imageUrl: asset.normalizedMetadata?.imageUrl,
).save();
}
Future<void> removeSolana(String walletName, String mint) =>
ImportedNFT.deleteOne(walletName, ImportedNFT.solanaChain, mint);
static String? evmIdentifier(NFTAssetModel asset) {
final address = asset.tokenAddress;
final tokenId = asset.tokenId;
if (address == null || address.isEmpty) {
return null;
}
return "$address:${tokenId ?? ""}";
}
ImportedNFT _rowForSolana(String walletName, String mint, SolanaNFTAssetModel asset) =>
ImportedNFT(
walletName: walletName,
chain: ImportedNFT.solanaChain,
identifier: mint,
name: asset.name,
symbol: asset.symbol,
description: asset.description,
imageUrl: asset.imageOriginalUrl,
isOwned: asset.isOwned,
);
String _tokenAddressOf(String identifier) => identifier.split(":").first;
String _tokenIdOf(String identifier) {
final parts = identifier.split(":");
return parts.length > 1 ? parts.sublist(1).join(":") : "";
}
}
+46 -41
View File
@@ -1,3 +1,6 @@
import "package:cw_core/utils/ipfs_url.dart";
import "package:cw_core/utils/nft_text.dart";
class SolanaNFTAssetModel {
String? address;
String? mint;
@@ -12,46 +15,50 @@ class SolanaNFTAssetModel {
Metaplex? metaplex;
Collection? collection;
Contract? contract;
bool? isOwned;
SolanaNFTAssetModel({
this.address,
this.mint,
this.standard,
this.name,
this.symbol,
this.description,
this.imageOriginalUrl,
String? name,
String? symbol,
String? description,
String? imageOriginalUrl,
this.externalUrl,
this.metadataOriginalUrl,
this.totalSupply,
this.metaplex,
this.collection,
this.contract,
});
this.isOwned,
}) : name = sanitizeNFTText(name),
symbol = sanitizeNFTText(symbol),
description = sanitizeNFTText(description),
imageOriginalUrl = tryNormalizeIpfsUrl(imageOriginalUrl);
factory SolanaNFTAssetModel.fromJson(Map<String, dynamic> json) {
return SolanaNFTAssetModel(
address: json['address'] as String?,
mint: json['mint'] as String?,
standard: json['standard'] as String?,
name: json['name'] as String?,
symbol: json['symbol'] as String?,
description: json['description'] as String?,
imageOriginalUrl: json['imageOriginalUrl'] as String?,
externalUrl: json['externalUrl'] as String?,
metadataOriginalUrl: json['metadataOriginalUrl'] as String?,
totalSupply: json['totalSupply'] as String?,
metaplex: json['metaplex'] != null
? Metaplex.fromJson(json['metaplex'] as Map<String, dynamic>)
: null,
collection: json['collection'] != null
? Collection.fromJson(json['collection'] as Map<String, dynamic>)
: null,
contract: json['contract'] != null
? Contract.fromJson(json['contract'] as Map<String, dynamic>)
: null,
);
}
factory SolanaNFTAssetModel.fromJson(Map<String, dynamic> json) => SolanaNFTAssetModel(
address: json["address"] as String?,
mint: json["mint"] as String?,
standard: json["standard"] as String?,
name: json["name"] as String?,
symbol: json["symbol"] as String?,
description: json["description"] as String?,
imageOriginalUrl: json["imageOriginalUrl"] as String?,
externalUrl: json["externalUrl"] as String?,
metadataOriginalUrl: json["metadataOriginalUrl"] as String?,
totalSupply: json["totalSupply"] as String?,
metaplex: json["metaplex"] != null
? Metaplex.fromJson(json["metaplex"] as Map<String, dynamic>)
: null,
collection: json["collection"] != null
? Collection.fromJson(json["collection"] as Map<String, dynamic>)
: null,
contract: json["contract"] != null
? Contract.fromJson(json["contract"] as Map<String, dynamic>)
: null,
isOwned: json["isOwned"] as bool?,
);
}
class Metaplex {
@@ -100,17 +107,15 @@ class Collection {
this.metaplexMint,
this.sellerFeeBasisPoints});
factory Collection.fromJson(Map<String, dynamic> json) {
return Collection(
collectionAddress: json['collectionAddress'] as String?,
name: json['name'] as String?,
description: json['description'] as String?,
imageOriginalUrl: json['imageOriginalUrl'] as String?,
externalUrl: json['externalUrl'] as String?,
metaplexMint: json['metaplexMint'] as String?,
sellerFeeBasisPoints: json['sellerFeeBasisPoints'] as int?,
);
}
factory Collection.fromJson(Map<String, dynamic> json) => Collection(
collectionAddress: json["collectionAddress"] as String?,
name: sanitizeNFTText(json["name"] as String?),
description: sanitizeNFTText(json["description"] as String?),
imageOriginalUrl: tryNormalizeIpfsUrl(json["imageOriginalUrl"] as String?),
externalUrl: json["externalUrl"] as String?,
metaplexMint: json["metaplexMint"] as String?,
sellerFeeBasisPoints: json["sellerFeeBasisPoints"] as int?,
);
}
class Contract {
@@ -123,8 +128,8 @@ class Contract {
factory Contract.fromJson(Map<String, dynamic> json) {
return Contract(
type: json['type'] as String?,
name: json['name'] as String?,
symbol: json['symbol'] as String?,
name: sanitizeNFTText(json["name"] as String?),
symbol: sanitizeNFTText(json["symbol"] as String?),
);
}
}
+9 -28
View File
@@ -1,3 +1,6 @@
import "package:cw_core/utils/ipfs_url.dart";
import "package:cw_core/utils/nft_text.dart";
class WalletNFTsResponseModel {
final int? page;
final int? pageSize;
@@ -40,8 +43,8 @@ class NFTAssetModel {
tokenAddress: json['token_address'] as String?,
tokenId: json['token_id'] as String?,
contractType: json['contract_type'] as String?,
name: json['name'] as String?,
symbol: json['symbol'] as String?,
name: sanitizeNFTText(json["name"] as String?),
symbol: sanitizeNFTText(json["symbol"] as String?),
normalizedMetadata: json['normalized_metadata'] != null
? new NormalizedMetadata.fromJson(json['normalized_metadata'] as Map<String, dynamic>)
: null,
@@ -61,33 +64,11 @@ class NormalizedMetadata {
factory NormalizedMetadata.fromJson(Map<String, dynamic> json) {
return NormalizedMetadata(
name: json['name'] as String?,
description: json['description'] as String?,
image: json['image'] as String?,
name: sanitizeNFTText(json["name"] as String?),
description: sanitizeNFTText(json["description"] as String?),
image: json["image"] as String?,
);
}
String? get imageUrl {
if (image == null) return image;
if (image!.contains('ipfs.io')) return image;
if (!image!.contains('ipfs')) return image;
// IPFS public gateway provided by Cloudflare is https://cloudflare-ipfs.com/ipfs/
//
// Here is an example of an ipfs image link:
//
// [ipfs://bafkreia2i2ctfexpovgzfff66wqhbmwwpvqjvozan7ioifzcnq76jharwu]
//https://ipfs.io/ipfs/QmTRcRXo6cXByjHYHTVxGpag6vpocrG3rxjPC9PxKAArR9/1620.png
const String ipfsPublicGateway = 'https://cloudflare-ipfs.com/ipfs/';
final ipfsPath = image?.split('//')[1];
final imageLink = '$ipfsPublicGateway$ipfsPath';
return imageLink;
}
String? get imageUrl => tryNormalizeIpfsUrl(image);
}
+17
View File
@@ -294,6 +294,23 @@ class CWMonero extends Monero {
};
}
@override
Future<int> getNodeHeight(Object wallet) => (wallet as MoneroWallet).getNodeHeight();
@override
String getLegacySeed(Object wallet, String langName) =>
(wallet as MoneroWallet).seedLegacy(langName);
@override
bool isBackgroundSyncRunning(Object wallet) => (wallet as MoneroWallet).isBackgroundSyncRunning;
@override
Future<void> startBackgroundSync(Object wallet) => (wallet as MoneroWallet).startBackgroundSync();
@override
Future<void> stopBackgroundSync(Object wallet, String password) =>
(wallet as MoneroWallet).stopBackgroundSync(password);
@override
int? getRestoreHeight(Object wallet) {
final moneroWallet = wallet as MoneroWallet;
+4
View File
@@ -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<NewDashboard> {
setState(() {
_selectedPage = index;
});
if (widget.dashboardPageWidgets[_selectedPage] case PageOpenListener page) {
page.onPageOpen();
}
},
)
],
+3
View File
@@ -0,0 +1,3 @@
abstract interface class PageOpenListener {
void onPageOpen();
}
+7 -1
View File
@@ -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';
@@ -117,7 +119,11 @@ class _NewHomePageState extends State<NewHomePage> with RouteAware {
sliver: CupertinoSliverRefreshControl(
refreshTriggerPullDistance: 160,
refreshIndicatorExtent: 90,
onRefresh: () => widget.dashboardViewModel.refreshDashboard(),
onRefresh: () {
unawaited(widget.nftViewModel.getNFTAssetByWallet());
return widget.dashboardViewModel.refreshDashboard();
},
),
),
SliverToBoxAdapter(
+6 -2
View File
@@ -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());
}
+28 -11
View File
@@ -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';
@@ -46,6 +47,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';
@@ -144,6 +146,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';
@@ -362,9 +365,10 @@ Route<dynamic> 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<HardwareWalletViewModel>(param1: hardwareWalletType),
@@ -374,19 +378,23 @@ Route<dynamic> createRoute(RouteSettings settings) {
return handleRouteWithPlatformAwareness(
(_) => getIt.get<NewWalletTypePage>(
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,
);
@@ -963,7 +971,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
));
case Routes.nftDetailsPage:
return MaterialPageRoute<void>(
return MaterialPageRoute<bool>(
builder: (_) => NFTDetailsPage(
arguments: settings.arguments as NFTDetailsPageArguments,
dashboardViewModel: getIt.get<DashboardViewModel>(),
@@ -977,6 +985,15 @@ Route<dynamic> createRoute(RouteSettings settings) {
),
);
case Routes.nftSendPage:
return MaterialPageRoute<bool>(
builder: (_) => NFTSendPage(
nftSendViewModel: getIt.get<NFTSendViewModel>(),
authService: getIt.get<AuthService>(),
arguments: settings.arguments as NFTSendPageArguments,
),
);
case Routes.signPage:
final vm = getIt.get<SignViewModel>();
return MaterialPageRoute<void>(
+1
View File
@@ -116,6 +116,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";
+34
View File
@@ -384,6 +384,40 @@ class CWSolana extends Solana {
}
}
@override
Future<PendingTransaction> sendNFT(
WalletBase wallet, {
required String mintAddress,
required String destinationAddress,
String? name,
}) =>
(wallet as SolanaWallet).sendNFT(
mintAddress: mintAddress,
destinationAddress: destinationAddress,
name: name,
);
@override
Future<SolanaNFTMetadata?> getNFTOnChainMetadata(WalletBase wallet, String mintAddress) async {
final metadata = await (wallet as SolanaWallet).client.getNFTOnChainMetadata(mintAddress);
if (metadata == null) {
return null;
}
return SolanaNFTMetadata(
mint: metadata.mint,
name: metadata.name,
symbol: metadata.symbol,
metadataUri: metadata.metadataUri,
imageUrl: metadata.imageUrl,
);
}
@override
Future<Set<String>> getHeldTokenMints(WalletBase wallet) =>
(wallet as SolanaWallet).heldTokenMints();
@override
Future<void> discoverAndAddWalletTokens(WalletBase wallet) async {
if (wallet is! SolanaWallet) return;
@@ -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);
}
@@ -87,7 +87,11 @@ class _DashboardPageState extends State<DashboardPage> {
final dashboardPageView = RefreshIndicator(
displacement: responsiveLayoutUtil.screenHeight * 0.1,
onRefresh: () async => await widget.dashboardViewModel.refreshDashboard(),
onRefresh: () async {
unawaited(widget.balancePage.nftViewModel.getNFTAssetByWallet());
await widget.dashboardViewModel.refreshDashboard();
},
child: SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
child: Container(
@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import 'package:cake_wallet/entities/wallet_nft_response.dart';
import 'package:cake_wallet/generated/i18n.dart';
import "package:cake_wallet/routes.dart";
import 'package:cake_wallet/src/screens/base_page.dart';
import "package:cake_wallet/src/screens/dashboard/pages/nft_send_page.dart";
import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/src/widgets/gradient_background.dart';
import "package:cake_wallet/src/widgets/primary_button.dart";
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
class NFTDetailsPage extends BasePage {
@@ -56,30 +59,56 @@ class NFTDetailsPage extends BasePage {
// );
}
bool get _canSend =>
arguments.isSolanaNFT &&
(arguments.solanaNFTAssetModel?.mint?.isNotEmpty ?? false) &&
arguments.solanaNFTAssetModel?.isOwned != false;
@override
Widget body(BuildContext context) {
return SingleChildScrollView(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
color: Theme.of(context).colorScheme.surface,
),
child: arguments.isSolanaNFT
? SolanaNFTDetailsWidget(
solanaNftAsset: arguments.solanaNFTAssetModel,
)
: EVMChainNFTDetailsWidget(
nftAsset: arguments.nftAsset,
Widget body(BuildContext context) => SingleChildScrollView(
child: Column(
children: [
Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
color: Theme.of(context).colorScheme.surface,
),
),
);
}
child: arguments.isSolanaNFT
? SolanaNFTDetailsWidget(
solanaNftAsset: arguments.solanaNFTAssetModel,
)
: EVMChainNFTDetailsWidget(
nftAsset: arguments.nftAsset,
),
),
if (_canSend)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24),
child: PrimaryButton(
text: S.of(context).send,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: () async {
final sent = await Navigator.of(context).pushNamed(
Routes.nftSendPage,
arguments: NFTSendPageArguments(asset: arguments.solanaNFTAssetModel!),
);
if (sent == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
),
],
),
);
}
class _NFTImageWidget extends StatelessWidget {
@@ -162,6 +191,15 @@ class SolanaNFTDetailsWidget extends StatelessWidget {
return Center(child: Text(S.current.no_extra_detail));
}
final moreInfoEntries = <MapEntry<String, String>>[
MapEntry(S.current.mint_address, solanaNftAsset?.mint ?? ""),
MapEntry(S.current.contractName, solanaNftAsset?.contract?.name ?? ""),
MapEntry(S.current.contractSymbol, solanaNftAsset?.contract?.symbol ?? ""),
MapEntry(S.current.collection_name, solanaNftAsset?.collection?.name ?? ""),
MapEntry(S.current.collection_description, solanaNftAsset?.collection?.description ?? ""),
MapEntry(S.current.collection_address, solanaNftAsset?.collection?.collectionAddress ?? ""),
].where((entry) => entry.value.isNotEmpty).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -171,49 +209,75 @@ class SolanaNFTDetailsWidget extends StatelessWidget {
infoType: S.current.name,
infoValue: solanaNftAsset?.name ?? '---',
),
if (solanaNftAsset?.description != null) ...[
if (solanaNftAsset?.isOwned == false) ...[
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.watching,
infoValue: S.current.nft_not_held_by_wallet,
),
],
if (solanaNftAsset?.description?.isNotEmpty ?? false) ...[
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.description,
infoValue: solanaNftAsset!.description ?? '---',
infoValue: solanaNftAsset!.description!,
),
],
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.mint_address,
infoValue: solanaNftAsset?.mint ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.contractName,
infoValue: solanaNftAsset?.contract?.name ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.contractSymbol,
infoValue: solanaNftAsset?.contract?.symbol ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.collection_name,
infoValue: solanaNftAsset?.collection?.name ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.collection_description,
infoValue: solanaNftAsset?.collection?.description ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.collection_address,
infoValue: solanaNftAsset?.collection?.collectionAddress ?? '---',
),
if (moreInfoEntries.isNotEmpty) _NFTMoreInfoSection(entries: moreInfoEntries),
const SizedBox(height: 16),
],
);
}
}
class _NFTMoreInfoSection extends StatefulWidget {
const _NFTMoreInfoSection({required this.entries});
final List<MapEntry<String, String>> entries;
@override
State<_NFTMoreInfoSection> createState() => _NFTMoreInfoSectionState();
}
class _NFTMoreInfoSectionState extends State<_NFTMoreInfoSection> {
bool isExpanded = false;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
InkWell(
onTap: () => setState(() => isExpanded = !isExpanded),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isExpanded ? S.current.hide_details : S.current.show_details,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
if (isExpanded)
for (final entry in widget.entries) ...[
const SizedBox(height: 16),
_NFTSingleInfoTile(infoType: entry.key, infoValue: entry.value),
],
],
);
}
class _NFTSingleInfoTile extends StatelessWidget {
final String infoType;
final String infoValue;
@@ -89,7 +89,7 @@ class _ImportNFTPage extends BasePage {
},
fillColor: Theme.of(context).colorScheme.surfaceContainer,
iconColor: Theme.of(context).colorScheme.primary,
placeholder: '0x...',
placeholder: nftViewModel.isSolanaWallet ? S.current.mint_address : "0x...",
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
@@ -7,6 +7,7 @@ import 'package:cake_wallet/src/screens/dashboard/widgets/solana_nft_tile_widget
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cw_core/wallet_type.dart';
import "package:mobx/mobx.dart";
class NFTListingPage extends StatefulWidget {
final NFTViewModel nftViewModel;
@@ -18,23 +19,34 @@ class NFTListingPage extends StatefulWidget {
}
class _NFTListingPageState extends State<NFTListingPage> {
late final ReactionDisposer _walletDisposer;
@override
void initState() {
super.initState();
_walletDisposer = reaction(
(_) => widget.nftViewModel.appStore.wallet,
(_) => widget.nftViewModel.onWalletChanged(),
);
fetchNFTsForWallet();
}
@override
void dispose() {
_walletDisposer();
super.dispose();
}
Future<void> fetchNFTsForWallet() async {
await widget.nftViewModel.getNFTAssetByWallet();
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Observer(
builder: (context) {
return Column(
Widget build(BuildContext context) => SliverToBoxAdapter(
child: Observer(
builder: (context) => Column(
children: [
const SizedBox(height: 16),
Padding(
@@ -50,6 +62,17 @@ class _NFTListingPageState extends State<NFTListingPage> {
),
),
),
if (widget.nftViewModel.unresolvedNFTCount > 0)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
child: Text(
S.current.nft_some_not_loaded,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if (widget.nftViewModel.isLoading)
Center(
child: CircularProgressIndicator(
@@ -62,11 +85,9 @@ class _NFTListingPageState extends State<NFTListingPage> {
else
NFTListWidget(nftViewModel: widget.nftViewModel)
],
);
},
),
);
}
),
),
);
}
class NFTListWidget extends StatelessWidget {
@@ -103,7 +124,7 @@ class NFTListWidget extends StatelessWidget {
itemCount: nftViewModel.solanaNftAssetModels.length,
itemBuilder: (context, index) {
final nftAsset = nftViewModel.solanaNftAssetModels[index];
return SolanaNFTTileWidget(nftAsset: nftAsset);
return SolanaNFTTileWidget(nftAsset: nftAsset, nftViewModel: nftViewModel);
},
),
);
@@ -0,0 +1,375 @@
import "package:cake_wallet/core/address_validator.dart";
import "package:cake_wallet/core/auth_service.dart";
import "package:cake_wallet/core/execution_state.dart";
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/src/screens/base_page.dart";
import "package:cake_wallet/src/widgets/address_text_field.dart";
import "package:cake_wallet/src/widgets/alert_with_one_action.dart";
import "package:cake_wallet/src/widgets/alert_with_two_actions.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cake_wallet/src/widgets/gradient_background.dart";
import "package:cake_wallet/src/widgets/primary_button.dart";
import "package:cake_wallet/utils/show_pop_up.dart";
import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/pending_transaction.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class NFTSendPage extends StatefulWidget {
const NFTSendPage({
required this.nftSendViewModel,
required this.authService,
required this.arguments,
super.key,
});
final NFTSendViewModel nftSendViewModel;
final AuthService authService;
final NFTSendPageArguments arguments;
@override
State<NFTSendPage> createState() => _NFTSendPageState();
}
class _NFTSendPageState extends State<NFTSendPage> {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
late TextEditingController addressController;
@override
void initState() {
super.initState();
addressController = TextEditingController();
}
@override
void dispose() {
addressController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => _NFTSendPage(
nftSendViewModel: widget.nftSendViewModel,
authService: widget.authService,
asset: widget.arguments.asset,
formKey: formKey,
addressController: addressController,
);
}
class _NFTSendPage extends BasePage {
_NFTSendPage({
required this.nftSendViewModel,
required this.authService,
required this.asset,
required this.formKey,
required this.addressController,
});
final NFTSendViewModel nftSendViewModel;
final AuthService authService;
final SolanaNFTAssetModel asset;
final GlobalKey<FormState> formKey;
final TextEditingController addressController;
@override
String get title => S.current.send;
@override
bool get gradientBackground => true;
@override
Widget Function(BuildContext, Widget) get rootWrapper =>
(context, scaffold) => GradientBackground(scaffold: scaffold);
@override
Widget body(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: MediaQuery.sizeOf(context).height / 3.5,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outline),
color: colorScheme.surface,
),
child: CakeImageWidget(imageUrl: asset.imageOriginalUrl),
),
const SizedBox(height: 16),
Text(
(asset.name?.isNotEmpty ?? false) ? asset.name! : (asset.symbol ?? "---"),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: colorScheme.surfaceContainer,
),
child: AddressTextField(
controller: addressController,
placeholder: S.of(context).recipient_address,
options: const [
AddressTextFieldOption.paste,
AddressTextFieldOption.qrCode,
],
buttonColor: colorScheme.surfaceContainerHighest,
validator: AddressValidator(type: CryptoCurrency.sol).call,
),
),
const SizedBox(height: 24),
Observer(
builder: (_) => LoadingPrimaryButton(
text: S.of(context).send,
color: colorScheme.primary,
textColor: colorScheme.onPrimary,
isLoading: nftSendViewModel.state is IsExecutingState,
onPressed: () => _onSendPressed(context),
),
),
],
),
),
),
);
}
Future<void> _onSendPressed(BuildContext context) async {
if (nftSendViewModel.state is IsExecutingState) {
return;
}
if (!(formKey.currentState?.validate() ?? false)) {
return;
}
nftSendViewModel.markSendStarted();
final destinationAddress = addressController.text.trim();
await authService.authenticateAction(
context,
conditionToDetermineIfToUse2FA: nftSendViewModel.shouldRequireTOTP2FAFor(destinationAddress),
onAuthSuccess: (isAuthenticatedSuccessfully) async {
if (!isAuthenticatedSuccessfully || !context.mounted) {
nftSendViewModel.reset();
return;
}
await _createAndConfirm(context, destinationAddress);
},
);
}
Future<void> _createAndConfirm(BuildContext context, String destinationAddress) async {
await nftSendViewModel.createTransaction(asset, destinationAddress);
if (!context.mounted) {
return;
}
final state = nftSendViewModel.state;
if (state is FailureState) {
await _showError(context, state.error);
return;
}
final pendingTransaction = nftSendViewModel.pendingTransaction;
if (pendingTransaction == null) {
return;
}
final confirmed = await _confirm(context, destinationAddress, pendingTransaction);
if (!context.mounted) {
return;
}
if (!confirmed) {
nftSendViewModel.reset();
return;
}
await _commit(context);
}
Future<bool> _confirm(
BuildContext context,
String destinationAddress,
PendingTransaction pendingTransaction,
) async {
final additionalCost = pendingTransaction.additionalCost;
var confirmed = false;
await showPopUp<void>(
context: context,
builder: (dialogContext) => AlertWithTwoActions(
alertTitle: S.of(context).confirm_sending,
alertContent: "",
alertContentTextWidget: _NFTSendConfirmation(
name: asset.name,
mint: asset.mint,
destinationAddress: destinationAddress,
fee: pendingTransaction.feeFormatted,
accountCreationFee: additionalCost?.toStringWithSymbol(fractionalDigits: 8),
),
leftButtonText: S.of(context).cancel,
rightButtonText: S.of(context).send,
actionLeftButton: () => Navigator.of(dialogContext).pop(),
actionRightButton: () {
confirmed = true;
Navigator.of(dialogContext).pop();
},
),
);
return confirmed;
}
Future<void> _commit(BuildContext context) async {
await nftSendViewModel.commitTransaction();
if (!context.mounted) {
return;
}
final state = nftSendViewModel.state;
if (state is FailureState) {
await _showError(context, state.error);
return;
}
await showPopUp<void>(
context: context,
builder: (dialogContext) => AlertWithOneAction(
alertTitle: "",
alertContent: S.of(context).transaction_sent,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(dialogContext).pop(),
),
);
if (!context.mounted) {
return;
}
Navigator.of(context).pop(true);
}
Future<void> _showError(BuildContext context, String error) => showPopUp<void>(
context: context,
builder: (dialogContext) => AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(dialogContext).pop(),
),
);
}
class NFTSendPageArguments {
NFTSendPageArguments({required this.asset});
final SolanaNFTAssetModel asset;
}
class _NFTSendConfirmation extends StatelessWidget {
const _NFTSendConfirmation({
required this.name,
required this.mint,
required this.destinationAddress,
required this.fee,
required this.accountCreationFee,
});
final String? name;
final String? mint;
final String destinationAddress;
final String fee;
final String? accountCreationFee;
@override
Widget build(BuildContext context) {
final creationFee = accountCreationFee;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
if (name?.isNotEmpty ?? false) _ConfirmationRow(label: S.of(context).name, value: name!),
if (mint?.isNotEmpty ?? false)
_ConfirmationRow(label: S.of(context).mint_address, value: mint!),
_ConfirmationRow(label: S.of(context).address, value: destinationAddress),
_ConfirmationRow(label: S.of(context).send_estimated_fee, value: fee),
if (creationFee != null)
Text(
S.of(context).recipient_account_creation_fee(creationFee),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
),
],
);
}
}
class _ConfirmationRow extends StatelessWidget {
const _ConfirmationRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
label,
textAlign: TextAlign.center,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
),
const SizedBox(height: 2),
Text(
value,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
decoration: TextDecoration.none,
),
),
],
);
}
}
@@ -1,87 +1,127 @@
import 'package:flutter/material.dart';
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import "package:cake_wallet/generated/i18n.dart";
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import "package:cake_wallet/view_model/dashboard/nft_view_model.dart";
class SolanaNFTTileWidget extends StatelessWidget {
const SolanaNFTTileWidget({super.key, required this.nftAsset});
const SolanaNFTTileWidget({
required this.nftAsset,
required this.nftViewModel,
super.key,
});
final SolanaNFTAssetModel nftAsset;
final NFTViewModel nftViewModel;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Navigator.pushNamed(
context,
Routes.nftDetailsPage,
arguments: NFTDetailsPageArguments(
isSolanaNFT: true,
solanaNFTAssetModel: nftAsset,
),
);
},
child: Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Row(
children: [
Container(
height: 100,
width: 100,
margin: const EdgeInsets.all(8),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
color: Theme.of(context).colorScheme.surface,
),
child: CakeImageWidget(
imageUrl: nftAsset.imageOriginalUrl,
),
Widget build(BuildContext context) => InkWell(
onTap: () async {
final walletName = nftViewModel.currentWalletName;
final sent = await Navigator.of(context).pushNamed(
Routes.nftDetailsPage,
arguments: NFTDetailsPageArguments(
isSolanaNFT: true,
solanaNFTAssetModel: nftAsset,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Symbol: ${nftAsset.symbol ?? '---'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
);
if (sent != true) {
return;
}
final mint = nftAsset.mint;
if (walletName != null && mint != null && mint.isNotEmpty) {
await nftViewModel.onNFTSent(walletName, mint);
}
},
child: Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Row(
children: [
Container(
height: 100,
width: 100,
margin: const EdgeInsets.all(8),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
const SizedBox(height: 8),
Text(
(nftAsset.name?.isNotEmpty ?? false)
? nftAsset.name!
: (nftAsset.symbol ?? '---'),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: Theme.of(context).colorScheme.onSurface,
height: 1,
),
),
],
color: Theme.of(context).colorScheme.surface,
),
child: CakeImageWidget(
imageUrl: nftAsset.imageOriginalUrl,
),
),
)
],
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Symbol: ${nftAsset.symbol ?? "---"}",
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
),
const SizedBox(height: 8),
Text(
(nftAsset.name?.isNotEmpty ?? false)
? nftAsset.name!
: (nftAsset.symbol ?? "---"),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: Theme.of(context).colorScheme.onSurface,
height: 1,
),
),
if (nftAsset.isOwned == false) ...[
const SizedBox(height: 8),
const _WatchingBadge(),
],
],
),
)
],
),
),
),
);
}
);
}
class _WatchingBadge extends StatelessWidget {
const _WatchingBadge();
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Text(
S.current.watching,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
height: 1,
),
),
);
}
@@ -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()) {
@@ -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<OrderDetailsPageBody> {
final OrderDetailsViewModel orderDetailsViewModel;
final MaterialThemeBase currentTheme;
ReactionDisposer? _sessionExpiredReaction;
@override
void initState() {
super.initState();
_sessionExpiredReaction = reaction<bool>(
(_) => orderDetailsViewModel.isCakePaySessionExpired,
(isExpired) {
if (!isExpired) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
showPopUp<void>(
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
@@ -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<void> logout() async => await _cakePayService.logout();
void _startExpirationTimer() {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: 1), (_) {
@@ -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<CakePayCard> userCards;
@@ -189,12 +191,9 @@ abstract class CakePayCardsListViewModelBase with Store {
@action
Future<void> 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
@@ -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<void> _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<void> _lastWalletContactsLoad = Future.value();
@action
Future<void> loadWalletContacts() async {
final waitFor = _lastWalletContactsLoad;
final done = Completer<void>();
_lastWalletContactsLoad = done.future;
await waitFor;
try {
final loadedContacts = <WalletContact>[];
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}) {
@@ -616,6 +616,7 @@ abstract class DashboardViewModelBase with Store {
@computed
bool get shouldShowMwebAd {
return false;
if (wallet.type != WalletType.litecoin) return false;
if (mwebEnabled) return false;
@@ -0,0 +1,132 @@
import "dart:async";
import "package:cake_wallet/core/execution_state.dart";
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/solana/solana.dart";
import "package:cake_wallet/store/app_store.dart";
import "package:cake_wallet/view_model/contact_list/contact_list_view_model.dart";
import "package:cw_core/exceptions.dart";
import "package:cw_core/pending_transaction.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_base.dart";
import "package:mobx/mobx.dart";
part "nft_send_view_model.g.dart";
class NFTSendViewModel = NFTSendViewModelBase with _$NFTSendViewModel;
abstract class NFTSendViewModelBase with Store {
NFTSendViewModelBase(this._appStore, this._contactListViewModel);
final AppStore _appStore;
final ContactListViewModel _contactListViewModel;
@observable
ExecutionState state = InitialExecutionState();
@observable
PendingTransaction? pendingTransaction;
bool shouldRequireTOTP2FAFor(String destinationAddress) {
final settingsStore = _appStore.settingsStore;
final isContact = _contactListViewModel.contactsToShow
.any((contact) => contact.address == destinationAddress);
if (isContact) {
return settingsStore.shouldRequireTOTP2FAForSendsToContact;
}
final isInternalWallet = _contactListViewModel.walletContactsToShow
.any((contact) => contact.address == destinationAddress);
if (isInternalWallet) {
return settingsStore.shouldRequireTOTP2FAForSendsToInternalWallets;
}
return settingsStore.shouldRequireTOTP2FAForSendsToNonContact;
}
@action
Future<void> createTransaction(SolanaNFTAssetModel asset, String destinationAddress) async {
state = IsExecutingState();
try {
pendingTransaction = await solana!.sendNFT(
_appStore.wallet!,
mintAddress: asset.mint!,
destinationAddress: destinationAddress,
name: asset.name,
);
state = ExecutedSuccessfullyState();
} catch (e) {
pendingTransaction = null;
state = FailureState(_errorMessage(e));
}
}
@action
Future<void> commitTransaction() async {
state = IsExecutingState();
try {
final wallet = _appStore.wallet!;
final transaction = pendingTransaction!;
await transaction.commit();
final signature = transaction.id;
pendingTransaction = null;
state = ExecutedSuccessfullyState();
if (signature.isNotEmpty) {
unawaited(_pollForTransaction(wallet, signature));
}
} catch (e) {
state = FailureState(_errorMessage(e));
}
}
@action
void markSendStarted() => state = IsExecutingState();
@action
void reset() {
pendingTransaction = null;
state = InitialExecutionState();
}
Future<void> _pollForTransaction(WalletBase wallet, String signature) async {
try {
await solana!.pollForTransaction(wallet, signature);
} catch (e) {
printV("Failed to poll for NFT transfer: ${e.toString()}");
}
}
String _errorMessage(Object error) {
if (error is NotAnNFTException) {
return S.current.nft_send_not_an_nft;
}
if (error is NoAssociatedTokenAccountException) {
return S.current.solana_no_associated_token_account_exception;
}
if (error is SignSPLTokenTransactionRentException) {
return S.current.solana_sign_spl_token_transaction_rent_exception;
}
if (error is CreateAssociatedTokenAccountException) {
return "${S.current.solana_create_associated_token_account_exception} "
"${S.current.added_message_for_ata_error}";
}
printV("NFT send failed: ${error.toString()}");
return error.toString();
}
}
+382 -112
View File
@@ -1,13 +1,18 @@
import "dart:async";
import 'dart:convert';
import 'dart:developer';
import "package:cake_wallet/entities/saved_nfts.dart";
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
import "package:cake_wallet/solana/solana.dart";
import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
import 'package:cake_wallet/src/screens/wallet_connect/widgets/bottom_sheet/bottom_sheet_message_display_widget.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/utils/debounce.dart';
import "package:cw_core/utils/ipfs_url.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_base.dart";
import 'package:cw_core/wallet_type.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:mobx/mobx.dart';
@@ -41,94 +46,96 @@ abstract class NFTViewModelBase with Store {
final BottomSheetService bottomSheetService;
final Debounce _fetchDebounce = Debounce(const Duration(milliseconds: 500));
Future<void> _lastFetch = Future.value();
String? _loadedWalletAddress;
Completer<void>? _queuedFetch;
final SavedNFTs _savedNFTs = SavedNFTs();
int _currentRefreshId = 0;
static const _requestTimeout = Duration(seconds: 20);
static const _runDeadline = Duration(minutes: 2);
Map<String, String> get _moralisHeaders => {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
};
@observable
bool isLoading;
@observable
bool isImportNFTLoading;
@observable
int unresolvedNFTCount = 0;
ObservableList<NFTAssetModel> nftAssetByWalletModels;
ObservableList<SolanaNFTAssetModel> solanaNftAssetModels;
bool get isSolanaWallet => appStore.wallet?.type == WalletType.solana;
String? get currentWalletName => appStore.wallet?.name;
@action
Future<void> getNFTAssetByWallet() async {
final wallet = appStore.wallet!;
void onWalletChanged() {
nftAssetByWalletModels.clear();
solanaNftAssetModels.clear();
unresolvedNFTCount = 0;
_nftErrorPopupShown = false;
_fetchDebounce.run(getNFTAssetByWallet);
}
if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) return;
@action
Future<void> getNFTAssetByWallet() {
final queued = _queuedFetch;
final walletAddress = wallet.walletInfo.address;
log('Fetching wallet NFTs for $walletAddress');
if (queued != null) {
return queued.future;
}
final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: wallet.chainId);
// the [chain] refers to the chain network that the nft is on
// the [format] refers to the number format type of the responses
// the [normalizedMetadata] field is a boolean that determines if
// the response would include a json string of the NFT Metadata that can be decoded
// and used within the wallet
// the [excludeSpam] field is a boolean that determines if spam nfts be excluded from the response.
final done = Completer<void>();
_queuedFetch = done;
Uri uri;
if (wallet.type == WalletType.solana) {
uri = Uri.https(
'solana-gateway.moralis.io',
'/account/$chainName/$walletAddress/nft',
);
} else {
uri = Uri.https(
'deep-index.moralis.io',
'/api/v2.2/$walletAddress/nft',
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"exclude_spam": "true",
"normalizeMetadata": "true",
},
);
final waitFor = _lastFetch;
_lastFetch = done.future;
unawaited(_fetchAfterPreviousRun(waitFor, done));
return done.future;
}
Future<void> _fetchAfterPreviousRun(Future<void> waitFor, Completer<void> done) async {
await waitFor;
if (identical(_queuedFetch, done)) {
_queuedFetch = null;
}
try {
if (isLoading) return;
final wallet = appStore.wallet!;
if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) {
return;
}
final walletAddress = wallet.walletInfo.address;
final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: wallet.chainId);
isLoading = true;
final response = await ProxyWrapper().get(
clearnetUri: uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
);
final refreshId = ++_currentRefreshId;
final decodedResponse = jsonDecode(response.body);
if (isSolanaWallet) {
await _getSolanaNFTAssets(wallet, walletAddress, chainName, refreshId)
.timeout(_runDeadline);
} else {
await _getEvmNFTAssets(wallet.name, walletAddress, chainName, refreshId);
}
_nftErrorPopupShown = false;
if (wallet.type == WalletType.solana) {
final results = await Future.wait(
(decodedResponse as List<dynamic>).map(
(x) {
final data = x as Map<String, dynamic>;
final mint = data['mint'] as String? ?? '';
return getSolanaNFTDetails(mint, chainName);
},
).toList(),
);
solanaNftAssetModels.clear();
solanaNftAssetModels.addAll(results);
} else {
final result =
WalletNFTsResponseModel.fromJson(decodedResponse as Map<String, dynamic>).result ?? [];
nftAssetByWalletModels.clear();
nftAssetByWalletModels.addAll(result);
}
} catch (e) {
log(e.toString());
printV("Fetching wallet NFTs failed: ${e.toString()}");
if (!_nftErrorPopupShown) {
_nftErrorPopupShown = true;
bottomSheetService.queueBottomSheet(
@@ -140,79 +147,342 @@ abstract class NFTViewModelBase with Store {
}
} finally {
isLoading = false;
done.complete();
}
}
Future<SolanaNFTAssetModel> getSolanaNFTDetails(String address, String chainName) async {
final uri = Uri.https(
'solana-gateway.moralis.io',
'/nft/$chainName/$address/metadata',
Future<void> _getEvmNFTAssets(
String walletName,
String walletAddress,
String chainName,
int refreshId,
) async {
final saved = await _savedNFTs.evmNFTs(walletName, chainName);
var result = const <NFTAssetModel>[];
Object? indexerError;
try {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"deep-index.moralis.io",
"/api/v2.2/$walletAddress/nft",
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"exclude_spam": "true",
"normalizeMetadata": "true",
},
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
result = WalletNFTsResponseModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
).result ??
[];
} catch (e) {
indexerError = e;
}
if (appStore.wallet?.walletInfo.address != walletAddress || refreshId != _currentRefreshId) {
return;
}
final listed = result.map(SavedNFTs.evmIdentifier).toSet();
nftAssetByWalletModels.clear();
nftAssetByWalletModels.addAll(result);
nftAssetByWalletModels
.addAll(saved.where((asset) => !listed.contains(SavedNFTs.evmIdentifier(asset))));
if (indexerError != null) {
throw indexerError;
}
}
Future<void> _getSolanaNFTAssets(
WalletBase wallet,
String walletAddress,
String chainName,
int refreshId,
) async {
final importedAssets = await _savedNFTs.solanaNFTs(wallet.name);
var indexerMints = const <String>[];
Object? indexerError;
try {
indexerMints = await _fetchMintsFromIndexer(walletAddress, chainName);
} catch (e) {
indexerError = e;
}
final heldMints = await _fetchMintsHeldOnChain(wallet);
final failedMints = <String>{};
final assets = await _loadAssetsForMints(
wallet: wallet,
chainName: chainName,
mints: <String>{...indexerMints, ...importedAssets.keys},
heldMints: heldMints,
importedAssets: importedAssets,
failedMints: failedMints,
);
final response = await ProxyWrapper().get(
clearnetUri: uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
if (appStore.wallet?.walletInfo.address != walletAddress || refreshId != _currentRefreshId) {
return;
}
assets.addAll(
_assetsToKeepFromPreviousRefresh(walletAddress, assets, failedMints, indexerError != null));
solanaNftAssetModels.clear();
solanaNftAssetModels.addAll(assets);
_loadedWalletAddress = walletAddress;
final shownMints = assets.map((asset) => asset.mint).toSet();
unresolvedNFTCount = failedMints.where((mint) => !shownMints.contains(mint)).length;
if (heldMints != null) {
await _savedNFTs.refreshSolana(wallet.name, assets);
}
if (indexerError != null) {
throw indexerError;
}
}
Future<List<String>> _fetchMintsFromIndexer(String walletAddress, String chainName) async {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"solana-gateway.moralis.io",
"/account/$chainName/$walletAddress/nft",
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception("Failed to fetch wallet NFTs (${response.statusCode})");
}
return (jsonDecode(response.body) as List<dynamic>)
.map((entry) => (entry as Map<String, dynamic>)["mint"] as String? ?? "")
.where((mint) => mint.isNotEmpty)
.toList();
}
Future<Set<String>?> _fetchMintsHeldOnChain(WalletBase wallet) async {
try {
return await solana!.getHeldTokenMints(wallet);
} catch (e) {
printV("Could not fetch held token mints: ${e.toString()}");
return null;
}
}
Future<List<SolanaNFTAssetModel>> _loadAssetsForMints({
required WalletBase wallet,
required String chainName,
required Set<String> mints,
required Set<String>? heldMints,
required Map<String, SolanaNFTAssetModel> importedAssets,
required Set<String> failedMints,
}) async {
const batchSize = 5;
final ordered = mints.toList();
final assets = <SolanaNFTAssetModel>[];
for (var i = 0; i < ordered.length; i += batchSize) {
final described = await Future.wait(
ordered.skip(i).take(batchSize).map((mint) async {
try {
final asset = await getSolanaNFTDetails(wallet, mint, chainName);
asset.isOwned = heldMints?.contains(mint) ?? importedAssets[mint]?.isOwned;
return asset;
} catch (e) {
printV("Failed to load NFT $mint: ${e.toString()}");
failedMints.add(mint);
final cached = importedAssets[mint];
if (cached != null && heldMints != null) {
cached.isOwned = heldMints.contains(mint);
}
return cached;
}
}),
);
assets.addAll(described.whereType<SolanaNFTAssetModel>());
}
return assets;
}
List<SolanaNFTAssetModel> _assetsToKeepFromPreviousRefresh(
String walletAddress,
List<SolanaNFTAssetModel> described,
Set<String> failedMints,
bool indexerFailed,
) {
if (_loadedWalletAddress != walletAddress) {
return const [];
}
final describedMints = described.map((asset) => asset.mint).toSet();
return solanaNftAssetModels.where((asset) {
final mint = asset.mint;
if (mint == null || describedMints.contains(mint)) {
return false;
}
return indexerFailed || failedMints.contains(mint);
}).toList();
}
Future<SolanaNFTAssetModel> getSolanaNFTDetails(
WalletBase wallet,
String address,
String chainName,
) async {
SolanaNFTAssetModel? asset;
try {
asset = await _fetchMoralisNFT(address, chainName);
asset.address = address;
asset.mint = address;
} catch (e) {
printV("Moralis NFT metadata failed for $address: ${e.toString()}");
}
if (asset != null && (asset.imageOriginalUrl?.isNotEmpty ?? false)) {
return asset;
}
// fallback for when moralis is unavailable, we still get the core details for that nft onchain
final onChainData = await solana!.getNFTOnChainMetadata(wallet, address);
if (asset != null) {
if (onChainData != null) {
asset.imageOriginalUrl = tryNormalizeIpfsUrl(onChainData.imageUrl);
}
return asset;
}
if (onChainData == null) {
throw Exception("Could not load NFT metadata for $address");
}
return SolanaNFTAssetModel(
address: address,
mint: address,
standard: "metaplex",
name: onChainData.name,
symbol: onChainData.symbol,
imageOriginalUrl: onChainData.imageUrl,
metadataOriginalUrl: onChainData.metadataUri,
);
}
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
Future<SolanaNFTAssetModel> _fetchMoralisNFT(String address, String chainName) async {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"solana-gateway.moralis.io",
"/nft/$chainName/$address/metadata",
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
return SolanaNFTAssetModel.fromJson(decodedResponse);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception("Failed to fetch NFT metadata (${response.statusCode})");
}
return SolanaNFTAssetModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
@action
Future<void> onNFTSent(String walletName, String mint) async {
await _savedNFTs.removeSolana(walletName, mint);
if (appStore.wallet?.name == walletName) {
solanaNftAssetModels.removeWhere((asset) => asset.mint == mint);
}
}
Future<void> _importEvmNFT(
String walletName,
String tokenAddress,
String? tokenId,
String chainName,
) async {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"deep-index.moralis.io",
"/api/v2.2/nft/$tokenAddress/$tokenId",
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"normalizeMetadata": "true",
},
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
final asset = NFTAssetModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
nftAssetByWalletModels.add(asset);
await _savedNFTs.addEvm(walletName, chainName, asset);
}
@action
Future<void> importNFT(String tokenAddress, String? tokenId) async {
final walletType = appStore.wallet!.type;
final wallet = appStore.wallet!;
int? chainId;
if (isEVMCompatibleChain(walletType)) {
chainId = evm!.getSelectedChainId(appStore.wallet!);
if (isEVMCompatibleChain(wallet.type)) {
chainId = evm!.getSelectedChainId(wallet);
}
final chainName = getChainNameBasedOnWalletType(walletType, chainId: chainId);
// the [chain] refers to the chain network that the nft is on
// the [format] refers to the number format type of the responses
// the [normalizedMetadata] field is a boolean that determines if
// the response would include a json string of the NFT Metadata that can be decoded
// and used within the wallet
final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: chainId);
try {
isImportNFTLoading = true;
if (appStore.wallet!.type == WalletType.solana) {
final result = await getSolanaNFTDetails(tokenAddress, chainName);
if (isSolanaWallet) {
final result = await getSolanaNFTDetails(wallet, tokenAddress, chainName);
try {
result.isOwned = (await solana!.getHeldTokenMints(wallet)).contains(result.mint);
} catch (e) {
printV("Could not check NFT ownership: ${e.toString()}");
}
solanaNftAssetModels.removeWhere((asset) => asset.mint == result.mint);
solanaNftAssetModels.add(result);
await _savedNFTs.addSolana(wallet.name, result);
} else {
final uri = Uri.https(
'deep-index.moralis.io',
'/api/v2.2/nft/$tokenAddress/$tokenId',
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"normalizeMetadata": "true",
},
);
final response = await ProxyWrapper().get(
clearnetUri: uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
);
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
final nftAsset = NFTAssetModel.fromJson(decodedResponse);
nftAssetByWalletModels.add(nftAsset);
await _importEvmNFT(wallet.name, tokenAddress, tokenId, chainName);
}
} catch (e) {
printV("Importing NFT $tokenAddress failed: ${e.toString()}");
bottomSheetService.queueBottomSheet(
isModalDismissible: true,
widget: BottomSheetMessageDisplayWidget(
message: e.toString(),
message: S.current.nft_import_failed,
),
);
} finally {
+15 -19
View File
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:cake_wallet/core/key_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/monero/monero.dart';
import 'package:cw_monero/monero_wallet.dart';
import 'package:mobx/mobx.dart';
import 'package:cw_core/wallet_base.dart';
@@ -56,26 +55,24 @@ abstract class DevMoneroBackgroundSyncBase with Store {
bool isBackgroundSyncing = false;
Future<void> _setValues() async {
final w = (wallet as MoneroWallet);
localBlockHeight = (await monero!.getCurrentHeight()).toString();
nodeBlockHeight = (await w.getNodeHeight()).toString();
final keys = w.keys;
primaryAddress = keys.primaryAddress;
publicViewKey = keys.publicViewKey;
privateViewKey = keys.privateViewKey;
publicSpendKey = keys.publicSpendKey;
privateSpendKey = keys.privateSpendKey;
passphrase = keys.passphrase;
seed = w.seed;
seedLegacy = w.seedLegacy("English");
nodeBlockHeight = (await monero!.getNodeHeight(wallet)).toString();
final keys = monero!.getKeys(wallet);
primaryAddress = keys["primaryAddress"];
publicViewKey = keys["publicViewKey"];
privateViewKey = keys["privateViewKey"];
publicSpendKey = keys["publicSpendKey"];
privateSpendKey = keys["privateSpendKey"];
passphrase = keys["passphrase"];
seed = wallet.seed;
seedLegacy = monero!.getLegacySeed(wallet, "English");
tick = refreshTimer?.tick ?? -1;
isBackgroundSyncing = w.isBackgroundSyncRunning;
isBackgroundSyncing = monero!.isBackgroundSyncRunning(wallet);
}
@action
Future<void> manualRescan() async {
final w = (wallet as MoneroWallet);
await wallet.rescan(height: await w.getNodeHeight() - 10000);
await wallet.rescan(height: await monero!.getNodeHeight(wallet) - 10000);
}
@action
@@ -93,14 +90,13 @@ abstract class DevMoneroBackgroundSyncBase with Store {
@action
void startBackgroundSync() {
final w = (wallet as MoneroWallet);
w.startBackgroundSync();
unawaited(monero!.startBackgroundSync(wallet));
}
@action
Future<void> stopBackgroundSync() async {
final w = (wallet as MoneroWallet);
final keyService = getIt.get<KeyService>();
await w.stopBackgroundSync(await keyService.getWalletPassword(walletName: wallet.name));
final password = await keyService.getWalletPassword(walletName: wallet.name);
await monero!.stopBackgroundSync(wallet, password);
}
}
@@ -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);
+10 -1
View File
@@ -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<StandartListItem> items;
@observable
bool isCakePaySessionExpired = false;
final CakePayService cakePayService;
final Box<Order> orders;
OrderProvider? _provider;
@@ -74,7 +78,9 @@ abstract class OrderDetailsViewModelBase with Store {
@action
Future<void> _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());
}
@@ -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;
+1 -4
View File
@@ -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);
}
+5
View File
@@ -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
+4 -4
View File
@@ -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:
+5
View File
@@ -691,6 +691,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": "لم يتم العثور على أي بطاقات",
@@ -1426,6 +1430,7 @@
"walletConnect": "WalletConnect",
"wallets": "المحافظ",
"warning": "تحذير",
"watching": "عرض فقط",
"wc_action_approve": "موافقة",
"wc_approve_request_title": "الموافقة على الطلب",
"wc_connect_request_title": "طلب الاتصال",
+5
View File
@@ -691,6 +691,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": "Няма намерени карти",
@@ -1426,6 +1430,7 @@
"walletConnect": "WalletConnect",
"wallets": "Портфейли",
"warning": "Предупреждение",
"watching": "Само преглед",
"wc_action_approve": "Одобряване",
"wc_approve_request_title": "Одобряване на заявката",
"wc_connect_request_title": "Заявка за свързване",
+5
View File
@@ -691,6 +691,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",
@@ -1426,6 +1430,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í",
+5
View File
@@ -706,6 +706,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",
@@ -1465,6 +1469,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",
+7 -1
View File
@@ -154,6 +154,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",
@@ -706,6 +707,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",
@@ -1467,6 +1472,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",
@@ -1543,4 +1549,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."
}
}
+5
View File
@@ -692,6 +692,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",
@@ -1428,6 +1432,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",
+5
View File
@@ -691,6 +691,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": "هیچ کارتی پیدا نشد",
@@ -1426,6 +1430,7 @@
"walletConnect": "WalletConnect",
"wallets": "کیف پول‌ها",
"warning": "هشدار",
"watching": "فقط مشاهده کنید",
"wc_action_approve": "تایید کنید",
"wc_approve_request_title": "درخواست را تایید کنید",
"wc_connect_request_title": "درخواست اتصال",
+5
View File
@@ -691,6 +691,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",
@@ -1426,6 +1430,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",
+5
View File
@@ -694,6 +694,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ã",
@@ -1433,6 +1437,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",
+5
View File
@@ -691,6 +691,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",
@@ -1428,6 +1432,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",
+5
View File
@@ -691,6 +691,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": "कोई कार्ड नहीं मिले",
@@ -1428,6 +1432,7 @@
"walletConnect": "WalletConnect",
"wallets": "वॉलेट्स",
"warning": "चेतावनी",
"watching": "केवल देखें",
"wc_action_approve": "मंज़ूरी देना",
"wc_approve_request_title": "अनुरोध स्वीकृत करें",
"wc_connect_request_title": "कनेक्ट अनुरोध",
+5
View File
@@ -691,6 +691,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",
@@ -1426,6 +1430,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",
+5
View File
@@ -691,6 +691,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": "Քարտեր չեն գտնվել",
@@ -1426,6 +1430,7 @@
"walletConnect": "WalletConnect",
"wallets": "Դրամապանակներ",
"warning": "Զգուշացում",
"watching": "Միայն դիտել",
"wc_action_approve": "Հաստատել",
"wc_approve_request_title": "Հաստատել հարցումը",
"wc_connect_request_title": "Միացման հարցում",
+5
View File
@@ -691,6 +691,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",
@@ -1429,6 +1433,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",
+5
View File
@@ -691,6 +691,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",
@@ -1428,6 +1432,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",
+5
View File
@@ -692,6 +692,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": "カードが見つかりません",
@@ -1427,6 +1431,7 @@
"walletConnect": "WalletConnect",
"wallets": "ウォレット",
"warning": "警告",
"watching": "閲覧のみ",
"wc_action_approve": "承認",
"wc_approve_request_title": "リクエストの承認",
"wc_connect_request_title": "接続リクエスト",
+5
View File
@@ -691,6 +691,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": "카드를 찾을 수 없습니다",
@@ -1426,6 +1430,7 @@
"walletConnect": "WalletConnect",
"wallets": "지갑",
"warning": "경고",
"watching": "보기 전용",
"wc_action_approve": "승인",
"wc_approve_request_title": "요청 승인",
"wc_connect_request_title": "연결 요청",
+5
View File
@@ -691,6 +691,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": "ကဒ် မတွေ့ပါ",
@@ -1426,6 +1430,7 @@
"walletConnect": "WalletConnect",
"wallets": "ဝေါလက်များ",
"warning": "သတိပေးချက်",
"watching": "ကြည့်ရန်သာ",
"wc_action_approve": "အတည်ပြု",
"wc_approve_request_title": "တောင်းဆိုချက်ကို အတည်ပြုပါ။",
"wc_connect_request_title": "တောင်းဆိုချက်ကို ချိတ်ဆက်ပါ။",
+5
View File
@@ -691,6 +691,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",
@@ -1427,6 +1431,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",
+5
View File
@@ -691,6 +691,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",
@@ -1426,6 +1430,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",
+5
View File
@@ -692,6 +692,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",
@@ -1429,6 +1433,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",
+5
View File
@@ -675,6 +675,10 @@
"skip_verification_question": "Você ainda deseja pular a verificação?",
"skip_verification_title": "Tem certeza?",
"skip_verification_warning": "Se você não fizer backup da sua frase de recuperação e perder o acesso à Cake Wallet ou ao seu dispositivo, seus ativos serão perdidos.",
"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ó",
@@ -1368,6 +1372,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}",
+5
View File
@@ -691,6 +691,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": "Карты не найдены",
@@ -1427,6 +1431,7 @@
"walletConnect": "WalletConnect",
"wallets": "Кошельки",
"warning": "Предупреждение",
"watching": "Только просмотр",
"wc_action_approve": "Одобрить",
"wc_approve_request_title": "Утвердить запрос",
"wc_connect_request_title": "Запрос на подключение",
+5
View File
@@ -691,6 +691,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": "ไม่พบการ์ด",
@@ -1426,6 +1430,7 @@
"walletConnect": "WalletConnect",
"wallets": "กระเป๋าเงิน",
"warning": "คำเตือน",
"watching": "ดูอย่างเดียว",
"wc_action_approve": "อนุมัติ",
"wc_approve_request_title": "อนุมัติคำขอ",
"wc_connect_request_title": "เชื่อมต่อคำขอ",
+5
View File
@@ -691,6 +691,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",
@@ -1426,6 +1430,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",
+5
View File
@@ -691,6 +691,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ı",
@@ -1426,6 +1430,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",
+5
View File
@@ -691,6 +691,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": "Картки не знайдено",
@@ -1427,6 +1431,7 @@
"walletConnect": "WalletConnect",
"wallets": "Гаманці",
"warning": "Попередження",
"watching": "Тільки перегляд",
"wc_action_approve": "Схвалити",
"wc_approve_request_title": "Підтвердити запит",
"wc_connect_request_title": "Запит на підключення",
+5
View File
@@ -691,6 +691,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": "کوئی کارڈ نہیں ملا",
@@ -1428,6 +1432,7 @@
"walletConnect": "WalletConnect",
"wallets": "والٹس",
"warning": "انتباہ",
"watching": "صرف دیکھیں",
"wc_action_approve": "منظور کریں",
"wc_approve_request_title": "درخواست منظور کریں۔",
"wc_connect_request_title": "رابطہ کی درخواست",
+5
View File
@@ -692,6 +692,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",
@@ -1427,6 +1431,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",
+5
View File
@@ -692,6 +692,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",
@@ -1427,6 +1431,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è",
+5
View File
@@ -692,6 +692,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": "未找到卡片",
@@ -1427,6 +1431,7 @@
"walletConnect": "WalletConnect",
"wallets": "钱包",
"warning": "警告",
"watching": "仅供查看",
"wc_action_approve": "批准",
"wc_approve_request_title": "批准请求",
"wc_connect_request_title": "连接请求",
+225
View File
@@ -0,0 +1,225 @@
import "dart:io";
import "package:cake_wallet/entities/saved_nfts.dart";
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cake_wallet/entities/wallet_nft_response.dart";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/imported_nft.dart";
import "package:flutter_test/flutter_test.dart";
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
import "package:sqflite_common_ffi/sqflite_ffi.dart";
class _FakePathProviderPlatform extends PathProviderPlatform {
_FakePathProviderPlatform(this.root);
final String root;
@override
Future<String?> getApplicationDocumentsPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
}
SolanaNFTAssetModel solanaNFT(String mint, {String? name, bool? isOwned}) =>
SolanaNFTAssetModel(mint: mint, address: mint, name: name ?? mint, isOwned: isOwned);
Future<void> main() async {
final dataRoot = Directory("./test/data/saved_nfts");
const wallet = "MyWallet";
const otherWallet = "OtherWallet";
const otherChain = "eth";
final saved = SavedNFTs();
setUpAll(() async {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
dataRoot.createSync(recursive: true);
Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
await initDb();
});
setUp(() async {
await db!.delete(ImportedNFT.tableName);
});
tearDownAll(() {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
});
group("SavedNFTs, solana", () {
test("keeps what was added and reads it back", () async {
await saved.addSolana(wallet, solanaNFT("MintA", name: "Ape", isOwned: true));
final stored = await saved.solanaNFTs(wallet);
expect(stored.keys, ["MintA"]);
expect(stored["MintA"]!.name, "Ape");
expect(stored["MintA"]!.isOwned, isTrue);
});
test("keeps wallets and chains apart", () async {
await saved.addSolana(wallet, solanaNFT("MintA"));
expect(await saved.solanaNFTs(otherWallet), isEmpty);
expect(await saved.evmNFTs(wallet, otherChain), isEmpty);
});
test("adding the same mint twice replaces it rather than duplicating", () async {
await saved.addSolana(wallet, solanaNFT("MintA", name: "first"));
await saved.addSolana(wallet, solanaNFT("MintA", name: "second"));
final stored = await saved.solanaNFTs(wallet);
expect(stored.length, 1);
expect(stored["MintA"]!.name, "second");
});
test("ignores an asset with no mint", () async {
await saved.addSolana(wallet, SolanaNFTAssetModel(address: "x"));
expect(await saved.solanaNFTs(wallet), isEmpty);
});
test("removes what was sent", () async {
await saved.addSolana(wallet, solanaNFT("MintA"));
await saved.removeSolana(wallet, "MintA");
expect(await saved.solanaNFTs(wallet), isEmpty);
});
test("refresh updates what is still saved and does not add anything new", () async {
await saved.addSolana(wallet, solanaNFT("MintA", name: "old", isOwned: true));
await saved.refreshSolana(wallet, [
solanaNFT("MintA", name: "new", isOwned: false),
solanaNFT("MintB", name: "never imported"),
]);
final stored = await saved.solanaNFTs(wallet);
expect(stored.keys, ["MintA"]);
expect(stored["MintA"]!.name, "new");
expect(stored["MintA"]!.isOwned, isFalse);
});
// A refresh runs for seconds while it fetches metadata per mint, and the
// user can import during it.
test("an import that lands during a refresh is not lost", () async {
await saved.addSolana(wallet, solanaNFT("MintA", name: "old"));
final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
final importing = saved.addSolana(wallet, solanaNFT("MintNew", name: "imported"));
await Future.wait([refreshing, importing]);
final stored = await saved.solanaNFTs(wallet);
expect(stored.keys.toSet(), {"MintA", "MintNew"});
expect(stored["MintA"]!.name, "refreshed");
});
test("an import during a refresh survives whichever order they run in", () async {
await saved.addSolana(wallet, solanaNFT("MintA"));
final importing = saved.addSolana(wallet, solanaNFT("MintNew"));
final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
await Future.wait([importing, refreshing]);
expect((await saved.solanaNFTs(wallet)).keys.toSet(), {"MintA", "MintNew"});
});
test("an NFT sent during a refresh is not put back", () async {
await saved.addSolana(wallet, solanaNFT("MintA"));
final sending = saved.removeSolana(wallet, "MintA");
final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
await Future.wait([sending, refreshing]);
expect(await saved.solanaNFTs(wallet), isEmpty);
});
test("an NFT sent after a refresh has already read the rows is not put back", () async {
await saved.addSolana(wallet, solanaNFT("MintA"));
// The refresh starts first here, so its read of the rows is queued ahead of
// the delete. That is the ordering a real send hits: the user taps send
// while a refresh is already in flight.
final refreshing = saved.refreshSolana(wallet, [solanaNFT("MintA", name: "refreshed")]);
final sending = saved.removeSolana(wallet, "MintA");
await Future.wait([refreshing, sending]);
expect(await saved.solanaNFTs(wallet), isEmpty);
});
test("many overlapping writes all land", () async {
await Future.wait([
for (var i = 0; i < 20; i++) saved.addSolana(wallet, solanaNFT("Mint$i")),
]);
expect((await saved.solanaNFTs(wallet)).length, 20);
});
});
group("SavedNFTs, evm", () {
NFTAssetModel evmNFT(String address, String tokenId, {String? name}) => NFTAssetModel(
tokenAddress: address,
tokenId: tokenId,
name: name ?? address,
symbol: "SYM",
normalizedMetadata: NormalizedMetadata(
name: name,
description: "a description",
image: "ipfs://QmAbC",
),
);
test("an imported evm nft survives a restart", () async {
await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7", name: "Punk"));
final stored = await saved.evmNFTs(wallet, otherChain);
expect(stored.length, 1);
expect(stored.first.tokenAddress, "0xabc");
expect(stored.first.tokenId, "7");
expect(stored.first.name, "Punk");
expect(stored.first.normalizedMetadata?.description, "a description");
});
test("stores the ipfs image through the shared gateway", () async {
await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7"));
final stored = await saved.evmNFTs(wallet, otherChain);
expect(stored.first.normalizedMetadata?.imageUrl, "https://ipfs.io/ipfs/QmAbC");
});
test("tells two token ids of one contract apart", () async {
await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7"));
await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "8"));
expect((await saved.evmNFTs(wallet, otherChain)).length, 2);
});
test("keeps evm and solana entries apart by chain", () async {
await saved.addEvm(wallet, otherChain, evmNFT("0xabc", "7"));
await saved.addSolana(wallet, solanaNFT("MintA"));
expect((await saved.evmNFTs(wallet, otherChain)).length, 1);
expect((await saved.solanaNFTs(wallet)).length, 1);
});
});
}
@@ -0,0 +1,107 @@
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cw_core/utils/nft_text.dart";
import "package:cake_wallet/entities/wallet_nft_response.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("SolanaNFTAssetModel", () {
Map<String, dynamic> json({String name = "DeGod", bool? isOwned}) => {
"address": "MintAddr",
"mint": "MintAddr",
"standard": "metaplex",
"name": name,
"symbol": "DGOD",
"description": "a description",
"imageOriginalUrl": "ipfs://QmAbC123",
"externalUrl": "https://example.com",
"metadataOriginalUrl": "https://example.com/meta.json",
"totalSupply": "1",
"isOwned": isOwned,
};
test("sanitises the text a mint controls", () {
final asset = SolanaNFTAssetModel.fromJson(json(name: "Ape\u2028Address: 9xQeW"));
expect(asset.name, "Ape Address: 9xQeW");
});
test("routes an ipfs image through the gateway", () {
expect(
SolanaNFTAssetModel.fromJson(json()).imageOriginalUrl,
"https://ipfs.io/ipfs/QmAbC123",
);
});
test("reads the fields the indexer actually sends", () {
final asset = SolanaNFTAssetModel.fromJson(json());
expect(asset.mint, "MintAddr");
expect(asset.name, "DeGod");
expect(asset.imageOriginalUrl, "https://ipfs.io/ipfs/QmAbC123");
});
test("sanitising is idempotent, so a value read back from storage stays stable", () {
final once = SolanaNFTAssetModel.fromJson(json(name: "Ape\u2028Name"));
expect(once.name, "Ape Name");
expect(sanitizeNFTText(once.name), once.name);
});
test("sanitises collection and contract text, which render on the same screen", () {
final asset = SolanaNFTAssetModel.fromJson({
...json(),
"collection": {
"name": "Coll\u2028ection",
"description": "desc\u202Eription",
},
"contract": {
"name": "Cont\u2029ract",
"symbol": "SY\u200BM",
},
});
expect(asset.collection?.name, "Coll ection");
expect(asset.collection?.description, "desc ription");
expect(asset.contract?.name, "Cont ract");
expect(asset.contract?.symbol, "SY M");
});
});
group("NFTAssetModel", () {
Map<String, dynamic> evmJson({String name = "Punk", String image = "ipfs://QmAbC"}) => {
"token_address": "0xabc",
"token_id": "7",
"contract_type": "ERC721",
"name": name,
"symbol": "PNK",
"normalized_metadata": {
"name": name,
"description": "a description",
"image": image,
},
};
test("sanitises the text a contract controls", () {
final asset = NFTAssetModel.fromJson(evmJson(name: "Punk\u2028Address: 0xdead"));
expect(asset.name, "Punk Address: 0xdead");
expect(asset.normalizedMetadata?.name, "Punk Address: 0xdead");
});
test("routes an ipfs image through the same gateway as solana", () {
expect(
NFTAssetModel.fromJson(evmJson()).normalizedMetadata?.imageUrl,
"https://ipfs.io/ipfs/QmAbC",
);
});
test("leaves a plain gateway url alone instead of mangling it", () {
expect(
NFTAssetModel.fromJson(evmJson(image: "https://example.com/ipfs/QmAbC"))
.normalizedMetadata
?.imageUrl,
"https://example.com/ipfs/QmAbC",
);
});
});
}
+35 -3
View File
@@ -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<void> 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<Output> updateOutputs(PendingTransaction pendingTransaction, List<Output> outputs);
bool txIsReceivedSilentPayment(TransactionInfo txInfo);
bool txIsMweb(TransactionInfo txInfo);
@@ -339,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';
@@ -487,6 +489,11 @@ WalletCredentials createMoneroNewWalletCredentials({required String name, requir
Future<void> syncTrezor(Object wallet);
Map<String, List<int>> debugCallLength();
Map<String, dynamic> getWalletCacheDebug();
Future<int> getNodeHeight(Object wallet);
String getLegacySeed(Object wallet, String langName);
bool isBackgroundSyncRunning(Object wallet);
Future<void> startBackgroundSync(Object wallet);
Future<void> stopBackgroundSync(Object wallet, String password);
}
abstract class MoneroSubaddressList {
@@ -1018,7 +1025,16 @@ abstract class Solana {
});
Future<void> discoverAndAddWalletTokens(WalletBase wallet);
Future<PendingTransaction> sendNFT(
WalletBase wallet, {
required String mintAddress,
required String destinationAddress,
String? name,
});
Future<SolanaNFTMetadata?> getNFTOnChainMetadata(WalletBase wallet, String mintAddress);
Future<Set<String>> getHeldTokenMints(WalletBase wallet);
TransactionInfo getTransactionInfo({
required String id,
required DateTime blockTime,
@@ -1031,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;