dev
dart 537 lines 21.6 KB
Raw
1 import 'package:cake_wallet/core/address_validator.dart';
2 import 'package:cake_wallet/generated/i18n.dart';
3 import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
4 import 'package:cake_wallet/src/screens/base_page.dart';
5 import 'package:cake_wallet/src/widgets/address_text_field.dart';
6 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
7 import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
8 import 'package:cake_wallet/src/widgets/base_text_form_field.dart';
9 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
10 import 'package:cake_wallet/src/widgets/checkbox_widget.dart';
11 import 'package:cake_wallet/src/widgets/primary_button.dart';
12 import 'package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart';
13 import 'package:cake_wallet/src/widgets/warning_box_widget.dart';
14 import 'package:cake_wallet/themes/core/theme_extension.dart';
15 import 'package:cake_wallet/utils/show_pop_up.dart';
16 import 'package:cake_wallet/view_model/dashboard/home_settings_view_model.dart';
17 import 'package:cw_core/crypto_currency.dart';
18 import 'package:cw_core/utils/homoglyph_normalizer.dart';
19 import 'package:cw_core/wallet_type.dart';
20 import 'package:dotted_border/dotted_border.dart';
21 import 'package:flutter/material.dart';
22 import 'package:flutter/services.dart';
23 import 'package:flutter_mobx/flutter_mobx.dart';
24
25 class EditTokenPage extends BasePage {
26 EditTokenPage({
27 Key? key,
28 required this.homeSettingsViewModel,
29 this.token,
30 this.initialContractAddress,
31 }) : assert(token == null || initialContractAddress == null);
32
33 final HomeSettingsViewModel homeSettingsViewModel;
34 final CryptoCurrency? token;
35 final String? initialContractAddress;
36
37 @override
38 String? get title => (token != null || initialContractAddress != null)
39 ? S.current.edit_token
40 : S.current.add_token;
41
42 @override
43 Widget body(BuildContext context) => EditTokenPageBody(
44 homeSettingsViewModel: homeSettingsViewModel,
45 token: token,
46 initialContractAddress: initialContractAddress,
47 );
48 }
49
50 class EditTokenPageBody extends StatefulWidget {
51 const EditTokenPageBody({
52 Key? key,
53 required this.homeSettingsViewModel,
54 this.token,
55 this.initialContractAddress,
56 }) : super(key: key);
57
58 final HomeSettingsViewModel homeSettingsViewModel;
59 final CryptoCurrency? token;
60 final String? initialContractAddress;
61
62 @override
63 State<EditTokenPageBody> createState() => _EditTokenPageBodyState();
64 }
65
66 class _EditTokenPageBodyState extends State<EditTokenPageBody> {
67 final TextEditingController _contractAddressController = TextEditingController();
68 final TextEditingController _tokenNameController = TextEditingController();
69 final TextEditingController _tokenSymbolController = TextEditingController();
70 final TextEditingController _tokenDecimalController = TextEditingController();
71 final TextEditingController _tokenIconPathController = TextEditingController();
72
73 final FocusNode _contractAddressFocusNode = FocusNode();
74 final FocusNode _tokenNameFocusNode = FocusNode();
75 final FocusNode _tokenSymbolFocusNode = FocusNode();
76 final FocusNode _tokenDecimalFocusNode = FocusNode();
77
78 final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
79
80 bool _showDisclaimer = false;
81 bool _disclaimerChecked = false;
82 bool isEditingToken = false;
83 bool _isTokenVerified = false;
84 bool _isJupiterVerified = false;
85 String? _jupiterVerifiedAddress;
86
87 bool get _isJupiterVerifiedForCurrentAddress =>
88 _isJupiterVerified && _jupiterVerifiedAddress == _contractAddressController.text;
89
90 @override
91 void initState() {
92 super.initState();
93
94 String? address;
95
96 if (widget.token != null) {
97 address = widget.homeSettingsViewModel.getTokenAddressBasedOnWallet(widget.token!);
98
99 _contractAddressController.text = address ?? '';
100 _tokenNameController.text = widget.token!.name;
101 _tokenSymbolController.text = widget.token!.title;
102 _tokenDecimalController.text = widget.token!.decimals.toString();
103 _tokenIconPathController.text = widget.token?.iconPath ?? '';
104
105 isEditingToken = true;
106
107 _checkIfTokenIsVerified(address);
108 }
109
110 if (widget.initialContractAddress != null) {
111 _contractAddressController.text = widget.initialContractAddress!;
112 _getTokenInfo();
113 }
114
115 _contractAddressFocusNode.addListener(() {
116 if (!_contractAddressFocusNode.hasFocus) {
117 _getTokenInfo();
118 }
119
120 final contractAddress = _contractAddressController.text;
121 if (contractAddress.isNotEmpty && contractAddress != address) {
122 setState(() {
123 _showDisclaimer = true;
124 });
125 }
126 });
127 }
128
129 void _checkIfTokenIsVerified(String? contractAddress) {
130 if (contractAddress == null) return;
131
132 final isVerified = widget.homeSettingsViewModel.checkIfTokenIsWhitelisted(contractAddress);
133
134 if (!mounted) return;
135
136 setState(() {
137 _isTokenVerified = isVerified;
138 });
139
140 _checkIfTokenIsVerifiedOnJupiter(contractAddress);
141 }
142
143 Future<void> _checkIfTokenIsVerifiedOnJupiter(String contractAddress) async {
144 final isVerified =
145 await widget.homeSettingsViewModel.checkIfTokenIsVerifiedOnJupiter(contractAddress);
146
147 if (!mounted || _contractAddressController.text != contractAddress) return;
148
149 setState(() {
150 _isJupiterVerified = isVerified ?? false;
151 _jupiterVerifiedAddress = contractAddress;
152 });
153 }
154
155 @override
156 Widget build(BuildContext context) => GestureDetector(
157 onTap: () => FocusScope.of(context).unfocus(),
158 child: ScrollableWithBottomSection(
159 contentPadding: EdgeInsets.zero,
160 content: Padding(
161 padding: EdgeInsets.symmetric(horizontal: 25),
162 child: Column(
163 children: [
164 SizedBox(height: 30),
165 WarningBox(
166 padding: EdgeInsets.all(16),
167 content: S.of(context).add_token_warning,
168 showBorder: false,
169 textWeight: FontWeight.w500,
170 textAlign: TextAlign.start,
171 textColor: context.customColors.warningOutlineColor,
172 iconSize: 22,
173 iconSpacing: 16,
174 ),
175 SizedBox(height: 50),
176 _tokenForm(),
177 ],
178 ),
179 ),
180 bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 48),
181 bottomSection: Column(
182 children: [
183 if (_showDisclaimer) ...[
184 CheckboxWidget(
185 value: _disclaimerChecked,
186 caption: S.of(context).add_token_disclaimer_check,
187 onChanged: (value) {
188 _disclaimerChecked = value;
189 },
190 ),
191 SizedBox(height: 20),
192 ],
193 Observer(
194 builder: (context) => Row(
195 children: <Widget>[
196 Expanded(
197 child: LoadingPrimaryButton(
198 isLoading: widget.homeSettingsViewModel.isDeletingToken,
199 onPressed: () async {
200 if (widget.token != null) {
201 await widget.homeSettingsViewModel.deleteToken(widget.token!);
202 }
203 Navigator.pop(context);
204 },
205 text: widget.token != null ? S.of(context).delete : S.of(context).cancel,
206 color: isEditingToken
207 ? Theme.of(context).colorScheme.errorContainer
208 : Theme.of(context).colorScheme.surfaceContainer,
209 textColor: isEditingToken
210 ? Theme.of(context).colorScheme.onErrorContainer
211 : Theme.of(context).colorScheme.primary,
212 ),
213 ),
214 SizedBox(width: 20),
215 Expanded(
216 child: LoadingPrimaryButton(
217 isLoading: widget.homeSettingsViewModel.isAddingToken ||
218 widget.homeSettingsViewModel.isValidatingContractAddress,
219 onPressed: () async {
220 if (_formKey.currentState!.validate() &&
221 (!_showDisclaimer || _disclaimerChecked)) {
222 final isTokenAlreadyAdded = isEditingToken
223 ? false
224 : await widget.homeSettingsViewModel
225 .checkIfTokenIsAlreadyAdded(_contractAddressController.text);
226 if (isTokenAlreadyAdded) {
227 showPopUp<void>(
228 context: context,
229 builder: (dialogContext) => AlertWithOneAction(
230 alertTitle: S.current.warning,
231 alertContent: S.of(context).token_already_exists,
232 buttonText: S.of(context).ok,
233 buttonAction: () => Navigator.of(dialogContext).pop(),
234 ),
235 );
236 return;
237 }
238
239 final isWhitelisted = await widget.homeSettingsViewModel
240 .checkIfTokenIsWhitelisted(_contractAddressController.text);
241
242 final isTrusted = isWhitelisted || _isJupiterVerifiedForCurrentAddress;
243
244 final hasPotentialError = !isTrusted &&
245 await widget.homeSettingsViewModel
246 .checkIfERC20TokenContractAddressIsAPotentialScamAddress(
247 _contractAddressController.text,
248 );
249
250 bool isPotentialScam = hasPotentialError && !isTrusted;
251
252 // Normalize to catch homoglyph spoofing attacks
253 final tokenSymbol = normalizeHomoglyphs(
254 _tokenSymbolController.text.trim().toUpperCase(),
255 );
256
257 // check if the token symbol is the same as the native token symbol
258 // to prevent token impersonation
259 // (e.g. fake ETH on Ethereum, fake SOL on Solana)
260 final nativeSymbol =
261 widget.homeSettingsViewModel.nativeToken.title.toUpperCase();
262 if (tokenSymbol == nativeSymbol && !isTrusted) {
263 isPotentialScam = true;
264 }
265
266 // check if the token symbol is the same as any of the default token symbols
267 // (e.g. fake USDC, USDT with wrong contract address)
268 if (widget.homeSettingsViewModel.checkIfTokenSymbolMatchesDefaultToken(
269 tokenSymbol,
270 ) &&
271 !isTrusted) {
272 isPotentialScam = true;
273 }
274
275 final actionCall = () async {
276 try {
277 await widget.homeSettingsViewModel.addToken(
278 token: CryptoCurrency(
279 name: _tokenNameController.text,
280 title: _tokenSymbolController.text.toUpperCase(),
281 decimals: int.parse(_tokenDecimalController.text),
282 iconPath: _tokenIconPathController.text.isNotEmpty
283 ? _tokenIconPathController.text
284 : null,
285 isPotentialScam: isPotentialScam,
286 ),
287 contractAddress: _contractAddressController.text,
288 );
289
290 if (mounted) {
291 Navigator.pop(context);
292 }
293 } catch (e) {
294 showPopUp<void>(
295 context: context,
296 builder: (dialogContext) => AlertWithOneAction(
297 alertTitle: S.current.warning,
298 alertContent: e.toString(),
299 buttonText: S.of(context).ok,
300 buttonAction: () => Navigator.of(dialogContext).pop(),
301 ),
302 );
303 }
304 };
305
306 if (hasPotentialError && !isTrusted) {
307 showPopUp<void>(
308 context: context,
309 builder: (dialogContext) => AlertWithTwoActions(
310 alertTitle: S.current.warning,
311 alertContent: S.current.contract_warning,
312 rightButtonText: S.of(context).continue_text,
313 leftButtonText: S.of(context).cancel,
314 actionRightButton: () async {
315 Navigator.of(dialogContext).pop();
316 await actionCall();
317 },
318 actionLeftButton: () => Navigator.of(dialogContext).pop(),
319 ),
320 );
321 } else {
322 try {
323 await actionCall();
324 } catch (e) {
325 showPopUp<void>(
326 context: context,
327 builder: (dialogContext) => AlertWithOneAction(
328 alertTitle: "Unable to add token",
329 alertContent: "$e",
330 buttonText: S.of(context).ok,
331 buttonAction: () => Navigator.of(context).pop(),
332 ),
333 );
334 }
335 if (mounted) {
336 Navigator.pop(context);
337 }
338 }
339 }
340 },
341 text: S.of(context).save,
342 color: Theme.of(context).colorScheme.primary,
343 textColor: Theme.of(context).colorScheme.onPrimary,
344 ),
345 ),
346 ],
347 ),
348 ),
349 ],
350 ),
351 ),
352 );
353
354 void _getTokenInfo() async {
355 if (_contractAddressController.text.isNotEmpty) {
356 final token = await widget.homeSettingsViewModel.getToken(_contractAddressController.text);
357
358 if (!mounted) return;
359
360 if (token != null) {
361 final isZano = widget.homeSettingsViewModel.walletType == WalletType.zano;
362 if (_tokenNameController.text.isEmpty || isZano) _tokenNameController.text = token.name;
363 if (_tokenSymbolController.text.isEmpty || isZano)
364 _tokenSymbolController.text = token.title;
365 if (_tokenIconPathController.text.isEmpty)
366 _tokenIconPathController.text = token.iconPath ?? '';
367 if (_tokenDecimalController.text.isEmpty || isZano)
368 _tokenDecimalController.text = token.decimals.toString();
369
370 _checkIfTokenIsVerified(_contractAddressController.text);
371 }
372 }
373 }
374
375 Future<void> _pasteText() async {
376 final value = await Clipboard.getData('text/plain');
377
378 if (value?.text?.isNotEmpty ?? false) {
379 _contractAddressController.text = value!.text!;
380
381 _getTokenInfo();
382 setState(() {
383 _showDisclaimer = true;
384 });
385 }
386 }
387
388 Widget _tokenForm() => Form(
389 key: _formKey,
390 child: Column(
391 mainAxisSize: MainAxisSize.min,
392 crossAxisAlignment: CrossAxisAlignment.center,
393 children: [
394 _tokenIconPathController.text.isEmpty
395 ? DottedBorder(
396 borderType: BorderType.Circle,
397 dashPattern: [6, 4],
398 color: Theme.of(context).colorScheme.surfaceContainerHighest,
399 strokeWidth: 2,
400 radius: Radius.circular(15),
401 child: Container(width: 75, height: 75),
402 )
403 : Stack(
404 alignment: Alignment.center,
405 children: [
406 ClipOval(
407 child: TokenImageWidget(
408 imageUrl: _tokenIconPathController.text,
409 size: 75,
410 ),
411 ),
412 if (_isTokenVerified || _isJupiterVerifiedForCurrentAddress)
413 Positioned(
414 bottom: 0,
415 right: 0,
416 child: Container(
417 padding: EdgeInsets.all(4),
418 decoration: BoxDecoration(
419 color: Theme.of(context).colorScheme.primary,
420 shape: BoxShape.circle,
421 border: Border.all(
422 color: Theme.of(context).colorScheme.surface,
423 width: 2,
424 ),
425 ),
426 child: Icon(
427 Icons.check,
428 size: 16,
429 color: Theme.of(context).colorScheme.onPrimary,
430 ),
431 ),
432 ),
433 ],
434 ),
435 if (_isJupiterVerifiedForCurrentAddress) ...[
436 const SizedBox(height: 12),
437 Text(
438 S.of(context).token_verified_on_jupiter,
439 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
440 fontWeight: FontWeight.w500,
441 color: Theme.of(context).colorScheme.primary,
442 ),
443 ),
444 ],
445 SizedBox(height: 25),
446 AddressTextField(
447 controller: _contractAddressController,
448 focusNode: _contractAddressFocusNode,
449 placeholder: S.of(context).contract_address,
450 copyImagePath: 'assets/images/copy.png',
451 options: [AddressTextFieldOption.paste],
452 validator: widget.homeSettingsViewModel.walletType == WalletType.zano
453 ? null
454 : AddressValidator(type: widget.homeSettingsViewModel.nativeToken).call,
455 onPushPasteButton: (_) {
456 _pasteText();
457 },
458 ),
459 const SizedBox(height: 16),
460 isEditingToken
461 ? WarningBox(
462 padding: EdgeInsets.all(16),
463 content: S.of(context).tokens_strong_warning,
464 showBorder: false,
465 textWeight: FontWeight.w500,
466 textColor: context.customColors.warningOutlineColor,
467 showIcon: false,
468 )
469 : Text(
470 S.of(context).tokens_strong_warning,
471 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
472 fontWeight: FontWeight.w500,
473 color: Theme.of(context).colorScheme.onSurface,
474 ),
475 ),
476 const SizedBox(height: 24),
477 Divider(
478 color: Theme.of(context).colorScheme.surfaceContainerHighest,
479 height: 1,
480 ),
481 const SizedBox(height: 24),
482 BaseTextFormField(
483 controller: _tokenNameController,
484 focusNode: _tokenNameFocusNode,
485 onSubmit: (_) => FocusScope.of(context).requestFocus(_tokenSymbolFocusNode),
486 textInputAction: TextInputAction.next,
487 hintText: S.of(context).token_name,
488 validator: (text) {
489 if (text?.isNotEmpty ?? false) {
490 return null;
491 }
492
493 return S.of(context).field_required;
494 },
495 ),
496 const SizedBox(height: 10),
497 BaseTextFormField(
498 controller: _tokenSymbolController,
499 focusNode: _tokenSymbolFocusNode,
500 onSubmit: (_) => FocusScope.of(context).requestFocus(_tokenDecimalFocusNode),
501 textInputAction: TextInputAction.next,
502 hintText: S.of(context).token_symbol,
503 validator: (text) {
504 if (text?.isNotEmpty ?? false) {
505 return null;
506 }
507
508 return S.of(context).field_required;
509 },
510 ),
511 const SizedBox(height: 10),
512 BaseTextFormField(
513 controller: _tokenDecimalController,
514 focusNode: _tokenDecimalFocusNode,
515 textInputAction: TextInputAction.done,
516 hintText: S.of(context).token_decimal,
517 validator: (text) {
518 if (text?.isEmpty ?? true) {
519 return S.of(context).field_required;
520 }
521
522 if (int.tryParse(text!) == null) {
523 return S.of(context).invalid_input;
524 }
525
526 if (int.tryParse(text) == 0) {
527 return S.current.decimals_cannot_be_zero;
528 }
529
530 return null;
531 },
532 ),
533 SizedBox(height: 24),
534 ],
535 ),
536 );
537 }