CakeWallet/lib/src/screens/dashboard/edit_token_page.dart
Omar Hatem 23a47ed561
Fix Scam tokens handling and make it persistent (#2138)
* Fix Scam tokens handling and make it persistent

* Add potential scam text next to scam tokens

* change UI of potential scam text
2025-04-03 03:32:00 +02:00

422 lines
17 KiB
Dart

import 'package:cake_wallet/core/address_validator.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/base_text_form_field.dart';
import 'package:cake_wallet/src/widgets/checkbox_widget.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/transaction_trade_theme.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/dashboard/home_settings_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class EditTokenPage extends BasePage {
EditTokenPage({
Key? key,
required this.homeSettingsViewModel,
this.token,
this.initialContractAddress,
}) : assert(token == null || initialContractAddress == null);
final HomeSettingsViewModel homeSettingsViewModel;
final CryptoCurrency? token;
final String? initialContractAddress;
@override
String? get title => S.current.edit_token;
@override
Widget body(BuildContext context) {
return EditTokenPageBody(
homeSettingsViewModel: homeSettingsViewModel,
token: token,
initialContractAddress: initialContractAddress,
);
}
}
class EditTokenPageBody extends StatefulWidget {
const EditTokenPageBody({
Key? key,
required this.homeSettingsViewModel,
this.token,
this.initialContractAddress,
}) : super(key: key);
final HomeSettingsViewModel homeSettingsViewModel;
final CryptoCurrency? token;
final String? initialContractAddress;
@override
State<EditTokenPageBody> createState() => _EditTokenPageBodyState();
}
class _EditTokenPageBodyState extends State<EditTokenPageBody> {
final TextEditingController _contractAddressController = TextEditingController();
final TextEditingController _tokenNameController = TextEditingController();
final TextEditingController _tokenSymbolController = TextEditingController();
final TextEditingController _tokenDecimalController = TextEditingController();
final TextEditingController _tokenIconPathController = TextEditingController();
final FocusNode _contractAddressFocusNode = FocusNode();
final FocusNode _tokenNameFocusNode = FocusNode();
final FocusNode _tokenSymbolFocusNode = FocusNode();
final FocusNode _tokenDecimalFocusNode = FocusNode();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
bool _showDisclaimer = false;
bool _disclaimerChecked = false;
@override
void initState() {
super.initState();
String? address;
if (widget.token != null) {
address = widget.homeSettingsViewModel.getTokenAddressBasedOnWallet(widget.token!);
_contractAddressController.text = address ?? '';
_tokenNameController.text = widget.token!.name;
_tokenSymbolController.text = widget.token!.title;
_tokenDecimalController.text = widget.token!.decimals.toString();
_tokenIconPathController.text = widget.token?.iconPath ?? '';
}
if (widget.initialContractAddress != null) {
_contractAddressController.text = widget.initialContractAddress!;
_getTokenInfo();
}
_contractAddressFocusNode.addListener(() {
if (!_contractAddressFocusNode.hasFocus) {
_getTokenInfo();
}
final contractAddress = _contractAddressController.text;
if (contractAddress.isNotEmpty && contractAddress != address) {
setState(() {
_showDisclaimer = true;
});
}
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: ScrollableWithBottomSection(
contentPadding: EdgeInsets.zero,
content: Padding(
padding: EdgeInsets.symmetric(horizontal: 25),
child: Column(
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 16, horizontal: 28),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/restore_keys.png'),
const SizedBox(width: 24),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context).warning,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
),
),
Padding(
padding: EdgeInsets.only(top: 5),
child: Text(
S.of(context).add_token_warning,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.extension<TransactionTradeTheme>()!
.detailsTitlesColor,
),
),
),
],
),
),
],
),
),
SizedBox(height: 50),
_tokenForm(),
],
),
),
bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24),
bottomSection: Column(
children: [
if (_showDisclaimer) ...[
CheckboxWidget(
value: _disclaimerChecked,
caption: S.of(context).add_token_disclaimer_check,
onChanged: (value) {
_disclaimerChecked = value;
},
),
SizedBox(height: 20),
],
Observer(
builder: (context) {
return Row(
children: <Widget>[
Expanded(
child: LoadingPrimaryButton(
isLoading: widget.homeSettingsViewModel.isDeletingToken,
onPressed: () async {
if (widget.token != null) {
await widget.homeSettingsViewModel.deleteToken(widget.token!);
}
Navigator.pop(context);
},
text: widget.token != null ? S.of(context).delete : S.of(context).cancel,
color: Colors.red,
textColor: Colors.white,
),
),
SizedBox(width: 20),
Expanded(
child: LoadingPrimaryButton(
isLoading: widget.homeSettingsViewModel.isAddingToken ||
widget.homeSettingsViewModel.isValidatingContractAddress,
onPressed: () async {
if (_formKey.currentState!.validate() &&
(!_showDisclaimer || _disclaimerChecked)) {
final hasPotentialError = await widget.homeSettingsViewModel
.checkIfERC20TokenContractAddressIsAPotentialScamAddress(
_contractAddressController.text,
);
final isWhitelisted = await widget.homeSettingsViewModel
.checkIfTokenIsWhitelisted(_contractAddressController.text);
bool isPotentialScam = hasPotentialError;
final tokenSymbol = _tokenSymbolController.text.toUpperCase();
// check if the token symbol is the same as any of the base currencies symbols (ETH, SOL, POL, TRX, etc):
// if it is, then it's probably a scam unless it's in the whitelist
final baseCurrencySymbols =
CryptoCurrency.all.map((e) => e.title.toUpperCase()).toList();
if (baseCurrencySymbols.contains(tokenSymbol.trim().toUpperCase()) &&
!isWhitelisted) {
isPotentialScam = true;
}
final actionCall = () async {
try {
await widget.homeSettingsViewModel.addToken(
token: CryptoCurrency(
name: _tokenNameController.text,
title: _tokenSymbolController.text.toUpperCase(),
decimals: int.parse(_tokenDecimalController.text),
iconPath: _tokenIconPathController.text,
isPotentialScam: isPotentialScam,
),
contractAddress: _contractAddressController.text,
);
if (mounted) {
Navigator.pop(context);
}
} catch (e) {
showPopUp<void>(
context: context,
builder: (dialogContext) {
return AlertWithOneAction(
alertTitle: S.current.warning,
alertContent: e.toString(),
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(dialogContext).pop(),
);
},
);
}
};
if (hasPotentialError) {
showPopUp<void>(
context: context,
builder: (dialogContext) {
return AlertWithTwoActions(
alertTitle: S.current.warning,
alertContent: S.current.contract_warning,
rightButtonText: S.of(context).continue_text,
leftButtonText: S.of(context).cancel,
actionRightButton: () async {
Navigator.of(dialogContext).pop();
await actionCall();
},
actionLeftButton: () => Navigator.of(dialogContext).pop(),
);
},
);
} else {
try {
await actionCall();
} catch (e) {
showPopUp<void>(
context: context,
builder: (dialogContext) {
return AlertWithOneAction(
alertTitle: "Unable to add token",
alertContent: "$e",
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop(),
);
},
);
}
if (mounted) {
Navigator.pop(context);
}
}
}
},
text: S.of(context).save,
color: Theme.of(context).primaryColor,
textColor: Colors.white,
),
),
],
);
},
),
],
),
),
);
}
void _getTokenInfo() async {
if (_contractAddressController.text.isNotEmpty) {
final token = await widget.homeSettingsViewModel.getToken(_contractAddressController.text);
if (token != null) {
final isZano = widget.homeSettingsViewModel.walletType == WalletType.zano;
if (_tokenNameController.text.isEmpty || isZano) _tokenNameController.text = token.name;
if (_tokenSymbolController.text.isEmpty || isZano)
_tokenSymbolController.text = token.title;
if (_tokenIconPathController.text.isEmpty)
_tokenIconPathController.text = token.iconPath ?? '';
if (_tokenDecimalController.text.isEmpty || isZano)
_tokenDecimalController.text = token.decimals.toString();
}
}
}
Future<void> _pasteText() async {
final value = await Clipboard.getData('text/plain');
if (value?.text?.isNotEmpty ?? false) {
_contractAddressController.text = value!.text!;
_getTokenInfo();
setState(() {
_showDisclaimer = true;
});
}
}
Widget _tokenForm() {
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AddressTextField(
controller: _contractAddressController,
focusNode: _contractAddressFocusNode,
placeholder: S.of(context).token_contract_address,
options: [AddressTextFieldOption.paste],
buttonColor: Theme.of(context).hintColor,
validator: widget.homeSettingsViewModel.walletType == WalletType.zano
? null
: AddressValidator(type: widget.homeSettingsViewModel.nativeToken).call,
onPushPasteButton: (_) {
_pasteText();
},
),
const SizedBox(height: 8),
BaseTextFormField(
controller: _tokenNameController,
focusNode: _tokenNameFocusNode,
onSubmit: (_) => FocusScope.of(context).requestFocus(_tokenSymbolFocusNode),
textInputAction: TextInputAction.next,
hintText: S.of(context).token_name,
validator: (text) {
if (text?.isNotEmpty ?? false) {
return null;
}
return S.of(context).field_required;
},
),
const SizedBox(height: 8),
BaseTextFormField(
controller: _tokenSymbolController,
focusNode: _tokenSymbolFocusNode,
onSubmit: (_) => FocusScope.of(context).requestFocus(_tokenDecimalFocusNode),
textInputAction: TextInputAction.next,
hintText: S.of(context).token_symbol,
validator: (text) {
if (text?.isNotEmpty ?? false) {
return null;
}
return S.of(context).field_required;
},
),
const SizedBox(height: 8),
BaseTextFormField(
controller: _tokenDecimalController,
focusNode: _tokenDecimalFocusNode,
textInputAction: TextInputAction.done,
hintText: S.of(context).token_decimal,
validator: (text) {
if (text?.isEmpty ?? true) {
return S.of(context).field_required;
}
if (int.tryParse(text!) == null) {
return S.of(context).invalid_input;
}
if (int.tryParse(text) == 0) {
return S.current.decimals_cannot_be_zero;
}
return null;
},
),
SizedBox(height: 24),
],
),
);
}
}