From 41f8ab8966b2f8f3b920c51bb5e67de2b214d2c1 Mon Sep 17 00:00:00 2001 From: HenryNguyen5 Date: Fri, 15 Jun 2018 17:10:30 -0400 Subject: [PATCH] Auto token add (#1808) * Add support for decimal and symbol getters * Make custom token form interactive via address lookup * Add balance field, improve error handling * Fix lint errors * Fix erc20 interface * Expand method name * Normalize parameter name * Remove extra variable * Stricten typing for decimals * Use common input field between decimal and symbol fields * use mycrypto-nano-result --- .../TokenBalances/AddCustomTokenForm.tsx | 166 ------------------ .../AddCustomTokenForm.scss | 0 .../AddCustomTokenForm/AddCustomTokenForm.tsx | 119 +++++++++++++ .../AddCustomTokenForm/AddressField.tsx | 58 ++++++ .../AddCustomTokenForm/BalanceField.tsx | 123 +++++++++++++ .../AddCustomTokenForm/DecimalField.tsx | 34 ++++ .../AddCustomTokenForm/FieldInput.tsx | 134 ++++++++++++++ .../AddCustomTokenForm/SymbolField.tsx | 39 ++++ .../TokenBalances/AddCustomTokenForm/index.ts | 1 + .../BalanceSidebar/TokenBalances/Balances.tsx | 2 +- common/libs/erc20.ts | 79 ++++++++- package.json | 1 + yarn.lock | 8 +- 13 files changed, 590 insertions(+), 174 deletions(-) delete mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm.tsx rename common/components/BalanceSidebar/TokenBalances/{ => AddCustomTokenForm}/AddCustomTokenForm.scss (100%) create mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddCustomTokenForm.tsx create mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddressField.tsx create mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/BalanceField.tsx create mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/DecimalField.tsx create mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/FieldInput.tsx create mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/SymbolField.tsx create mode 100644 common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/index.ts diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm.tsx b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm.tsx deleted file mode 100644 index b4b37d29..00000000 --- a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; -import { HELP_ARTICLE } from 'config'; -import { isPositiveIntegerOrZero, isValidETHAddress } from 'libs/validators'; -import translate, { translateRaw } from 'translations'; -import { HelpLink, Input } from 'components/ui'; -import './AddCustomTokenForm.scss'; -import { Token } from 'types/network'; - -interface Props { - allTokens: Token[]; - onSave(params: Token): void; - toggleForm(): void; -} - -interface IGenerateSymbolLookup { - [tokenSymbol: string]: boolean; -} - -interface IGenerateAddressLookup { - [address: string]: boolean; -} - -interface State { - tokenSymbolLookup: IGenerateSymbolLookup; - tokenAddressLookup: IGenerateAddressLookup; - address: string; - symbol: string; - decimal: string; -} - -export default class AddCustomTokenForm extends React.PureComponent { - public state: State = { - tokenSymbolLookup: this.generateSymbolLookup(), - tokenAddressLookup: this.generateAddressMap(), - address: '', - symbol: '', - decimal: '' - }; - - public render() { - const { address, symbol, decimal } = this.state; - const errors = this.getErrors(); - - const fields = [ - { - name: 'symbol', - value: symbol, - label: translateRaw('TOKEN_SYMBOL') - }, - { - name: 'address', - value: address, - label: translateRaw('TOKEN_ADDR') - }, - { - name: 'decimal', - value: decimal, - label: translateRaw('TOKEN_DEC') - } - ]; - - return ( -
- {fields.map(field => { - return ( - - ); - })} - - - {translate('ADD_CUSTOM_TKN_HELP')} - -
- - -
-
- ); - } - - public getErrors() { - const { address, symbol, decimal } = this.state; - const errors: { [key: string]: string } = {}; - - // Formatting errors - if (decimal && !isPositiveIntegerOrZero(Number(decimal))) { - errors.decimal = 'Invalid decimal'; - } - if (address) { - if (!isValidETHAddress(address)) { - errors.address = 'Not a valid address'; - } - if (this.state.tokenAddressLookup[address]) { - errors.address = 'A token with this address already exists'; - } - } - - // Message errors - if (symbol && this.state.tokenSymbolLookup[symbol]) { - errors.symbol = 'A token with this symbol already exists'; - } - - return errors; - } - - public isValid() { - const { address, symbol, decimal } = this.state; - return !Object.keys(this.getErrors()).length && address && symbol && decimal; - } - - public onFieldChange = (e: React.FormEvent) => { - // TODO: typescript bug: https://github.com/Microsoft/TypeScript/issues/13948 - const name: any = e.currentTarget.name; - const value = e.currentTarget.value; - this.setState({ [name]: value }); - }; - - public onSave = (ev: React.FormEvent) => { - ev.preventDefault(); - if (!this.isValid()) { - return; - } - - const { address, symbol, decimal } = this.state; - this.props.onSave({ address, symbol, decimal: parseInt(decimal, 10) }); - }; - - private generateSymbolLookup() { - return this.tknArrToMap('symbol'); - } - - private generateAddressMap() { - return this.tknArrToMap('address'); - } - - private tknArrToMap(key: Exclude) { - const tokens = this.props.allTokens; - return tokens.reduce<{ [k: string]: boolean }>((prev, tk) => { - prev[tk[key]] = true; - return prev; - }, {}); - } -} diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm.scss b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddCustomTokenForm.scss similarity index 100% rename from common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm.scss rename to common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddCustomTokenForm.scss diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddCustomTokenForm.tsx b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddCustomTokenForm.tsx new file mode 100644 index 00000000..10f819cb --- /dev/null +++ b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddCustomTokenForm.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { HELP_ARTICLE } from 'config'; +import { AddressField } from './AddressField'; +import { DecimalField } from './DecimalField'; +import { SymbolField } from './SymbolField'; +import { BalanceField } from './BalanceField'; +import translate from 'translations'; +import { HelpLink } from 'components/ui'; +import './AddCustomTokenForm.scss'; +import { Token } from 'types/network'; +import { Result } from 'mycrypto-nano-result'; + +interface Props { + allTokens: Token[]; + onSave(params: Token): void; + toggleForm(): void; +} + +export interface IGenerateSymbolLookup { + [tokenSymbol: string]: boolean; +} + +export interface IGenerateAddressLookup { + [address: string]: boolean; +} + +interface State { + address: Result; + symbol: Result; + decimal: Result; +} + +export class AddCustomTokenForm extends React.PureComponent { + public state: State = { + address: Result.from({ err: 'This field is empty' }), + symbol: Result.from({ err: 'This field is empty' }), + decimal: Result.from({ err: 'This field is empty' }) + }; + + private tokenSymbolLookup = this.generateSymbolLookup(); + private tokenAddressLookup = this.generateAddressMap(); + + public render() { + const address = this.state.address.toVal().res; + + return ( +
+ + + + + + {translate('ADD_CUSTOM_TKN_HELP')} + +
+ + +
+ + ); + } + + public onSave = (ev: React.FormEvent) => { + ev.preventDefault(); + if (!this.isValid()) { + return; + } + + const { address, symbol, decimal } = this.state; + this.props.onSave({ + address: address.unwrap(), + symbol: symbol.unwrap(), + decimal: parseInt(decimal.unwrap(), 10) + }); + }; + + private handleFieldChange = (fieldName: keyof State) => (res: Result) => { + this.setState({ [fieldName as any]: res }); + }; + + private isValid() { + const { address, decimal, symbol } = this.state; + const valid = address.ok() && decimal.ok() && symbol.ok(); + return valid; + } + + private generateSymbolLookup() { + return this.tokenArrayToMap('symbol'); + } + + private generateAddressMap() { + return this.tokenArrayToMap('address'); + } + + private tokenArrayToMap(key: Exclude) { + const tokens = this.props.allTokens; + return tokens.reduce<{ [k: string]: boolean }>((prev, tk) => { + prev[tk[key]] = true; + return prev; + }, {}); + } +} diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddressField.tsx b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddressField.tsx new file mode 100644 index 00000000..6baf221b --- /dev/null +++ b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/AddressField.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Input } from 'components/ui'; +import { translateRaw } from 'translations'; +import { IGenerateAddressLookup } from './AddCustomTokenForm'; +import { isValidETHAddress } from 'libs/validators'; +import { Result } from 'mycrypto-nano-result'; + +interface OwnProps { + addressLookup: IGenerateAddressLookup; + onChange(address: Result): void; +} + +enum ErrType { + INVALIDADDR = 'Not a valid address', + ADDRTAKEN = 'A token with this address already exists' +} + +interface State { + address: Result; + userInput: string; +} + +export class AddressField extends React.Component { + public state: State = { + address: Result.from({ res: '' }), + userInput: '' + }; + + public render() { + const { userInput, address } = this.state; + + return ( + + ); + } + + private handleFieldChange = (e: React.FormEvent) => { + const userInput = e.currentTarget.value; + const addrTaken = this.props.addressLookup[userInput]; + const validAddr = isValidETHAddress(userInput); + const err = addrTaken ? ErrType.ADDRTAKEN : !validAddr ? ErrType.INVALIDADDR : undefined; + const address: Result = err ? Result.from({ err }) : Result.from({ res: userInput }); + + this.setState({ userInput, address }); + this.props.onChange(address); + }; +} diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/BalanceField.tsx b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/BalanceField.tsx new file mode 100644 index 00000000..beb1a90c --- /dev/null +++ b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/BalanceField.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Input } from 'components/ui'; +import Spinner from 'components/ui/Spinner'; +import ERC20 from 'libs/erc20'; +import { shepherdProvider } from 'libs/nodes'; +import { Result } from 'mycrypto-nano-result'; +import { getWalletInst } from 'selectors/wallet'; +import { connect } from 'react-redux'; +import { AppState } from 'reducers'; + +interface OwnProps { + address?: string; +} + +interface StateProps { + walletInst: ReturnType; +} + +interface State { + balance: Result; + addressToLoad?: string; + loading: boolean; +} + +type Props = OwnProps & StateProps; + +class BalanceFieldClass extends React.Component { + public static getDerivedStateFromProps( + nextProps: OwnProps, + prevState: State + ): Partial | null { + if (nextProps.address && nextProps.address !== prevState.addressToLoad) { + return { loading: true, addressToLoad: nextProps.address }; + } + return null; + } + + public state: State = { + balance: Result.from({ res: '' }), + loading: false + }; + + private currentRequest: Promise | null; + + public componentDidUpdate() { + if (this.state.addressToLoad && this.state.loading) { + this.attemptToLoadBalance(this.state.addressToLoad); + } + } + + public componentWillUnmount() { + if (this.currentRequest) { + this.currentRequest = null; + } + } + public render() { + const { balance, loading } = this.state; + + return ( + + ); + } + + private attemptToLoadBalance(address: string) { + // process request + this.currentRequest = this.loadBalance(address) + // set state on successful request e.g it was not cancelled + // and then also set our current request to null + .then(({ balance }) => + this.setState({ + balance, + loading: false + }) + ) + .catch(e => { + console.error(e); + // if the component is unmounted, then dont call set state + if (!this.currentRequest) { + return; + } + + // otherwise it was a failed fetch call + this.setState({ loading: false }); + }) + .then(() => (this.currentRequest = null)); + } + + private loadBalance(address: string) { + if (!this.props.walletInst) { + return Promise.reject('No wallet found'); + } + + const owner = this.props.walletInst.getAddressString(); + return shepherdProvider + .sendCallRequest({ data: ERC20.balanceOf.encodeInput({ _owner: owner }), to: address }) + .then(ERC20.balanceOf.decodeOutput) + .then(({ balance }) => { + const result = Result.from({ res: balance }); + return { balance: result }; + }); + } +} + +function mapStateToProps(state: AppState): StateProps { + return { walletInst: getWalletInst(state) }; +} + +export const BalanceField = connect(mapStateToProps)(BalanceFieldClass); diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/DecimalField.tsx b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/DecimalField.tsx new file mode 100644 index 00000000..c393b410 --- /dev/null +++ b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/DecimalField.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { isPositiveIntegerOrZero } from 'libs/validators'; +import { translateRaw } from 'translations'; +import { Result } from 'mycrypto-nano-result'; + +import { FieldInput } from './FieldInput'; + +interface OwnProps { + address?: string; + onChange(decimals: Result): void; +} + +export class DecimalField extends React.Component { + public render() { + return ( + !(req.toVal().res === '0')} + address={this.props.address} + userInputValidator={this.isValidUserInput} + onChange={this.props.onChange} + /> + ); + } + + private isValidUserInput = (userInput: string) => { + const validDecimals = isPositiveIntegerOrZero(Number(userInput)); + const decimals: Result = validDecimals + ? Result.from({ res: userInput }) + : Result.from({ err: 'Invalid decimal' }); + return decimals; + }; +} diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/FieldInput.tsx b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/FieldInput.tsx new file mode 100644 index 00000000..65249415 --- /dev/null +++ b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/FieldInput.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { Result } from 'mycrypto-nano-result'; +import { shepherdProvider } from 'libs/nodes'; +import { Input } from 'components/ui'; +import Spinner from 'components/ui/Spinner'; +import ERC20 from 'libs/erc20'; + +interface OwnProps { + fieldToFetch: keyof Pick; + fieldName: string; + address?: string; + userInputValidator(input: string): Result; + fetchedFieldValidator?(input: any): Result; + shouldEnableAutoField(input: Result): boolean; + + onChange(symbol: Result): void; +} + +interface State { + field: Result; + autoField: boolean; + userInput: string; + addressToLoad?: string; + loading: boolean; +} + +export class FieldInput extends React.Component { + public static getDerivedStateFromProps( + nextProps: OwnProps, + prevState: State + ): Partial | null { + if (nextProps.address && nextProps.address !== prevState.addressToLoad) { + return { loading: true, autoField: true, addressToLoad: nextProps.address }; + } + return null; + } + + public state: State = { + userInput: '', + autoField: true, + field: Result.from({ res: '' }), + loading: false + }; + + private currentRequest: Promise | null; + + public componentDidUpdate() { + if (this.state.addressToLoad && this.state.loading) { + this.attemptToLoadField(this.state.addressToLoad); + } + } + + public componentWillUnmount() { + if (this.currentRequest) { + this.currentRequest = null; + } + } + + public render() { + const { userInput, field, autoField, loading } = this.state; + + return ( + + ); + } + + private handleFieldChange = (args: React.FormEvent) => { + const userInput = args.currentTarget.value; + const field = this.props.userInputValidator(userInput); + this.setState({ userInput, field }); + this.props.onChange(field); + }; + + private attemptToLoadField(address: string) { + // process request + this.currentRequest = this.loadField(address) + // set state on successful request e.g it was not cancelled + // and then also set our current request to null + .then(({ [this.props.fieldToFetch]: field }) => + this.setState({ + field, + loading: false, + autoField: this.props.shouldEnableAutoField(field) + }) + ) + .catch(e => { + console.error(e); + // if the component is unmounted, then dont call set state + if (!this.currentRequest) { + return; + } + + // otherwise it was a failed fetch call + this.setState({ autoField: false, loading: false }); + }) + .then(() => (this.currentRequest = null)); + } + + private loadField(address: string) { + const { fieldToFetch } = this.props; + return shepherdProvider + .sendCallRequest({ data: ERC20[fieldToFetch].encodeInput(), to: address }) + .then(ERC20[fieldToFetch].decodeOutput as any) + .then(({ [fieldToFetch]: field }) => { + let result: Result; + if (this.props.fetchedFieldValidator) { + result = this.props.fetchedFieldValidator(field); + } else { + result = Result.from({ res: field }); + } + + // + // + this.props.onChange(result); + return { [fieldToFetch]: result }; + }); + } +} diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/SymbolField.tsx b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/SymbolField.tsx new file mode 100644 index 00000000..97879e74 --- /dev/null +++ b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/SymbolField.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { translateRaw } from 'translations'; +import { IGenerateSymbolLookup } from './AddCustomTokenForm'; +import { Result } from 'mycrypto-nano-result'; +import { FieldInput } from './FieldInput'; + +interface OwnProps { + address?: string; + symbolLookup: IGenerateSymbolLookup; + onChange(symbol: Result): void; +} + +export class SymbolField extends React.Component { + public render() { + return ( + !req.err()} + address={this.props.address} + userInputValidator={this.isValidUserInput} + fetchedFieldValidator={field => + field + ? Result.from({ res: field }) + : Result.from({ err: 'No Symbol found, please input the token symbol manually' }) + } + onChange={this.props.onChange} + /> + ); + } + + private isValidUserInput = (userInput: string) => { + const validSymbol = !this.props.symbolLookup[userInput]; + const symbol: Result = validSymbol + ? Result.from({ res: userInput }) + : Result.from({ err: 'A token with this symbol already exists' }); + return symbol; + }; +} diff --git a/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/index.ts b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/index.ts new file mode 100644 index 00000000..bd180f65 --- /dev/null +++ b/common/components/BalanceSidebar/TokenBalances/AddCustomTokenForm/index.ts @@ -0,0 +1 @@ +export * from './AddCustomTokenForm'; diff --git a/common/components/BalanceSidebar/TokenBalances/Balances.tsx b/common/components/BalanceSidebar/TokenBalances/Balances.tsx index c21f35d1..1b3f0e24 100644 --- a/common/components/BalanceSidebar/TokenBalances/Balances.tsx +++ b/common/components/BalanceSidebar/TokenBalances/Balances.tsx @@ -1,7 +1,7 @@ import React from 'react'; import translate from 'translations'; import { TokenBalance } from 'selectors/wallet'; -import AddCustomTokenForm from './AddCustomTokenForm'; +import { AddCustomTokenForm } from './AddCustomTokenForm'; import TokenRow from './TokenRow'; import { Token } from 'types/network'; diff --git a/common/libs/erc20.ts b/common/libs/erc20.ts index 234b2607..665eb82e 100644 --- a/common/libs/erc20.ts +++ b/common/libs/erc20.ts @@ -1,20 +1,62 @@ import Contract from 'libs/contracts'; -interface ABIFunc { - encodeInput(x: T): string; +type uint256 = any; + +type address = any; + +export interface ABIFunc { + outputType: K; decodeInput(argStr: string): T; + encodeInput(x: T): string; decodeOutput(argStr: string): K; } -type address = any; -type uint256 = any; +export interface ABIFuncParamless { + outputType: T; + encodeInput(): string; + decodeOutput(argStr: string): T; +} interface IErc20 { + decimals: ABIFuncParamless<{ decimals: string }>; + symbol: ABIFuncParamless<{ symbol: string }>; + balanceOf: ABIFunc<{ _owner: address }, { balance: uint256 }>; transfer: ABIFunc<{ _to: address; _value: uint256 }>; } const erc20Abi = [ + { + name: 'decimals', + type: 'function', + + constant: true, + payable: false, + inputs: [], + + outputs: [ + { + name: '', + type: 'uint8' + } + ] + }, + { + name: 'symbol', + type: 'function', + constant: true, + payable: false, + + inputs: [], + + outputs: [ + { + name: '', + type: 'string' + } + ] + }, + { name: 'balanceOf', type: 'function', @@ -35,11 +77,13 @@ const erc20Abi = [ } ] }, + { name: 'transfer', type: 'function', constant: false, payable: false, + inputs: [ { name: '_to', @@ -57,7 +101,32 @@ const erc20Abi = [ type: 'bool' } ] + }, + { + name: 'Transfer', + type: 'event', + anonymous: false, + inputs: [ + { + indexed: true, + name: '_from', + type: 'address' + }, + { + indexed: true, + name: '_to', + type: 'address' + }, + { + indexed: false, + name: '_value', + type: 'uint256' + } + ] } ]; -export default (new Contract(erc20Abi) as any) as IErc20; +export default (new Contract(erc20Abi, { + decimals: ['decimals'], + symbol: ['symbol'] +}) as any) as IErc20; diff --git a/package.json b/package.json index 4c8ae19a..bcfb44f3 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "lint-staged": "7.0.4", "mini-css-extract-plugin": "0.4.0", "minimist": "1.2.0", + "mycrypto-nano-result": "0.0.1", "node-sass": "4.8.3", "nodemon": "1.17.3", "null-loader": "0.1.1", diff --git a/yarn.lock b/yarn.lock index 807120f5..02e2192c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7432,6 +7432,10 @@ mycrypto-eth-exists@1.0.0: isomorphic-ws "^4.0.1" toml "^2.3.3" +mycrypto-nano-result@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/mycrypto-nano-result/-/mycrypto-nano-result-0.0.1.tgz#c1d3208458dc485441b0230c69948f62344f9d5a" + mycrypto-shepherd@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/mycrypto-shepherd/-/mycrypto-shepherd-1.4.0.tgz#ad86e0f18040da524631bf471bce03ba65c165a3" @@ -9953,8 +9957,8 @@ sdp@^2.6.0: resolved "https://registry.yarnpkg.com/sdp/-/sdp-2.7.0.tgz#02b64ea0c29d73179afa19794e466b123b1b29f3" sdp@^2.7.0: - version "2.7.4" - resolved "https://registry.yarnpkg.com/sdp/-/sdp-2.7.4.tgz#cac76b0e2f16f55243d25bc0432f6bbb5488bfc1" + version "2.7.3" + resolved "https://registry.yarnpkg.com/sdp/-/sdp-2.7.3.tgz#ed177eb4074aa3213e150e74a9ab2d06ae6e5dbf" secp256k1@^3.0.1: version "3.5.0"