feat: Add warning before adding erc20 token via contract address (#1663)

* feat: Add warning before adding erc20 token via contract address

* fix: Update message on edit token page

* fix: Adjust polygon secrets variable name

* fix: Handle loading state, translate strings and fix other issues

* fix linux build [skip ci]

* fix: Modify warning text for contract address scam alert

* Update secret_key.dart

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
David Adegoke
2024-09-12 00:19:47 +03:00
committed by GitHub
co-authored by Omar Hatem
parent 0e106834bc
commit 7bf2fb9dce
34 changed files with 584 additions and 93 deletions
@@ -0,0 +1,80 @@
class Erc20TokenInfoExplorers {
String? contractAddress;
String? tokenName;
String? symbol;
String? divisor;
String? tokenType;
String? totalSupply;
String? blueCheckmark;
String? description;
String? website;
String? email;
String? blog;
String? reddit;
String? slack;
String? facebook;
String? twitter;
String? bitcointalk;
String? github;
String? telegram;
String? wechat;
String? linkedin;
String? discord;
String? whitepaper;
String? tokenPriceUSD;
String? image;
Erc20TokenInfoExplorers({
this.contractAddress,
this.tokenName,
this.symbol,
this.divisor,
this.tokenType,
this.totalSupply,
this.blueCheckmark,
this.description,
this.website,
this.email,
this.blog,
this.reddit,
this.slack,
this.facebook,
this.twitter,
this.bitcointalk,
this.github,
this.telegram,
this.wechat,
this.linkedin,
this.discord,
this.whitepaper,
this.tokenPriceUSD,
this.image,
});
Erc20TokenInfoExplorers.fromJson(Map<String, dynamic> json) {
contractAddress = json['contractAddress'] as String?;
tokenName = json['tokenName'] as String?;
symbol = json['symbol'] as String?;
divisor = json['divisor'] as String?;
tokenType = json['tokenType'] as String?;
totalSupply = json['totalSupply'] as String?;
blueCheckmark = json['blueCheckmark'] as String?;
description = json['description'] as String?;
website = json['website'] as String?;
email = json['email'] as String?;
blog = json['blog'] as String?;
reddit = json['reddit'] as String?;
slack = json['slack'] as String?;
facebook = json['facebook'] as String?;
twitter = json['twitter'] as String?;
bitcointalk = json['bitcointalk'] as String?;
github = json['github'] as String?;
telegram = json['telegram'] as String?;
wechat = json['wechat'] as String?;
linkedin = json['linkedin'] as String?;
discord = json['discord'] as String?;
whitepaper = json['whitepaper'] as String?;
tokenPriceUSD = json['tokenPriceUSD'] as String?;
image = json['image'] as String?;
}
}
@@ -0,0 +1,85 @@
class Erc20TokenInfoMoralis {
String? address;
String? addressLabel;
String? name;
String? symbol;
String? decimals;
String? logo;
String? logoHash;
String? thumbnail;
String? totalSupply;
String? totalSupplyFormatted;
String? fullyDilutedValuation;
String? blockNumber;
int? validated;
String? createdAt;
bool? possibleSpam;
bool? verifiedContract;
Links? links;
int? securityScore;
Erc20TokenInfoMoralis({
this.address,
this.addressLabel,
this.name,
this.symbol,
this.decimals,
this.logo,
this.logoHash,
this.thumbnail,
this.totalSupply,
this.totalSupplyFormatted,
this.fullyDilutedValuation,
this.blockNumber,
this.validated,
this.createdAt,
this.possibleSpam,
this.verifiedContract,
this.links,
this.securityScore,
});
Erc20TokenInfoMoralis.fromJson(Map<String, dynamic> json) {
address = json['address'] as String?;
addressLabel = json['address_label'] as String?;
name = json['name'] as String?;
symbol = json['symbol'] as String?;
decimals = json['decimals'] as String?;
logo = json['logo'] as String?;
logoHash = json['logo_hash'] as String?;
thumbnail = json['thumbnail'] as String?;
totalSupply = json['total_supply'] as String?;
totalSupplyFormatted = json['total_supply_formatted'] as String?;
fullyDilutedValuation = json['fully_diluted_valuation'] as String?;
blockNumber = json['block_number'] as String?;
validated = json['validated'] as int?;
createdAt = json['created_at'] as String?;
possibleSpam = json['possible_spam'] as bool?;
verifiedContract = json['verified_contract'] as bool;
links =
json['links'] != null ? new Links.fromJson(json['links'] as Map<String, dynamic>) : null;
securityScore = json['security_score'] as int?;
}
}
class Links {
String? twitter;
String? website;
String? facebook;
String? reddit;
String? github;
String? linkedin;
String? telegram;
Links({this.twitter, this.website, this.facebook, this.reddit});
Links.fromJson(Map<String, dynamic> json) {
twitter = json['twitter'] as String?;
website = json['website'] as String?;
facebook = json['facebook'] as String?;
reddit = json['reddit'] as String?;
github = json['github'] as String?;
linkedin = json['linkedin'] as String?;
telegram = json['telegram'] as String?;
}
}
+94 -45
View File
@@ -2,16 +2,19 @@ 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_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/erc20_token.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class EditTokenPage extends BasePage {
EditTokenPage({
@@ -79,7 +82,7 @@ class _EditTokenPageBodyState extends State<EditTokenPageBody> {
if (widget.token != null) {
address = widget.homeSettingsViewModel.getTokenAddressBasedOnWallet(widget.token!);
_contractAddressController.text = address ?? '';
_tokenNameController.text = widget.token!.name;
_tokenSymbolController.text = widget.token!.title;
@@ -135,8 +138,8 @@ class _EditTokenPageBodyState extends State<EditTokenPageBody> {
S.of(context).warning,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).dialogTheme.backgroundColor,
fontWeight: FontWeight.bold,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
),
),
Padding(
@@ -145,7 +148,7 @@ class _EditTokenPageBodyState extends State<EditTokenPageBody> {
S.of(context).add_token_warning,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.extension<TransactionTradeTheme>()!
.detailsTitlesColor,
@@ -167,6 +170,15 @@ class _EditTokenPageBodyState extends State<EditTokenPageBody> {
bottomSection: Column(
children: [
if (_showDisclaimer) ...[
Text(
S.current.do_not_send_funds_to_contract_address_warning,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
),
),
SizedBox(height: 20),
CheckboxWidget(
value: _disclaimerChecked,
caption: S.of(context).add_token_disclaimer_check,
@@ -176,47 +188,84 @@ class _EditTokenPageBodyState extends State<EditTokenPageBody> {
),
SizedBox(height: 20),
],
Row(
children: <Widget>[
Expanded(
child: PrimaryButton(
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: PrimaryButton(
onPressed: () async {
if (_formKey.currentState!.validate() &&
(!_showDisclaimer || _disclaimerChecked)) {
await widget.homeSettingsViewModel.addToken(
token: CryptoCurrency(
name: _tokenNameController.text,
title: _tokenSymbolController.text.toUpperCase(),
decimals: int.parse(_tokenDecimalController.text),
iconPath: _tokenIconPathController.text,
),
contractAddress: _contractAddressController.text,
);
if (mounted) {
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: S.of(context).save,
color: Theme.of(context).primaryColor,
textColor: Colors.white,
),
),
],
},
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 actionCall = () async {
await widget.homeSettingsViewModel.addToken(
token: CryptoCurrency(
name: _tokenNameController.text,
title: _tokenSymbolController.text.toUpperCase(),
decimals: int.parse(_tokenDecimalController.text),
iconPath: _tokenIconPathController.text,
),
contractAddress: _contractAddressController.text,
);
};
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();
if (mounted) {
Navigator.pop(context);
}
},
actionLeftButton: () => Navigator.of(dialogContext).pop(),
);
},
);
} else {
await actionCall();
if (mounted) {
Navigator.pop(context);
}
}
}
},
text: S.of(context).save,
color: Theme.of(context).primaryColor,
textColor: Colors.white,
),
),
],
);
},
),
],
),
@@ -1,8 +1,14 @@
import 'dart:convert';
import 'dart:developer';
import 'package:cake_wallet/core/fiat_conversion_service.dart';
import 'package:cake_wallet/entities/erc20_token_info_explorers.dart';
import 'package:cake_wallet/entities/fiat_api_mode.dart';
import 'package:cake_wallet/entities/erc20_token_info_moralis.dart';
import 'package:cake_wallet/entities/sort_balance_types.dart';
import 'package:cake_wallet/ethereum/ethereum.dart';
import 'package:cake_wallet/polygon/polygon.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
import 'package:cake_wallet/solana/solana.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/tron/tron.dart';
@@ -11,6 +17,8 @@ import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/erc20_token.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:mobx/mobx.dart';
import 'package:http/http.dart' as http;
import 'package:cake_wallet/.secrets.g.dart' as secrets;
part 'home_settings_view_model.g.dart';
@@ -18,7 +26,10 @@ class HomeSettingsViewModel = HomeSettingsViewModelBase with _$HomeSettingsViewM
abstract class HomeSettingsViewModelBase with Store {
HomeSettingsViewModelBase(this._settingsStore, this._balanceViewModel)
: tokens = ObservableSet<CryptoCurrency>() {
: tokens = ObservableSet<CryptoCurrency>(),
isAddingToken = false,
isDeletingToken = false,
isValidatingContractAddress = false {
_updateTokensList();
}
@@ -27,6 +38,15 @@ abstract class HomeSettingsViewModelBase with Store {
final ObservableSet<CryptoCurrency> tokens;
@observable
bool isAddingToken;
@observable
bool isDeletingToken;
@observable
bool isValidatingContractAddress;
@observable
String searchText = '';
@@ -45,66 +65,263 @@ abstract class HomeSettingsViewModelBase with Store {
@action
void setPinNativeToken(bool value) => _settingsStore.pinNativeTokenAtTop = value;
@action
Future<void> addToken({
required String contractAddress,
required CryptoCurrency token,
}) async {
if (_balanceViewModel.wallet.type == WalletType.ethereum) {
final erc20token = Erc20Token(
name: token.name,
symbol: token.title,
decimal: token.decimals,
contractAddress: contractAddress,
iconPath: token.iconPath,
);
try {
isAddingToken = true;
if (_balanceViewModel.wallet.type == WalletType.ethereum) {
final erc20token = Erc20Token(
name: token.name,
symbol: token.title,
decimal: token.decimals,
contractAddress: contractAddress,
iconPath: token.iconPath,
);
await ethereum!.addErc20Token(_balanceViewModel.wallet, erc20token);
await ethereum!.addErc20Token(_balanceViewModel.wallet, erc20token);
}
if (_balanceViewModel.wallet.type == WalletType.polygon) {
final polygonToken = Erc20Token(
name: token.name,
symbol: token.title,
decimal: token.decimals,
contractAddress: contractAddress,
iconPath: token.iconPath,
);
await polygon!.addErc20Token(_balanceViewModel.wallet, polygonToken);
}
if (_balanceViewModel.wallet.type == WalletType.solana) {
await solana!.addSPLToken(
_balanceViewModel.wallet,
token,
contractAddress,
);
}
if (_balanceViewModel.wallet.type == WalletType.tron) {
await tron!.addTronToken(_balanceViewModel.wallet, token, contractAddress);
}
_updateTokensList();
_updateFiatPrices(token);
} finally {
isAddingToken = false;
}
if (_balanceViewModel.wallet.type == WalletType.polygon) {
final polygonToken = Erc20Token(
name: token.name,
symbol: token.title,
decimal: token.decimals,
contractAddress: contractAddress,
iconPath: token.iconPath,
);
await polygon!.addErc20Token(_balanceViewModel.wallet, polygonToken);
}
if (_balanceViewModel.wallet.type == WalletType.solana) {
await solana!.addSPLToken(
_balanceViewModel.wallet,
token,
contractAddress,
);
}
if (_balanceViewModel.wallet.type == WalletType.tron) {
await tron!.addTronToken(_balanceViewModel.wallet, token, contractAddress);
}
_updateTokensList();
_updateFiatPrices(token);
}
@action
Future<void> deleteToken(CryptoCurrency token) async {
if (_balanceViewModel.wallet.type == WalletType.ethereum) {
await ethereum!.deleteErc20Token(_balanceViewModel.wallet, token as Erc20Token);
}
try {
isDeletingToken = true;
if (_balanceViewModel.wallet.type == WalletType.ethereum) {
await ethereum!.deleteErc20Token(_balanceViewModel.wallet, token as Erc20Token);
}
if (_balanceViewModel.wallet.type == WalletType.polygon) {
await polygon!.deleteErc20Token(_balanceViewModel.wallet, token as Erc20Token);
}
if (_balanceViewModel.wallet.type == WalletType.polygon) {
await polygon!.deleteErc20Token(_balanceViewModel.wallet, token as Erc20Token);
}
if (_balanceViewModel.wallet.type == WalletType.solana) {
await solana!.deleteSPLToken(_balanceViewModel.wallet, token);
}
if (_balanceViewModel.wallet.type == WalletType.solana) {
await solana!.deleteSPLToken(_balanceViewModel.wallet, token);
}
if (_balanceViewModel.wallet.type == WalletType.tron) {
await tron!.deleteTronToken(_balanceViewModel.wallet, token);
if (_balanceViewModel.wallet.type == WalletType.tron) {
await tron!.deleteTronToken(_balanceViewModel.wallet, token);
}
_updateTokensList();
} finally {
isDeletingToken = false;
}
}
Future<bool> checkIfERC20TokenContractAddressIsAPotentialScamAddress(
String contractAddress,
) async {
try {
isValidatingContractAddress = true;
if (!isEVMCompatibleChain(_balanceViewModel.wallet.type)) {
return false;
}
bool isEthereum = _balanceViewModel.wallet.type == WalletType.ethereum;
bool isPotentialScamViaMoralis = await _isPotentialScamTokenViaMoralis(
contractAddress,
isEthereum ? 'eth' : 'polygon',
);
bool isPotentialScamViaExplorers = await _isPotentialScamTokenViaExplorers(
contractAddress,
isEthereum: isEthereum,
);
bool isUnverifiedContract = await _isContractUnverified(
contractAddress,
isEthereum: isEthereum,
);
final showWarningForContractAddress =
isPotentialScamViaMoralis || isUnverifiedContract || isPotentialScamViaExplorers;
return showWarningForContractAddress;
} finally {
isValidatingContractAddress = false;
}
}
Future<bool> _isPotentialScamTokenViaMoralis(
String contractAddress,
String chainName,
) async {
final uri = Uri.https(
'deep-index.moralis.io',
'/api/v2.2/erc20/metadata',
{
"chain": chainName,
"addresses": contractAddress,
},
);
try {
final response = await http.get(
uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
);
final decodedResponse = jsonDecode(response.body);
final tokenInfo = Erc20TokenInfoMoralis.fromJson(decodedResponse[0] as Map<String, dynamic>);
// Based on analysis using Moralis internal metrics
if (tokenInfo.possibleSpam == true) {
return true;
}
// Tokens whose contract have not been verified are potentially risky tokens.
if (tokenInfo.verifiedContract == false) {
return true;
}
// Tokens with a security score less than 40 are potentially risky, requiring caution when dealing with them.
if (tokenInfo.securityScore == null || tokenInfo.securityScore! < 40) {
return true;
}
// Absence of a website URL for an ERC-20 token can be a potential red flag. A legitimate ERC-20 projects should have a well-maintained website that provides information about the token, its purpose, team, and roadmap.
if (tokenInfo.links?.website == null || tokenInfo.links!.website!.isEmpty) {
return true;
}
// Having a Fully Diluted Valiuation of 0 is a significant red flag that could signify:
// - An abandoned/unlaunched project
// - Incorrect/missing token data
// - Suspicious manipulation of token data
if (tokenInfo.fullyDilutedValuation == '0') {
return true;
}
// I mean, a logo is the most basic of all the potential causes, but why does your fully functional project not have a logo?
if (tokenInfo.logo == null) {
return true;
}
return false;
} catch (e) {
return true;
}
}
Future<bool> _isPotentialScamTokenViaExplorers(
String contractAddress, {
required bool isEthereum,
}) async {
final uri = Uri.https(
isEthereum ? "api.etherscan.io" : "api.polygonscan.com",
"/api",
{
"module": "token",
"action": "tokeninfo",
"contractaddress": contractAddress,
"apikey": isEthereum ? secrets.etherScanApiKey : secrets.polygonScanApiKey,
},
);
try {
final response = await http.get(uri);
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
if (decodedResponse['status'] != '1') {
log('${decodedResponse['result']}');
return true;
}
final tokenInfo =
Erc20TokenInfoExplorers.fromJson(decodedResponse['result'][0] as Map<String, dynamic>);
// A token without an email to reach its creators is a potential red flag
if (tokenInfo.email?.isEmpty == true) {
return true;
}
// A token without a website is a potential red flag
if (tokenInfo.website?.isEmpty == true) {
return true;
}
// if (tokenInfo.whitepaper == null) {
// return true;
// }
return false;
} catch (e) {
return true;
}
}
Future<bool> _isContractUnverified(
String contractAddress, {
required bool isEthereum,
}) async {
final uri = Uri.https(
isEthereum ? "api.etherscan.io" : "api.polygonscan.com",
"/api",
{
"module": "contract",
"action": "getsourcecode",
"contractaddress": contractAddress,
"apikey": isEthereum ? secrets.etherScanApiKey : secrets.polygonScanApiKey,
},
);
try {
final response = await http.get(uri);
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
if (decodedResponse['status'] == '0') {
log('${decodedResponse['result']}');
return true;
}
if (decodedResponse['status'] == '1' &&
decodedResponse['result'][0]['ABI'] == 'Contract source code not verified') {
return true; // Contract is not verified
} else {
return false; // Contract is verified
}
} catch (e) {
return true;
}
_updateTokensList();
}
Future<CryptoCurrency?> getToken(String contractAddress) async {