-
Notifications
You must be signed in to change notification settings - Fork 5k
/
send.js
1463 lines (1389 loc) · 53.3 KB
/
send.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import abi from 'human-standard-token-abi';
import contractMap from '@metamask/contract-metadata';
import BigNumber from 'bignumber.js';
import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util';
import { debounce } from 'lodash';
import {
conversionGreaterThan,
conversionUtil,
multiplyCurrencies,
subtractCurrencies,
} from '../../helpers/utils/conversion-util';
import { GAS_LIMITS } from '../../../shared/constants/gas';
import {
CONTRACT_ADDRESS_ERROR,
INSUFFICIENT_FUNDS_ERROR,
INSUFFICIENT_TOKENS_ERROR,
INVALID_RECIPIENT_ADDRESS_ERROR,
INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR,
KNOWN_RECIPIENT_ADDRESS_WARNING,
MIN_GAS_LIMIT_HEX,
NEGATIVE_ETH_ERROR,
} from '../../pages/send/send.constants';
import {
addGasBuffer,
calcGasTotal,
generateTokenTransferData,
isBalanceSufficient,
isTokenBalanceSufficient,
} from '../../pages/send/send.utils';
import {
getAddressBookEntry,
getAdvancedInlineGasShown,
getCurrentChainId,
getGasPriceInHexWei,
getIsMainnet,
getSelectedAddress,
getTargetAccount,
} from '../../selectors';
import {
displayWarning,
estimateGas,
hideLoadingIndication,
showConfTxPage,
showLoadingIndication,
updateTokenType,
updateTransaction,
} from '../../store/actions';
import {
fetchBasicGasEstimates,
setCustomGasLimit,
BASIC_ESTIMATE_STATES,
} from '../gas/gas.duck';
import {
SET_BASIC_GAS_ESTIMATE_DATA,
BASIC_GAS_ESTIMATE_STATUS,
} from '../gas/gas-action-constants';
import {
QR_CODE_DETECTED,
SELECTED_ACCOUNT_CHANGED,
ACCOUNT_CHANGED,
ADDRESS_BOOK_UPDATED,
} from '../../store/actionConstants';
import {
calcTokenAmount,
getTokenAddressParam,
getTokenValueParam,
} from '../../helpers/utils/token-util';
import {
checkExistingAddresses,
isDefaultMetaMaskChain,
isOriginContractAddress,
isValidDomainName,
} from '../../helpers/utils/util';
import { getTokens, getUnapprovedTxs } from '../metamask/metamask';
import { resetResolution } from '../ens';
import {
isBurnAddress,
isValidHexAddress,
} from '../../../shared/modules/hexstring-utils';
// typedefs
/**
* @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction
*/
const name = 'send';
/**
* The Stages that the send slice can be in
* 1. UNINITIALIZED - The send state is idle, and hasn't yet fetched required
* data for gasPrice and gasLimit estimations, etc.
* 2. ADD_RECIPIENT - The user is selecting which address to send an asset to
* 3. DRAFT - The send form is shown for a transaction yet to be sent to the
* Transaction Controller.
* 4. EDIT - The send form is shown for a transaction already submitted to the
* Transaction Controller but not yet confirmed. This happens when a
* confirmation is shown for a transaction and the 'edit' button in the header
* is clicked.
*/
export const SEND_STAGES = {
INACTIVE: 'INACTIVE',
ADD_RECIPIENT: 'ADD_RECIPIENT',
DRAFT: 'DRAFT',
EDIT: 'EDIT',
};
/**
* The status that the send slice can be in is either
* 1. VALID - the transaction is valid and can be submitted
* 2. INVALID - the transaction is invalid and cannot be submitted
*
* A number of cases would result in an invalid form
* 1. The recipient is not yet defined
* 2. The amount + gasTotal is greater than the user's balance when sending
* native currency
* 3. The gasTotal is greater than the user's *native* balance
* 4. The amount of sent asset is greater than the user's *asset* balance
* 5. Gas price estimates failed to load entirely
* 6. The gasLimit is less than 21000 (0x5208)
*/
export const SEND_STATUSES = {
VALID: 'VALID',
INVALID: 'INVALID',
};
/**
* Controls what is displayed in the send-gas-row component.
* 1. BASIC - Shows the basic estimate slow/avg/fast buttons when on mainnet
* and the metaswaps API request is successful.
* 2. INLINE - Shows inline gasLimit/gasPrice fields when on any other network
* or metaswaps API fails and we use eth_gasPrice
* 3. CUSTOM - Shows GasFeeDisplay component that is a read only display of the
* values the user has set in the advanced gas modal (stored in the gas duck
* under the customData key).
*/
export const GAS_INPUT_MODES = {
BASIC: 'BASIC',
INLINE: 'INLINE',
CUSTOM: 'CUSTOM',
};
/**
* The types of assets that a user can send
* 1. NATIVE - The native asset for the current network, such as ETH
* 2. TOKEN - An ERC20 token.
*/
export const ASSET_TYPES = {
NATIVE: 'NATIVE',
TOKEN: 'TOKEN',
};
/**
* The modes that the amount field can be set by
* 1. INPUT - the user provides the amount by typing in the field
* 2. MAX - The user selects the MAX button and amount is calculated based on
* balance - (amount + gasTotal)
*/
export const AMOUNT_MODES = {
INPUT: 'INPUT',
MAX: 'MAX',
};
export const RECIPIENT_SEARCH_MODES = {
MY_ACCOUNTS: 'MY_ACCOUNTS',
CONTACT_LIST: 'CONTACT_LIST',
};
async function estimateGasLimitForSend({
selectedAddress,
value,
gasPrice,
sendToken,
to,
data,
...options
}) {
// blockGasLimit may be a falsy, but defined, value when we receive it from
// state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX.
const blockGasLimit = options.blockGasLimit || MIN_GAS_LIMIT_HEX;
// The parameters below will be sent to our background process to estimate
// how much gas will be used for a transaction. That background process is
// located in tx-gas-utils.js in the transaction controller folder.
const paramsForGasEstimate = { from: selectedAddress, value, gasPrice };
if (sendToken) {
if (!to) {
// if no to address is provided, we cannot generate the token transfer
// hexData. hexData in a transaction largely dictates how much gas will
// be consumed by a transaction. We must use our best guess, which is
// represented in the gas shared constants.
return GAS_LIMITS.BASE_TOKEN_ESTIMATE;
}
paramsForGasEstimate.value = '0x0';
// We have to generate the erc20 contract call to transfer tokens in
// order to get a proper estimate for gasLimit.
paramsForGasEstimate.data = generateTokenTransferData({
toAddress: to,
amount: value,
sendToken,
});
paramsForGasEstimate.to = sendToken.address;
} else {
if (!data) {
// eth.getCode will return the compiled smart contract code at the
// address. If this returns 0x, 0x0 or a nullish value then the address
// is an externally owned account (NOT a contract account). For these
// types of transactions the gasLimit will always be 21,000 or 0x5208
const contractCode = Boolean(to) && (await global.eth.getCode(to));
// Geth will return '0x', and ganache-core v2.2.1 will return '0x0'
const contractCodeIsEmpty =
!contractCode || contractCode === '0x' || contractCode === '0x0';
if (contractCodeIsEmpty) {
return GAS_LIMITS.SIMPLE;
}
}
paramsForGasEstimate.data = data;
if (to) {
paramsForGasEstimate.to = to;
}
if (!value || value === '0') {
// TODO: Figure out what's going on here. According to eth_estimateGas
// docs this value can be zero, or undefined, yet we are setting it to a
// value here when the value is undefined or zero. For more context:
// https://github.com/MetaMask/metamask-extension/pull/6195
paramsForGasEstimate.value = '0xff';
}
}
// If we do not yet have a gasLimit, we must call into our background
// process to get an estimate for gasLimit based on known parameters.
paramsForGasEstimate.gas = addHexPrefix(
multiplyCurrencies(blockGasLimit, 0.95, {
multiplicandBase: 16,
multiplierBase: 10,
roundDown: '0',
toNumericBase: 'hex',
}),
);
try {
// call into the background process that will simulate transaction
// execution on the node and return an estimate of gasLimit
const estimatedGasLimit = await estimateGas(paramsForGasEstimate);
const estimateWithBuffer = addGasBuffer(
estimatedGasLimit,
blockGasLimit,
1.5,
);
return addHexPrefix(estimateWithBuffer);
} catch (error) {
const simulationFailed =
error.message.includes('Transaction execution error.') ||
error.message.includes(
'gas required exceeds allowance or always failing transaction',
);
if (simulationFailed) {
const estimateWithBuffer = addGasBuffer(
paramsForGasEstimate.gas,
blockGasLimit,
1.5,
);
return addHexPrefix(estimateWithBuffer);
}
throw error;
}
}
export async function getERC20Balance(token, accountAddress) {
const contract = global.eth.contract(abi).at(token.address);
const usersToken = (await contract.balanceOf(accountAddress)) ?? null;
if (!usersToken) {
return '0x0';
}
const amount = calcTokenAmount(
usersToken.balance.toString(),
token.decimals,
).toString(16);
return addHexPrefix(amount);
}
// After modification of specific fields in specific circumstances we must
// recompute the gasLimit estimate to be as accurate as possible. the cases
// that necessitate this logic are listed below:
// 1. when the amount sent changes when sending a token due to the amount being
// part of the hex encoded data property of the transaction.
// 2. when updating the data property while sending NATIVE currency (ex: ETH)
// because the data parameter defines function calls that the EVM will have
// to execute which is where a large chunk of gas is potentially consumed.
// 3. when the recipient changes while sending a token due to the recipient's
// address being included in the hex encoded data property of the
// transaction
// 4. when the asset being sent changes due to the contract address and details
// of the token being included in the hex encoded data property of the
// transaction. If switching to NATIVE currency (ex: ETH), the gasLimit will
// change due to hex data being removed (unless supplied by user).
// This method computes the gasLimit estimate which is written to state in an
// action handler in extraReducers.
export const computeEstimatedGasLimit = createAsyncThunk(
'send/computeEstimatedGasLimit',
async (_, thunkApi) => {
const { send, metamask } = thunkApi.getState();
if (send.stage !== SEND_STAGES.EDIT) {
const gasLimit = await estimateGasLimitForSend({
gasPrice: send.gas.gasPrice,
blockGasLimit: metamask.blockGasLimit,
selectedAddress: metamask.selectedAddress,
sendToken: send.asset.details,
to: send.recipient.address?.toLowerCase(),
value: send.amount.value,
data: send.draftTransaction.userInputHexData,
});
await thunkApi.dispatch(setCustomGasLimit(gasLimit));
return {
gasLimit,
};
}
return null;
},
);
/**
* Responsible for initializing required state for the send slice.
* This method is dispatched from the send page in the componentDidMount
* method. It is also dispatched anytime the network changes to ensure that
* the slice remains valid with changing token and account balances. To do so
* it keys into state to get necessary values and computes a starting point for
* the send slice. It returns the values that might change from this action and
* those values are written to the slice in the `initializeSendState.fulfilled`
* action handler.
*/
export const initializeSendState = createAsyncThunk(
'send/initializeSendState',
async (_, thunkApi) => {
const state = thunkApi.getState();
const {
send: { asset, stage, recipient, amount, draftTransaction },
metamask,
} = state;
// First determine the correct from address. For new sends this is always
// the currently selected account and switching accounts switches the from
// address. If editing an existing transaction (by clicking 'edit' on the
// send page), the fromAddress is always the address from the txParams.
const fromAddress =
stage === SEND_STAGES.EDIT
? draftTransaction.txParams.from
: metamask.selectedAddress;
// We need the account's balance which is calculated from cachedBalances in
// the getMetaMaskAccounts selector. getTargetAccount consumes this
// selector and returns the account at the specified address.
const account = getTargetAccount(state, fromAddress);
// Initiate gas slices work to fetch gasPrice estimates. We need to get the
// new state after this is set to determine if initialization can proceed.
await thunkApi.dispatch(fetchBasicGasEstimates());
const {
gas: { basicEstimateStatus, basicEstimates },
} = thunkApi.getState();
// Default gasPrice to 1 gwei if all estimation fails
const gasPrice =
basicEstimateStatus === BASIC_ESTIMATE_STATES.READY
? getGasPriceInHexWei(basicEstimates.average)
: '0x1';
// Set a basic gasLimit in the event that other estimation fails
let gasLimit =
asset.type === ASSET_TYPES.TOKEN
? GAS_LIMITS.BASE_TOKEN_ESTIMATE
: GAS_LIMITS.SIMPLE;
if (
basicEstimateStatus === BASIC_ESTIMATE_STATES.READY &&
stage !== SEND_STAGES.EDIT
) {
// Run our estimateGasLimit logic to get a more accurate estimation of
// required gas. If this value isn't nullish, set it as the new gasLimit
const estimatedGasLimit = await estimateGasLimitForSend({
gasPrice: getGasPriceInHexWei(basicEstimates.average),
blockGasLimit: metamask.blockGasLimit,
selectedAddress: fromAddress,
sendToken: asset.details,
to: recipient.address.toLowerCase(),
value: amount.value,
data: draftTransaction.userInputHexData,
});
gasLimit = estimatedGasLimit || gasLimit;
}
// We have to keep the gas slice in sync with the draft send transaction
// so that it'll be initialized correctly if the gas modal is opened.
await thunkApi.dispatch(setCustomGasLimit(gasLimit));
// We must determine the balance of the asset that the transaction will be
// sending. This is done by referencing the native balance on the account
// for native assets, and calling the balanceOf method on the ERC20
// contract for token sends.
let { balance } = account;
if (asset.type === ASSET_TYPES.TOKEN) {
if (asset.details === null) {
// If we're sending a token but details have not been provided we must
// abort and set the send slice into invalid status.
throw new Error(
'Send slice initialized as token send without token details',
);
}
balance = await getERC20Balance(asset.details, fromAddress);
}
return {
address: fromAddress,
nativeBalance: account.balance,
assetBalance: balance,
chainId: getCurrentChainId(state),
tokens: getTokens(state),
gasPrice,
gasLimit,
gasTotal: addHexPrefix(calcGasTotal(gasLimit, gasPrice)),
};
},
);
export const initialState = {
// which stage of the send flow is the user on
stage: SEND_STAGES.UNINITIALIZED,
// status of the send slice, either VALID or INVALID
status: SEND_STATUSES.VALID,
account: {
// from account address, defaults to selected account. will be the account
// the original transaction was sent from in the case of the EDIT stage
address: null,
// balance of the from account
balance: '0x0',
},
gas: {
// indicate whether the gas estimate is loading
isGasEstimateLoading: true,
// has the user set custom gas in the custom gas modal
isCustomGasSet: false,
// maximum gas needed for tx
gasLimit: '0x0',
// price in gwei to pay per gas
gasPrice: '0x0',
// maximum total price in gwei to pay
gasTotal: '0x0',
// minimum supported gasLimit
minimumGasLimit: GAS_LIMITS.SIMPLE,
// error to display for gas fields
error: null,
},
amount: {
// The mode to use when determining new amounts. For INPUT mode the
// provided payload is always used. For MAX it is calculated based on avail
// asset balance
mode: AMOUNT_MODES.INPUT,
// Current value of the transaction, how much of the asset are we sending
value: '0x0',
// error to display for amount field
error: null,
},
asset: {
// type can be either NATIVE such as ETH or TOKEN for ERC20 tokens
type: ASSET_TYPES.NATIVE,
// the balance the user holds at the from address for this asset
balance: '0x0',
// In the case of tokens, the address, decimals and symbol of the token
// will be included in details
details: null,
},
draftTransaction: {
// The metamask internal id of the transaction. Only populated in the EDIT
// stage.
id: null,
// The hex encoded data provided by the user who has enabled hex data field
// in advanced settings
userInputHexData: null,
// The txParams that should be submitted to the network once this
// transaction is confirmed. This object is computed on every write to the
// slice of fields that would result in the txParams changing
txParams: {
to: '',
from: '',
data: undefined,
value: '0x0',
gas: '0x0',
gasPrice: '0x0',
},
},
recipient: {
// Defines which mode to use for searching for matches in the input field
mode: RECIPIENT_SEARCH_MODES.CONTACT_LIST,
// Partial, not yet validated, entry into the address field. Used to share
// user input amongst the AddRecipient and EnsInput components.
userInput: '',
// The address of the recipient
address: '',
// The nickname stored in the user's address book for the recipient address
nickname: '',
// Error to display on the address field
error: null,
// Warning to display on the address field
warning: null,
},
};
const slice = createSlice({
name,
initialState,
reducers: {
/**
* update current amount.value in state and run post update validation of
* the amount field and the send state. Recomputes the draftTransaction
*/
updateSendAmount: (state, action) => {
state.amount.value = addHexPrefix(action.payload);
// Once amount has changed, validate the field
slice.caseReducers.validateAmountField(state);
// validate send state
slice.caseReducers.validateSendState(state);
},
/**
* computes the maximum amount of asset that can be sent and then calls
* the updateSendAmount action above with the computed value, which will
* revalidate the field and form and recomputes the draftTransaction
*/
updateAmountToMax: (state) => {
let amount = '0x0';
if (state.asset.type === ASSET_TYPES.TOKEN) {
const decimals = state.asset.details?.decimals ?? 0;
const multiplier = Math.pow(10, Number(decimals));
amount = multiplyCurrencies(state.asset.balance, multiplier, {
toNumericBase: 'hex',
multiplicandBase: 16,
multiplierBase: 10,
});
} else {
amount = subtractCurrencies(
addHexPrefix(state.asset.balance),
addHexPrefix(state.gas.gasTotal),
{
toNumericBase: 'hex',
aBase: 16,
bBase: 16,
},
);
}
slice.caseReducers.updateSendAmount(state, {
payload: amount,
});
// draftTransaction update happens in updateSendAmount
},
/**
* updates the draftTransaction.userInputHexData state key and then
* recomputes the draftTransaction if the user is currently sending the
* native asset. When sending ERC20 assets, this is unnecessary because the
* hex data used in the transaction will be that for interacting with the
* ERC20 contract
*/
updateUserInputHexData: (state, action) => {
state.draftTransaction.userInputHexData = action.payload;
if (state.asset.type === ASSET_TYPES.NATIVE) {
slice.caseReducers.updateDraftTransaction(state);
}
},
/**
* Initiates the edit transaction flow by setting the stage to 'EDIT' and
* then pulling the details of the previously submitted transaction from
* the action payload. It also computes a new draftTransaction that will be
* used when updating the transaction in the provider
*/
editTransaction: (state, action) => {
state.stage = SEND_STAGES.EDIT;
state.gas.gasLimit = action.payload.gasLimit;
state.gas.gasPrice = action.payload.gasPrice;
state.amount.value = action.payload.amount;
state.gas.error = null;
state.amount.error = null;
state.recipient.address = action.payload.address;
state.recipient.nickname = action.payload.nickname;
state.draftTransaction.id = action.payload.id;
state.draftTransaction.txParams.from = action.payload.from;
slice.caseReducers.updateDraftTransaction(state);
},
/**
* gasTotal is computed based on gasPrice and gasLimit and set in state
* recomputes the maximum amount if the current amount mode is 'MAX' and
* sending the native token. ERC20 assets max amount is unaffected by
* gasTotal so does not need to be recomputed. Finally, validates the gas
* field and send state, then updates the draft transaction.
*/
calculateGasTotal: (state) => {
state.gas.gasTotal = addHexPrefix(
calcGasTotal(state.gas.gasLimit, state.gas.gasPrice),
);
if (
state.amount.mode === AMOUNT_MODES.MAX &&
state.asset.type === ASSET_TYPES.NATIVE
) {
slice.caseReducers.updateAmountToMax(state);
}
slice.caseReducers.validateAmountField(state);
slice.caseReducers.validateGasField(state);
// validate send state
slice.caseReducers.validateSendState(state);
},
/**
* sets the provided gasLimit in state and then recomputes the gasTotal.
*/
updateGasLimit: (state, action) => {
state.gas.gasLimit = addHexPrefix(action.payload);
slice.caseReducers.calculateGasTotal(state);
},
/**
* sets the provided gasPrice in state and then recomputes the gasTotal
*/
updateGasPrice: (state, action) => {
state.gas.gasPrice = addHexPrefix(action.payload);
slice.caseReducers.calculateGasTotal(state);
},
/**
* sets the amount mode to the provided value as long as it is one of the
* supported modes (MAX|INPUT)
*/
updateAmountMode: (state, action) => {
if (Object.values(AMOUNT_MODES).includes(action.payload)) {
state.amount.mode = action.payload;
}
},
updateAsset: (state, action) => {
state.asset.type = action.payload.type;
state.asset.balance = action.payload.balance;
if (state.asset.type === ASSET_TYPES.TOKEN) {
state.asset.details = action.payload.details;
} else {
// clear the details object when sending native currency
state.asset.details = null;
if (state.recipient.error === CONTRACT_ADDRESS_ERROR) {
// Errors related to sending tokens to their own contract address
// are no longer valid when sending native currency.
state.recipient.error = null;
}
if (state.recipient.warning === KNOWN_RECIPIENT_ADDRESS_WARNING) {
// Warning related to sending tokens to a known contract address
// are no longer valid when sending native currency.
state.recipient.warning = null;
}
}
// if amount mode is MAX update amount to max of new asset, otherwise set
// to zero. This will revalidate the send amount field.
if (state.amount.mode === AMOUNT_MODES.MAX) {
slice.caseReducers.updateAmountToMax(state);
} else {
slice.caseReducers.updateSendAmount(state, { payload: '0x0' });
}
// validate send state
slice.caseReducers.validateSendState(state);
},
updateRecipient: (state, action) => {
state.recipient.error = null;
state.recipient.userInput = '';
state.recipient.address = action.payload.address ?? '';
state.recipient.nickname = action.payload.nickname ?? '';
if (state.recipient.address === '') {
// If address is null we are clearing the recipient and must return
// to the ADD_RECIPIENT stage.
state.stage = SEND_STAGES.ADD_RECIPIENT;
} else {
// if and address is provided and an id exists on the draft transaction,
// we progress to the EDIT stage, otherwise we progress to the DRAFT
// stage. We also reset the search mode for recipient search.
state.stage =
state.draftTransaction.id === null
? SEND_STAGES.DRAFT
: SEND_STAGES.EDIT;
state.recipient.mode = RECIPIENT_SEARCH_MODES.CONTACT_LIST;
}
// validate send state
slice.caseReducers.validateSendState(state);
},
updateDraftTransaction: (state) => {
// We keep a copy of txParams in state that could be submitted to the
// network if the form state is valid.
if (state.status === SEND_STATUSES.VALID) {
state.draftTransaction.txParams.from = state.account.address;
switch (state.asset.type) {
case ASSET_TYPES.TOKEN:
// When sending a token the to address is the contract address of
// the token being sent. The value is set to '0x0' and the data
// is generated from the recipient address, token being sent and
// amount.
state.draftTransaction.txParams.to = state.asset.details.address;
state.draftTransaction.txParams.value = '0x0';
state.draftTransaction.txParams.gas = state.gas.gasLimit;
state.draftTransaction.txParams.gasPrice = state.gas.gasPrice;
state.draftTransaction.txParams.data = generateTokenTransferData({
toAddress: state.recipient.address,
amount: state.amount.value,
sendToken: state.asset.details,
});
break;
case ASSET_TYPES.NATIVE:
default:
// When sending native currency the to and value fields use the
// recipient and amount values and the data key is either null or
// populated with the user input provided in hex field.
state.draftTransaction.txParams.to = state.recipient.address;
state.draftTransaction.txParams.value = state.amount.value;
state.draftTransaction.txParams.gas = state.gas.gasLimit;
state.draftTransaction.txParams.gasPrice = state.gas.gasPrice;
state.draftTransaction.txParams.data =
state.draftTransaction.userInputHexData ?? undefined;
}
}
},
useDefaultGas: (state) => {
// Show the default gas price/limit fields in the send page
state.gas.isCustomGasSet = false;
},
useCustomGas: (state) => {
// Show the gas fees set in the custom gas modal (state.gas.customData)
state.gas.isCustomGasSet = true;
},
updateRecipientUserInput: (state, action) => {
// Update the value in state to match what the user is typing into the
// input field
state.recipient.userInput = action.payload;
},
validateRecipientUserInput: (state, action) => {
const { asset, recipient } = state;
if (
recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS ||
recipient.userInput === '' ||
recipient.userInput === null
) {
recipient.error = null;
recipient.warning = null;
} else {
const isSendingToken = asset.type === ASSET_TYPES.TOKEN;
const { chainId, tokens } = action.payload;
if (
isBurnAddress(recipient.userInput) ||
(!isValidHexAddress(recipient.userInput, {
mixedCaseUseChecksum: true,
}) &&
!isValidDomainName(recipient.userInput))
) {
recipient.error = isDefaultMetaMaskChain(chainId)
? INVALID_RECIPIENT_ADDRESS_ERROR
: INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR;
} else if (
isSendingToken &&
isOriginContractAddress(recipient.userInput, asset.details.address)
) {
recipient.error = CONTRACT_ADDRESS_ERROR;
} else {
recipient.error = null;
}
if (
isSendingToken &&
(toChecksumAddress(recipient.userInput) in contractMap ||
checkExistingAddresses(recipient.userInput, tokens))
) {
recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING;
} else {
recipient.warning = null;
}
}
},
updateRecipientSearchMode: (state, action) => {
state.recipient.userInput = '';
state.recipient.mode = action.payload;
},
resetSendState: () => initialState,
validateAmountField: (state) => {
switch (true) {
// set error to INSUFFICIENT_FUNDS_ERROR if the account balance is lower
// than the total price of the transaction inclusive of gas fees.
case state.asset.type === ASSET_TYPES.NATIVE &&
!isBalanceSufficient({
amount: state.amount.value,
balance: state.asset.balance,
gasTotal: state.gas.gasTotal ?? '0x0',
}):
state.amount.error = INSUFFICIENT_FUNDS_ERROR;
break;
// set error to INSUFFICIENT_FUNDS_ERROR if the token balance is lower
// than the amount of token the user is attempting to send.
case state.asset.type === ASSET_TYPES.TOKEN &&
!isTokenBalanceSufficient({
tokenBalance: state.asset.balance ?? '0x0',
amount: state.amount.value,
decimals: state.asset.details.decimals,
}):
state.amount.error = INSUFFICIENT_TOKENS_ERROR;
break;
// if the amount is negative, set error to NEGATIVE_ETH_ERROR
// TODO: change this to NEGATIVE_ERROR and remove the currency bias.
case conversionGreaterThan(
{ value: 0, fromNumericBase: 'dec' },
{ value: state.amount.value, fromNumericBase: 'hex' },
):
state.amount.error = NEGATIVE_ETH_ERROR;
break;
// If none of the above are true, set error to null
default:
state.amount.error = null;
}
},
validateGasField: (state) => {
// Checks if the user has enough funds to cover the cost of gas, always
// uses the native currency and does not take into account the amount
// being sent. If the user has enough to cover cost of gas but not gas
// + amount then the error will be displayed on the amount field.
const insufficientFunds = !isBalanceSufficient({
amount:
state.asset.type === ASSET_TYPES.NATIVE ? state.amount.value : '0x0',
balance: state.account.balance,
gasTotal: state.gas.gasTotal ?? '0x0',
});
state.gas.error = insufficientFunds ? INSUFFICIENT_FUNDS_ERROR : null;
},
validateSendState: (state) => {
switch (true) {
// 1 + 2. State is invalid when either gas or amount fields have errors
// 3. State is invalid if asset type is a token and the token details
// are unknown.
// 4. State is invalid if no recipient has been added
// 5. State is invalid if the send state is uninitialized
// 6. State is invalid if gas estimates are loading
// 7. State is invalid if gasLimit is less than the minimumGasLimit
// 8. State is invalid if the selected asset is a ERC721
case Boolean(state.amount.error):
case Boolean(state.gas.error):
case state.asset.type === ASSET_TYPES.TOKEN &&
state.asset.details === null:
case state.stage === SEND_STAGES.ADD_RECIPIENT:
case state.stage === SEND_STAGES.UNINITIALIZED:
case state.gas.isGasEstimateLoading:
case new BigNumber(state.gas.gasLimit, 16).lessThan(
new BigNumber(state.gas.minimumGasLimit),
):
state.status = SEND_STATUSES.INVALID;
break;
case state.asset.type === ASSET_TYPES.TOKEN &&
state.asset.details.isERC721:
state.state = SEND_STATUSES.INVALID;
break;
default:
state.status = SEND_STATUSES.VALID;
// Recompute the draftTransaction object
slice.caseReducers.updateDraftTransaction(state);
}
},
},
extraReducers: (builder) => {
builder
.addCase(QR_CODE_DETECTED, (state, action) => {
// When data is received from the QR Code Scanner we set the recipient
// as long as a valid address can be pulled from the data. If an
// address is pulled but it is invalid, we display an error.
const qrCodeData = action.value;
if (qrCodeData) {
if (qrCodeData.type === 'address') {
const scannedAddress = qrCodeData.values.address.toLowerCase();
if (
isValidHexAddress(scannedAddress, { allowNonPrefixed: false })
) {
if (state.recipient.address !== scannedAddress) {
slice.caseReducers.updateRecipient(state, {
payload: { address: scannedAddress },
});
}
} else {
state.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR;
}
}
}
})
.addCase(SELECTED_ACCOUNT_CHANGED, (state, action) => {
// If we are on the edit flow the account we are keyed into will be the
// original 'from' account, which may differ from the selected account
if (state.stage !== SEND_STAGES.EDIT) {
// This event occurs when the user selects a new account from the
// account menu, or the currently active account's balance updates.
state.account.balance = action.payload.account.balance;
state.account.address = action.payload.account.address;
// We need to update the asset balance if the asset is the native
// network asset. Once we update the balance we recompute error state.
if (state.asset.type === ASSET_TYPES.NATIVE) {
state.asset.balance = action.payload.account.balance;
}
slice.caseReducers.validateAmountField(state);
slice.caseReducers.validateGasField(state);
slice.caseReducers.validateSendState(state);
}
})
.addCase(ACCOUNT_CHANGED, (state, action) => {
// If we are on the edit flow then we need to watch for changes to the
// current account.address in state and keep balance updated
// appropriately
if (
state.stage === SEND_STAGES.EDIT &&
action.payload.account.address === state.account.address
) {
// This event occurs when the user's account details update due to
// background state changes. If the account that is being updated is
// the current from account on the edit flow we need to update
// the balance for the account and revalidate the send state.
state.account.balance = action.payload.account.balance;
// We need to update the asset balance if the asset is the native
// network asset. Once we update the balance we recompute error state.
if (state.asset.type === ASSET_TYPES.NATIVE) {
state.asset.balance = action.payload.account.balance;
}
slice.caseReducers.validateAmountField(state);
slice.caseReducers.validateGasField(state);
slice.caseReducers.validateSendState(state);
}
})
.addCase(ADDRESS_BOOK_UPDATED, (state, action) => {
// When the address book updates from background state changes we need
// to check to see if an entry exists for the current address or if the
// entry changed.
const { addressBook } = action.payload;
if (addressBook[state.recipient.address]?.name) {
state.recipient.nickname = addressBook[state.recipient.address].name;
}
})
.addCase(initializeSendState.pending, (state) => {
// when we begin initializing state, which can happen when switching
// chains even after loading the send flow, we set
// gas.isGasEstimateLoading as initialization will trigger a fetch
// for gasPrice estimates.
state.gas.isGasEstimateLoading = true;
})
.addCase(initializeSendState.fulfilled, (state, action) => {
// writes the computed initialized state values into the slice and then
// calculates slice validity using the caseReducers.
state.account.address = action.payload.address;
state.account.balance = action.payload.nativeBalance;
state.asset.balance = action.payload.assetBalance;
state.gas.gasLimit = action.payload.gasLimit;
state.gas.gasPrice = action.payload.gasPrice;
state.gas.gasTotal = action.payload.gasTotal;
if (state.stage !== SEND_STAGES.UNINITIALIZED) {
slice.caseReducers.validateRecipientUserInput(state, {
payload: {
chainId: action.payload.chainId,
tokens: action.payload.tokens,
},
});
}
state.stage =
state.stage === SEND_STAGES.UNINITIALIZED
? SEND_STAGES.ADD_RECIPIENT
: state.stage;
slice.caseReducers.validateAmountField(state);
slice.caseReducers.validateGasField(state);
slice.caseReducers.validateSendState(state);
})
.addCase(computeEstimatedGasLimit.pending, (state) => {
// When we begin to fetch gasLimit we should indicate we are loading
// a gas estimate.
state.gas.isGasEstimateLoading = true;
})
.addCase(computeEstimatedGasLimit.fulfilled, (state, action) => {
// When we receive a new gasLimit from the computeEstimatedGasLimit
// thunk we need to update our gasLimit in the slice. We call into the
// caseReducer updateGasLimit to tap into the appropriate follow up
// checks and gasTotal calculation.
if (action.payload?.gasLimit) {
slice.caseReducers.updateGasLimit(state, {
payload: action.payload.gasLimit,
});
}
})
.addCase(SET_BASIC_GAS_ESTIMATE_DATA, (state, action) => {
// When we receive a new gasPrice via the gas duck we need to update
// the gasPrice in our slice. We call into the caseReducer
// updateGasPrice to also tap into the appropriate follow up checks
// and gasTotal calculation.
slice.caseReducers.updateGasPrice(state, {
payload: getGasPriceInHexWei(action.value.average),
});
})
.addCase(BASIC_GAS_ESTIMATE_STATUS, (state, action) => {
// When we fetch gas prices we should temporarily set the form invalid
// Once the price updates we get that value in the
// SET_BASIC_GAS_ESTIMATE_DATA extraReducer above. Finally as long as
// the state is 'READY' we will revalidate the form.
switch (action.value) {
case BASIC_ESTIMATE_STATES.FAILED:
state.status = SEND_STATUSES.INVALID;
state.gas.isGasEstimateLoading = true;
break;
case BASIC_ESTIMATE_STATES.LOADING: