Merge upstream Cake Wallet v6.5.0

Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
jwinterm
2026-10-24 23:49:29 -04:00
1406 changed files with 81062 additions and 48972 deletions
+31 -12
View File
@@ -80,13 +80,26 @@ class AddressFormatter {
spans.add(TextSpan(text: '${chunks[i]} ', style: style));
}
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
return _withAddressSemantics(
address: address,
child: RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
),
);
}
/// The visual segmentation turns an address into space-separated pseudo-words
/// (and, when truncated, into a literal "..."), which cannot be used to verify
/// an address by ear. Announce the full address instead, exactly the way
/// `Text.semanticsLabel` does.
static Widget _withAddressSemantics({required String address, required Widget child}) =>
Semantics(
label: address,
child: ExcludeSemantics(child: child),
);
static Widget _buildTruncatedAddress({
required String address,
required bool isMWEB,
@@ -112,10 +125,13 @@ class AddressFormatter {
TextSpan(text: lastChunk, style: evenTextStyle),
];
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
return _withAddressSemantics(
address: address,
child: RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
),
);
} else {
final int digitCount = chunkSize;
@@ -142,10 +158,13 @@ class AddressFormatter {
TextSpan(text: lastPart, style: evenTextStyle),
];
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
return _withAddressSemantics(
address: address,
child: RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
),
);
}
}
+18 -2
View File
@@ -1,6 +1,8 @@
import 'package:flutter/services.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/utils/device_info.dart';
import 'package:hash_wallet/utils/show_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sensitive_clipboard/sensitive_clipboard.dart';
class ClipboardUtil {
@@ -13,4 +15,18 @@ class ClipboardUtil {
return Clipboard.setData(data);
}
/// Copies [text] to the clipboard and shows a "Copied to Clipboard"
/// confirmation toast. Prefer this for any user-facing copy action so the
/// feedback stays consistent across the app.
static Future<void> copyToClipboard(
BuildContext context,
String text, {
bool isSensitive = false,
}) async {
await setSensitiveDataToClipboard(ClipboardData(text: text), isSensitive: isSensitive);
if (context.mounted) {
showBar<void>(context, S.of(context).copied_to_clipboard);
}
}
}
+5
View File
@@ -11,4 +11,9 @@ class Debounce {
_timer?.cancel();
_timer = Timer(duration, action);
}
void cancel() {
_timer?.cancel();
_timer = null;
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:flutter/services.dart';
class DecimalInputFormatter extends TextInputFormatter {
const DecimalInputFormatter({this.maxDecimals = 7}) : assert(maxDecimals >= 0);
final int maxDecimals;
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
final text = newValue.text;
if (text.isEmpty) return newValue;
if (S.current.all.startsWith(text)) {
return const TextEditingValue(
text: "",
selection: TextSelection.collapsed(offset: 0),
);
}
final regex = maxDecimals == 0 ? RegExp(r'^\d*$') : RegExp('^\\d*([.,]\\d{0,$maxDecimals})?\$');
return regex.hasMatch(text) ? newValue.copyWith(text: text.replaceAll(',', '.')) : oldValue;
}
}
+39 -5
View File
@@ -138,7 +138,8 @@ class ExceptionHandler {
if (kDebugMode || kProfileMode) {
if (_ignoreError(errorDetails.exception.toString()) ||
_ignoreError(errorDetails.stack.toString())) {
_ignoreError(errorDetails.stack.toString()) ||
_flutterErrorIgnore(errorDetails)) {
printV("(BELOW ERROR IS IGNORED AND WILL NOT TRIGGER POPUP IN PROD)");
}
FlutterError.presentError(errorDetails);
@@ -278,6 +279,7 @@ class ExceptionHandler {
"Connection timed out",
"Connection reset by peer",
"Connection closed before full header was received",
"Connection closed while receiving data",
"Connection terminated during handshake",
"OS Error: Connection refused, errno = 61",
"PERMISSION_NOT_GRANTED",
@@ -297,6 +299,7 @@ class ExceptionHandler {
"invalid signature",
"invalid password",
"NetworkImage._loadAsync",
"Invalid image data",
"SSLV3_ALERT_BAD_RECORD_MAC",
"PlatformException(already_active, File picker is already active",
// SVG-related errors
@@ -316,16 +319,22 @@ class ExceptionHandler {
"Wrong Device Status: 0x5515 (UNKNOWN)",
"Command handling failed. With error: hostUnreachable",
// Android IME/Gboard occasionally reports a caret offset past the end of
// the text on the platform text-input channel while typing. Non-fatal
// framework<->platform desync; the app keeps working.
"invalid selection start",
"FocusScopeNode was used after being disposed",
"_getDismissibleFlushbar",
"_QueuedFuture.execute (package:universal_ble/src/queue.dart:65)",
"Pending Request Canceled | RequestQueue disposed",
"reown_core/relay_client/websocket/websocket_handler.dart",
"Image upload failed due to loss of GPU access",
"transport error",
"SdkError.sparkError(field0: Operator RPC error: Connection error: status: Unavailable, message: \"dns error\", details: []",
"the timeout of the request was reached",
"support for coin removed, your seedphrase:"
"support for coin removed, your seedphrase:",
"Exception: Invalid image data",
];
static String _platformName() {
@@ -473,8 +482,33 @@ class ExceptionHandler {
}
static bool _flutterErrorIgnore(FlutterErrorDetails errorDetails) {
// most probably a flutter context error so just ignore it if there is no stack we can debug with
return errorDetails.exception.toString().contains("Null check operator used on a null value") &&
errorDetails.stack == null;
if (errorDetails.exception.toString().contains("Null check operator used on a null value")) {
// Most probably a flutter context error so just ignore it if there is no
// stack we can debug with.
if (errorDetails.stack == null) {
return true;
}
final stack = errorDetails.stack.toString();
if (stack.contains("handleFocusHighlightModeChange") ||
stack.contains("_HighlightModeManager")) {
return true;
}
}
if (errorDetails.exception.toString().contains("Cannot add event after closing")) {
// grpc-dart teardown race (e.g. the MWEB channel on litecoin): a buffered outgoing
// frame is delivered to the http2 stream's sink after the call/channel was
// terminated. The message alone is too generic to ignore, so require the exact
// shape of grpc's forwarding chain: .map().map().handleError().listen(sink.add).
final stack = errorDetails.stack.toString();
if (stack.contains("_StreamSinkWrapper.add") &&
stack.contains("_MapStream._handleData") &&
stack.contains("_HandleErrorStream._handleData")) {
return true;
}
}
return false;
}
}
+3 -2
View File
@@ -6,6 +6,9 @@ class FeatureFlag {
// Hash Bags: Lightning disabled — running Greenlight/LSP infra is out of
// scope for now. Re-enable once we have an LSP relationship lined up.
static const bool isLightningEnabled = false;
// Hash Bags: the charts tab reads Cake's prices.cakewallet.com history API. Off until
// prices.neroswap.com serves history; PriceApiClient makes no requests while it is false.
static const bool isChartsEnabled = false;
static const bool isBackgroundSyncEnabled = true;
static bool get isInAppTorEnabled => CakeTor.instance is! CakeTorDisabled;
static const int verificationWordsCount = kDebugMode || kProfileMode ? 0 : 2;
@@ -16,6 +19,4 @@ class FeatureFlag {
static const bool duressPinEnabled = true;
static const bool isEVMChainSwitcherEnabled = false;
static const bool isAutomaticNodeSwitchingEnabled = false;
static const bool hasNewUi = true;
static const bool hasNewUiExtraPages = true;
}
-18
View File
@@ -1,18 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:mobx/mobx.dart';
import 'package:hash_wallet/utils/mobx.dart';
import 'package:cw_core/keyable.dart';
// part 'node_list_view_model.g.dart';
//
// class NodeListViewModel = NodeListViewModelBase with _$NodeListViewModel;
class ItemCell<Item> with Keyable {
ItemCell(this.value, {required this.isSelectedBuilder, required dynamic key}) {
keyIndex = key;
}
final Item value;
bool get isSelected => isSelectedBuilder(value);
bool Function(Item item) isSelectedBuilder;
}
-5
View File
@@ -1,5 +0,0 @@
class ListSection<Item> {
const ListSection({required this.items});
final List<Item> items;
}
-106
View File
@@ -3,72 +3,14 @@ import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart';
import 'package:cw_core/keyable.dart';
void connectMapToListWithTransform<T extends Keyable, Y extends Keyable>(
ObservableMap<dynamic, T> source, ObservableList<Y> dest, Y Function(T?) transform,
{bool Function(T?)? filter}) {
source.observe((MapChange<dynamic, T> change) {
if (change.type == null) {
return;
}
switch (change.type) {
case OperationType.add:
if (filter?.call(change.newValue) ?? true) {
dest.add(transform(change.newValue));
}
break;
case OperationType.remove:
// Hive could has equal index and key
dest.removeWhere((elem) => elem.keyIndex == (change.key ?? change.newValue?.keyIndex));
break;
case OperationType.update:
for (var i = 0; i < dest.length; i++) {
final item = dest[i];
if (item.keyIndex == change.key) {
dest[i] = transform(change.newValue);
}
}
break;
default:
break;
}
});
}
typedef Filter<T> = bool Function(T);
typedef Transform<T, Y> = Y Function(T);
enum ChangeType { update, delete, add }
class EntityChange<T extends Keyable> {
EntityChange(this.value, this.type, {dynamic key}) : _key = key;
dynamic get key => _key ?? value.keyIndex;
final T value;
final ChangeType type;
final dynamic _key;
}
extension MobxBindable<T extends Keyable> on Box<T> {
StreamSubscription<BoxEvent> bindToList(
ObservableList<T> dest, {
bool initialFire = false,
Filter<T>? filter,
}) {
if (initialFire) {
final res = filter != null ? values.where(filter) : values;
dest.addAll(res);
}
return watch().listen((event) {
if (filter != null && event.value != null && !filter(event.value as T)) {
return;
}
dest.acceptBoxChange(event);
});
}
StreamSubscription<BoxEvent> bindToListWithTransform<Y extends Keyable>(
ObservableList<Y> dest,
@@ -91,37 +33,7 @@ extension MobxBindable<T extends Keyable> on Box<T> {
}
extension HiveBindable<T extends Keyable> on ObservableList<T> {
Stream<EntityChange<T>> listen() {
// ignore: close_sinks
final controller = StreamController<EntityChange<T>>();
observe((ListChange<T> change) {
change.elementChanges?.forEach((change) {
ChangeType type;
switch (change.type) {
case OperationType.add:
type = ChangeType.add;
break;
case OperationType.remove:
type = ChangeType.delete;
break;
case OperationType.update:
type = ChangeType.update;
break;
}
final value = change.newValue as T;
controller.add(EntityChange(value, type,
key: type == ChangeType.delete ? change.index : value.keyIndex));
});
});
return controller.stream;
}
StreamSubscription<EntityChange<T>> bindToList(ObservableList<T> dest) =>
listen().listen((event) => dest.acceptEntityChange(event));
void acceptBoxChange(BoxEvent event, {T? transformed}) {
if (event.deleted) {
@@ -145,22 +57,4 @@ extension HiveBindable<T extends Keyable> on ObservableList<T> {
}
}
void acceptEntityChange(EntityChange<T> event) {
if (event.type == ChangeType.delete) {
removeWhere((el) => el.keyIndex == event.key);
return;
}
final dynamic value = event.value;
if (value is T) {
final index = indexWhere((el) => el.keyIndex == value.keyIndex);
if (index > -1) {
this.setAll(index, [value]); // FIXME: fixme
} else {
add(value);
}
}
}
}
+118 -30
View File
@@ -1,24 +1,41 @@
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/payment_uris.dart';
import 'package:hash_wallet/nano/nano.dart';
import "package:hash_wallet/nano/nano.dart";
import "package:cw_core/amount/amount_sanitizer.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/format_fixed.dart";
import "package:cw_core/lnurl.dart";
import "package:cw_core/payment_uris.dart";
import "package:cw_core/utils/print_verbose.dart";
class PaymentRequest {
PaymentRequest(this.address, this.amount, this.note, this.scheme, this.pjUri,
{this.callbackUrl, this.callbackMessage, this.contractAddress});
PaymentRequest(
this.address,
this.amount,
this.note,
this.scheme,
this.pjUri, {
this.callbackUrl,
this.callbackMessage,
this.contractAddress,
this.chainId,
this.rawTokenAmount,
});
factory PaymentRequest.fromString(String input) {
try {
return PaymentRequest.fromBolt11(input);
} catch (_) {
return PaymentRequest.fromUri(Uri.parse(input));
try {
return PaymentRequest.fromUri(Uri.parse(input));
} catch (_) {
return PaymentRequest(input, "", "", "", null);
}
}
}
factory PaymentRequest.fromBolt11(String invoice) {
final amountRaw = getBolt11Amount(invoice) ?? 0;
final amount = CryptoCurrency.btcln.formatAmount(BigInt.from(amountRaw));
return PaymentRequest(invoice, amount, '', 'lightning', null);
final amount = getBolt11Amount(invoice) ?? Money.zero(CryptoCurrency.btcln);
return PaymentRequest(invoice, amount.toString(), "", "lightning", null);
}
factory PaymentRequest.fromUri(Uri? uri) {
@@ -31,35 +48,50 @@ class PaymentRequest {
String? callbackMessage;
String? pjUri;
String? contractAddress;
int? chainId;
String? rawTokenAmount;
if (uri != null) {
if (uri.queryParameters['pj'] != null) {
if (uri.queryParameters["pj"] != null) {
pjUri = uri.toString();
}
address = uri.queryParameters['address'] ?? uri.path;
address = uri.queryParameters["address"] ?? uri.path;
try {
final lnAmount =
CryptoCurrency.btcln.formatAmount(BigInt.from(getBolt11Amount(uri.path) ?? 0));
final lnAmount = getBolt11Amount(uri.path) ?? Money.zero(CryptoCurrency.btcln);
if (lnAmount != 0) {
amount = lnAmount;
amount = lnAmount.toString();
}
} catch (_) {}
if (amount.isEmpty) {
amount = uri.queryParameters['tx_amount'] ?? uri.queryParameters['amount'] ?? "";
amount = uri.queryParameters["tx_amount"] ?? uri.queryParameters["amount"] ?? "";
}
note = uri.queryParameters['tx_description'] ?? uri.queryParameters['message'] ?? "";
note = uri.queryParameters["tx_description"] ?? uri.queryParameters["message"] ?? "";
scheme = uri.scheme;
callbackUrl = uri.queryParameters['callback'];
callbackMessage = uri.queryParameters['callbackMessage'];
walletType = uri.queryParameters['type'];
callbackUrl = uri.queryParameters["callback"];
callbackMessage = uri.queryParameters["callbackMessage"];
walletType = uri.queryParameters["type"];
if (scheme == "ethereum") {
final paymentUri = ERC681URI.fromUri(uri);
final hasExplicitChainId = uri.path.contains("@");
address = paymentUri.address;
amount = paymentUri.amount;
contractAddress = paymentUri.contractAddress;
chainId = hasExplicitChainId ? paymentUri.chainId : null;
rawTokenAmount = paymentUri.rawTokenAmount;
} else if (scheme == "tron") {
final token = uri.queryParameters["token"];
if (token != null && token.isNotEmpty) {
contractAddress = token;
}
} else if (scheme == "solana") {
final splToken = uri.queryParameters["spl-token"];
if (splToken != null && splToken.isNotEmpty) {
contractAddress = splToken;
}
}
}
@@ -88,9 +120,36 @@ class PaymentRequest {
callbackUrl: callbackUrl,
callbackMessage: callbackMessage,
contractAddress: contractAddress,
chainId: chainId,
rawTokenAmount: rawTokenAmount,
);
}
PaymentRequest copyWith({
String? address,
String? amount,
String? note,
String? scheme,
String? pjUri,
String? callbackUrl,
String? callbackMessage,
String? contractAddress,
int? chainId,
String? rawTokenAmount,
}) =>
PaymentRequest(
address ?? this.address,
amount ?? this.amount,
note ?? this.note,
scheme ?? this.scheme,
pjUri ?? this.pjUri,
callbackUrl: callbackUrl ?? this.callbackUrl,
callbackMessage: callbackMessage ?? this.callbackMessage,
contractAddress: contractAddress ?? this.contractAddress,
chainId: chainId ?? this.chainId,
rawTokenAmount: rawTokenAmount ?? this.rawTokenAmount,
);
final String address;
final String amount;
final String note;
@@ -99,24 +158,53 @@ class PaymentRequest {
final String? callbackUrl;
final String? callbackMessage;
final String? contractAddress;
final int? chainId;
final String? rawTokenAmount;
String? resolveTokenAmount(CryptoCurrency token) {
if (amount.isNotEmpty) {
return amount;
}
if (rawTokenAmount == null || rawTokenAmount!.isEmpty) {
return null;
}
final raw = BigInt.tryParse(rawTokenAmount!);
if (raw == null) {
return null;
}
// 1e9 whole tokens is beyond any real payment, so a larger result means the QR
// came from an old app version that encoded with 18 decimals regardless of token.
if (token.decimals != 18 && raw > BigInt.from(10).pow(token.decimals + 9)) {
printV("resolveTokenAmount: reading $rawTokenAmount with legacy 18 decimals");
return formatFixed(raw, 18);
}
return formatFixed(raw, token.decimals);
}
/// Checks if the amount string is already in a usable format (e.g., "123.45") and doesn't need to be converted from raw format.
///
/// This was causing an error for us when we scan Nano QRs with amounts in them, the amounts are already in usable format so when the parsing was done, it returns 0 wrongly.
static bool _isAlreadyUsableAmount(String amount) {
if (amount.isEmpty) return false;
if (amount.isEmpty) {
return false;
}
// Try to parse as double - if successful, it's already in usable format
final parsed = double.tryParse(amount.replaceAll(',', '.'));
if (parsed == null) return false;
// Check if the amount contains a decimal point and is a reasonable number,
// it's likely already in usable format rather than raw format
// Raw amounts are typically very large integers without decimal points
if (amount.contains('.') && parsed > 0 && parsed < 1000000000) return true;
final parsed = double.tryParse(amount.sanitized());
if (parsed == null) {
return false;
}
if (amount.contains(".") && parsed > 0 && parsed < 1000000000) {
return true;
}
// If it's a small integer (less than 1 billion), it's likely already usable
if (parsed == parsed.toInt() && parsed < 1000000000) return true;
if (parsed == parsed.toInt() && parsed < 1000000000) {
return true;
}
return false;
}
+21 -6
View File
@@ -10,13 +10,15 @@ class ShareUtil {
);
}
static Future<void> shareFile({
static Future<ShareResult> shareFile({
required String filePath,
required String fileName,
required BuildContext context,
}) async {
String? subject,
String? text,
}) {
const _mimeType = 'application/*';
await Share.shareXFiles(
return Share.shareXFiles(
<XFile>[
XFile(
filePath,
@@ -24,17 +26,30 @@ class ShareUtil {
mimeType: _mimeType,
)
],
subject: subject,
text: text,
sharePositionOrigin: _sharePosition(context),
);
}
static Rect _sharePosition(BuildContext context) {
if (!context.mounted) {
return Rect.zero;
return const Rect.fromLTWH(0, 0, 1, 1);
}
final box = context.findRenderObject() as RenderBox?;
final screen = Offset.zero & MediaQuery.of(context).size;
final fallback = Rect.fromCenter(center: screen.center, width: 1, height: 1);
return box!.localToGlobal(Offset.zero) & box.size;
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) {
return fallback;
}
final rect = renderObject.localToGlobal(Offset.zero) & renderObject.size;
final clipped = rect.intersect(screen);
if (clipped.isEmpty) {
return fallback;
}
return clipped;
}
}
+141 -79
View File
@@ -1,15 +1,14 @@
import 'package:hash_wallet/reactions/wallet_connect.dart';
import 'package:hash_wallet/evm/evm.dart';
import 'package:cw_core/cake_hive.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/erc20_token.dart';
import 'package:cw_core/spl_token.dart';
import 'package:cw_core/tron_token.dart';
import "package:cw_core/utils/print_verbose.dart";
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:hive/hive.dart';
class TokenUtilities {
static Future<List<Erc20Token>> loadAllUniqueEvmTokens() async {
@@ -23,10 +22,10 @@ class TokenUtilities {
for (final wallet in evmWallets) {
final chain = getTokenNameBasedOnWalletType(wallet.type);
final box = await _openEvmTokensBoxFor(wallet);
final tokens = await Erc20Token.getAllForWallet(wallet.name, _getDefaultChainId(wallet.type));
for (final t in box.values.where((t) => t.enabled)) {
final key = '$chain|${t.contractAddress.toLowerCase()}';
for (final t in tokens.where((t) => t.enabled)) {
final key = "$chain|${t.contractAddress.toLowerCase()}";
if (seen.add(key)) {
unique.add(t);
}
@@ -45,15 +44,19 @@ class TokenUtilities {
}
static List<Erc20Token> loadDefaultEvmTokensForSwap() {
if (evm == null) return [];
if (evm == null) {
return [];
}
final tokens = <Erc20Token>[];
final seen = <String>{};
for (final chain in evm!.getAllChains()) {
for (final token in evm!.getDefaultTokensByChainId(chain.chainId)) {
final key = '${chain.chainId}|${token.contractAddress.toLowerCase()}';
if (seen.add(key)) tokens.add(token);
final key = "${chain.chainId}|${token.contractAddress.toLowerCase()}";
if (seen.add(key)) {
tokens.add(token);
}
}
}
@@ -73,7 +76,9 @@ class TokenUtilities {
for (final t in [...defaultTokens, ...userTokens]) {
final key = '${t.tag ?? 'ETH'}|${t.contractAddress.toLowerCase()}';
if (seen.add(key)) result.add(t);
if (seen.add(key)) {
result.add(t);
}
}
return result;
@@ -88,7 +93,9 @@ class TokenUtilities {
for (final t in [...defaultTokens, ...userTokens]) {
final key = t.mintAddress.toLowerCase();
if (seen.add(key)) result.add(t);
if (seen.add(key)) {
result.add(t);
}
}
return result;
@@ -103,59 +110,77 @@ class TokenUtilities {
for (final t in [...defaultTokens, ...userTokens]) {
final key = t.contractAddress.toLowerCase();
if (seen.add(key)) result.add(t);
if (seen.add(key)) {
result.add(t);
}
}
return result;
}
/// Finds a token by address across wallets depending on [walletType]
/// Finds a token by address for the given [walletType].
/// - EVM chains: match by contractAddress
static Future<CryptoCurrency?> findTokenByAddress({
required WalletType walletType,
required String address,
}) async {
if (address.isEmpty) return null;
if (address.isEmpty) {
return null;
}
final lower = address.toLowerCase();
switch (walletType) {
case WalletType.ethereum:
case WalletType.polygon:
case WalletType.base:
case WalletType.arbitrum:
case WalletType.bsc:
final tokens = await loadAllUniqueEvmTokens();
for (final t in tokens) {
if (t.contractAddress.toLowerCase() == lower) return t;
}
return null;
default:
return null;
final tokens = await getAvailableTokensForNetwork(walletType);
for (final t in tokens) {
if (t is Erc20Token && t.contractAddress.toLowerCase() == lower) {
return t;
}
}
return null;
}
static Future<Box<Erc20Token>> _openEvmTokensBoxFor(WalletInfo walletInfo) async {
final walletKey = walletInfo.name.replaceAll(' ', '_');
final boxName = _getErc20TokensBoxName(walletKey, walletInfo.type);
if (CakeHive.isBoxOpen(boxName)) {
return CakeHive.box<Erc20Token>(boxName);
}
return CakeHive.openBox<Erc20Token>(boxName);
}
static String _getErc20TokensBoxName(String walletKey, WalletType walletType) {
return switch (walletType) {
WalletType.ethereum => '${walletKey}_${Erc20Token.ethereumBoxName}',
WalletType.polygon => '${walletKey}_${Erc20Token.polygonBoxName}',
WalletType.base => '${walletKey}_${Erc20Token.baseBoxName}',
WalletType.arbitrum => '${walletKey}_${Erc20Token.arbitrumBoxName}',
WalletType.bsc => '${walletKey}_${Erc20Token.bscBoxName}',
_ => '${walletKey}_${Erc20Token.ethereumBoxName}',
static int _getDefaultChainId(WalletType walletType) => switch (walletType) {
WalletType.ethereum => 1,
WalletType.polygon => 137,
WalletType.base => 8453,
WalletType.arbitrum => 42161,
WalletType.bsc => 56,
_ => 1,
};
static Future<int?> findEvmChainIdForContract(
String contractAddress, {
int? excludingChainId,
}) async {
if (evm == null || contractAddress.isEmpty) {
return null;
}
try {
for (final chain in evm!.getAllChains()) {
if (chain.chainId == excludingChainId) {
continue;
}
final walletType = evm!.getWalletTypeByChainId(chain.chainId);
if (walletType == null) {
continue;
}
final token = await findTokenByAddress(walletType: walletType, address: contractAddress);
if (token != null) {
return chain.chainId;
}
}
} catch (e) {
printV("findEvmChainIdForContract failed: $e");
}
return null;
}
static Erc20Token? findErc20Token(CryptoCurrency currency, WalletBase wallet) {
if (currency is Erc20Token) return currency;
if (currency is Erc20Token) {
return currency;
}
// More of a fallback for us
for (final balanceCurrency in wallet.balance.keys) {
@@ -168,10 +193,32 @@ class TokenUtilities {
}
static Erc20Token? findErc20TokenForSwap(CryptoCurrency currency) {
if (currency is Erc20Token) return currency;
if (currency is Erc20Token) {
return currency;
}
for (final token in loadDefaultEvmTokensForSwap()) {
if (_matchesCurrency(token, currency)) return token;
if (_matchesCurrency(token, currency)) {
return token;
}
}
return null;
}
static String? findSolanaTokenMint(CryptoCurrency currency) {
if (currency is SPLToken) return currency.mintAddress;
for (final token in loadDefaultSolTokensForSwap()) {
if (_matchesCurrency(token, currency)) return token.mintAddress;
}
return null;
}
static String? findTronTokenContract(CryptoCurrency currency) {
if (currency is TronToken) return currency.contractAddress;
for (final token in loadDefaultTronTokensForSwap()) {
if (_matchesCurrency(token, currency)) return token.contractAddress;
}
return null;
}
@@ -180,19 +227,19 @@ class TokenUtilities {
final title = currency.title.toLowerCase();
final tag = currency.tag?.toLowerCase();
return title == 'eth' ||
title == 'ethereum' ||
title == 'matic' ||
title == 'polygon' ||
title == 'base' ||
title == 'arbitrum' ||
title == 'bnb' ||
title == 'bsc' ||
title == 'avax' ||
title == 'avalanche' ||
tag == 'polygon' ||
tag == 'bsc' ||
tag == 'avalanche';
return title == "eth" ||
title == "ethereum" ||
title == "matic" ||
title == "polygon" ||
title == "base" ||
title == "arbitrum" ||
title == "bnb" ||
title == "bsc" ||
title == "avax" ||
title == "avalanche" ||
tag == "polygon" ||
tag == "bsc" ||
tag == "avalanche";
}
static int getChainId(CryptoCurrency currency) {
@@ -202,37 +249,41 @@ class TokenUtilities {
// Only check EVM registry for currencies that might be EVM-related
final isPotentialEVM = isNativeToken(currency) ||
(tag != null &&
(tag == 'ETH' || tag == 'POL' || tag == 'BASE' || tag == 'ARB' || tag == 'BSC'));
(tag == "ETH" ||
tag == "POL" ||
tag == "BASE" ||
tag == "ARB" ||
tag == "BSC"));
if (isPotentialEVM) {
// Try by tag first if available (e.g., 'POL', 'BASE', 'ARB')
if (tag != null) {
final chainId = evm?.getChainIdByTag(tag);
if (chainId != null) return chainId;
if (chainId != null) {
return chainId;
}
}
// Try by title (case-insensitive)
final titleChainId = evm?.getChainIdByTitle(title);
if (titleChainId != null) return titleChainId;
if (titleChainId != null) {
return titleChainId;
}
}
// Fallback to hardcoded values for chains not in registry yet
// Avalanche C-Chain
if (title == 'avalanche' || title == 'avax' || tag == 'AVALANCHE') {
if (title == "avalanche" || title == "avax" || tag == "AVALANCHE") {
return 43114;
}
// Optimism
if (title == 'optimism' || title == 'op' || tag == 'OPTIMISM') {
if (title == "optimism" || title == "op" || tag == "OPTIMISM") {
return 10;
}
// Fantom Opera
if (title == 'fantom' || title == 'ftm' || tag == 'FANTOM') {
if (title == "fantom" || title == "ftm" || tag == "FANTOM") {
return 250;
}
// Default to Ethereum mainnet
return 1;
}
@@ -243,9 +294,7 @@ class TokenUtilities {
final allTokens = <CryptoCurrency>[baseCurrency];
final addedAddresses = <String>{};
// Handle EVM networks
if (isEVMCompatibleChain(network)) {
// First, collect all user tokens
final userTokens = await _getUserTokensForNetwork(baseCurrency);
for (final token in userTokens) {
if (token is Erc20Token) {
@@ -256,7 +305,17 @@ class TokenUtilities {
}
}
// Then add tokens from CryptoCurrency.all that don't duplicate user tokens
if (evm != null) {
final chainId = evm!.getChainIdByWalletType(network);
for (final token in evm!.getDefaultTokensByChainId(chainId)) {
final address = token.contractAddress.toLowerCase();
if (addedAddresses.add(address)) {
allTokens.add(token);
}
}
}
// Add tokens from CryptoCurrency.all that don't duplicate user tokens
for (final currency in CryptoCurrency.all) {
// Match by tag for POL/BASE, match by title==tag for ETH
final matches = (baseCurrency.tag == null && baseCurrency.title == currency.tag) ||
@@ -279,20 +338,23 @@ class TokenUtilities {
return allTokens;
}
static bool _matchesCurrency(CryptoCurrency a, CryptoCurrency b) {
return a.title.toUpperCase() == b.title.toUpperCase() &&
(a.tag?.toUpperCase() == b.tag?.toUpperCase());
}
static bool _matchesCurrency(CryptoCurrency a, CryptoCurrency b) =>
a.title.toUpperCase() == b.title.toUpperCase() &&
(a.tag?.toUpperCase() == b.tag?.toUpperCase());
static Future<List<CryptoCurrency>> _getUserTokensForNetwork(CryptoCurrency baseCurrency) async {
final walletType = cryptoCurrencyOrTokenToWalletType(baseCurrency);
if (walletType == null) return [];
if (walletType == null) {
return [];
}
if (isEVMCompatibleChain(walletType)) {
final tokens = await TokenUtilities.loadAllUniqueEvmTokens();
return tokens.where((token) {
if (baseCurrency.tag == null) return token.tag == baseCurrency.title;
if (baseCurrency.tag == null) {
return token.tag == baseCurrency.title;
}
return token.tag?.toLowerCase() == baseCurrency.tag?.toLowerCase();
}).toList();