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
This commit is contained in:
HenryNguyen5 2018-06-15 17:10:30 -04:00 committed by Daniel Ternyak
parent cd26970d7f
commit 41f8ab8966
13 changed files with 590 additions and 174 deletions

View File

@ -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<Props, State> {
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 (
<form className="AddCustom" onSubmit={this.onSave}>
{fields.map(field => {
return (
<label className="AddCustom-field form-group" key={field.name}>
<div className="input-group-header">{field.label}</div>
<Input
isValid={!errors[field.name]}
className="input-group-input-small"
type="text"
name={field.name}
value={field.value}
onChange={this.onFieldChange}
/>
{errors[field.name] && (
<div className="AddCustom-field-error">{errors[field.name]}</div>
)}
</label>
);
})}
<HelpLink article={HELP_ARTICLE.ADDING_NEW_TOKENS} className="AddCustom-buttons-help">
{translate('ADD_CUSTOM_TKN_HELP')}
</HelpLink>
<div className="AddCustom-buttons">
<button
className="AddCustom-buttons-btn btn btn-sm btn-default"
onClick={this.props.toggleForm}
>
{translate('ACTION_2')}
</button>
<button
className="AddCustom-buttons-btn btn btn-primary btn-sm"
disabled={!this.isValid()}
>
{translate('X_SAVE')}
</button>
</div>
</form>
);
}
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<HTMLInputElement>) => {
// 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<HTMLFormElement>) => {
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<keyof Token, 'error'>) {
const tokens = this.props.allTokens;
return tokens.reduce<{ [k: string]: boolean }>((prev, tk) => {
prev[tk[key]] = true;
return prev;
}, {});
}
}

View File

@ -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<string>;
symbol: Result<string>;
decimal: Result<string>;
}
export class AddCustomTokenForm extends React.PureComponent<Props, State> {
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 (
<form className="AddCustom" onSubmit={this.onSave}>
<AddressField
addressLookup={this.tokenAddressLookup}
onChange={this.handleFieldChange('address')}
/>
<DecimalField address={address} onChange={this.handleFieldChange('decimal')} />
<SymbolField
address={address}
symbolLookup={this.tokenSymbolLookup}
onChange={this.handleFieldChange('symbol')}
/>
<BalanceField address={address} />
<HelpLink article={HELP_ARTICLE.ADDING_NEW_TOKENS} className="AddCustom-buttons-help">
{translate('ADD_CUSTOM_TKN_HELP')}
</HelpLink>
<div className="AddCustom-buttons">
<button
className="AddCustom-buttons-btn btn btn-sm btn-default"
onClick={this.props.toggleForm}
>
{translate('ACTION_2')}
</button>
<button
className="AddCustom-buttons-btn btn btn-primary btn-sm"
disabled={!this.isValid()}
>
{translate('X_SAVE')}
</button>
</div>
</form>
);
}
public onSave = (ev: React.FormEvent<HTMLFormElement>) => {
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<string>) => {
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<keyof Token, 'error'>) {
const tokens = this.props.allTokens;
return tokens.reduce<{ [k: string]: boolean }>((prev, tk) => {
prev[tk[key]] = true;
return prev;
}, {});
}
}

View File

@ -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<string>): void;
}
enum ErrType {
INVALIDADDR = 'Not a valid address',
ADDRTAKEN = 'A token with this address already exists'
}
interface State {
address: Result<string>;
userInput: string;
}
export class AddressField extends React.Component<OwnProps, State> {
public state: State = {
address: Result.from({ res: '' }),
userInput: ''
};
public render() {
const { userInput, address } = this.state;
return (
<label className="AddCustom-field form-group">
<div className="input-group-header">{translateRaw('TOKEN_ADDR')}</div>
<Input
isValid={address.ok()}
className="input-group-input-small"
type="text"
name="Address"
value={address.ok() ? address.unwrap() : userInput}
onChange={this.handleFieldChange}
/>
{address.err() && <div className="AddCustom-field-error">{address.err()}</div>}
</label>
);
}
private handleFieldChange = (e: React.FormEvent<HTMLInputElement>) => {
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<string> = err ? Result.from({ err }) : Result.from({ res: userInput });
this.setState({ userInput, address });
this.props.onChange(address);
};
}

View File

@ -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<typeof getWalletInst>;
}
interface State {
balance: Result<string>;
addressToLoad?: string;
loading: boolean;
}
type Props = OwnProps & StateProps;
class BalanceFieldClass extends React.Component<Props, State> {
public static getDerivedStateFromProps(
nextProps: OwnProps,
prevState: State
): Partial<State> | 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<any> | 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 (
<label className="AddCustom-field form-group">
<div className="input-group-header">Balance</div>
{loading ? (
<Spinner />
) : (
<Input
isValid={balance.ok()}
className="input-group-input-small"
type="text"
name="Balance"
readOnly={true}
value={balance.ok() ? balance.unwrap() : '0'}
/>
)}
{balance.err() && <div className="AddCustom-field-error">{balance.err()}</div>}
</label>
);
}
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);

View File

@ -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<string>): void;
}
export class DecimalField extends React.Component<OwnProps> {
public render() {
return (
<FieldInput
fieldName={translateRaw('TOKEN_DEC')}
fieldToFetch={'decimals'}
shouldEnableAutoField={req => !(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<string> = validDecimals
? Result.from({ res: userInput })
: Result.from({ err: 'Invalid decimal' });
return decimals;
};
}

View File

@ -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<typeof ERC20, 'symbol' | 'decimals'>;
fieldName: string;
address?: string;
userInputValidator(input: string): Result<string>;
fetchedFieldValidator?(input: any): Result<string>;
shouldEnableAutoField(input: Result<string>): boolean;
onChange(symbol: Result<string>): void;
}
interface State {
field: Result<string>;
autoField: boolean;
userInput: string;
addressToLoad?: string;
loading: boolean;
}
export class FieldInput extends React.Component<OwnProps, State> {
public static getDerivedStateFromProps(
nextProps: OwnProps,
prevState: State
): Partial<State> | 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<any> | 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 (
<label className="AddCustom-field form-group">
<div className="input-group-header">{this.props.fieldName}</div>
{loading ? (
<Spinner />
) : (
<Input
isValid={field.ok()}
className="input-group-input-small"
type="text"
name={this.props.fieldName}
readOnly={autoField}
value={field.ok() ? field.unwrap() : userInput}
onChange={this.handleFieldChange}
/>
)}
{field.err() && <div className="AddCustom-field-error">{field.err()}</div>}
</label>
);
}
private handleFieldChange = (args: React.FormEvent<HTMLInputElement>) => {
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<string>;
if (this.props.fetchedFieldValidator) {
result = this.props.fetchedFieldValidator(field);
} else {
result = Result.from({ res: field });
}
//
//
this.props.onChange(result);
return { [fieldToFetch]: result };
});
}
}

View File

@ -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<string>): void;
}
export class SymbolField extends React.Component<OwnProps> {
public render() {
return (
<FieldInput
fieldName={translateRaw('TOKEN_SYMBOL')}
fieldToFetch={'symbol'}
shouldEnableAutoField={req => !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<string> = validSymbol
? Result.from({ res: userInput })
: Result.from({ err: 'A token with this symbol already exists' });
return symbol;
};
}

View File

@ -0,0 +1 @@
export * from './AddCustomTokenForm';

View File

@ -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';

View File

@ -1,20 +1,62 @@
import Contract from 'libs/contracts';
interface ABIFunc<T, K = void> {
encodeInput(x: T): string;
type uint256 = any;
type address = any;
export interface ABIFunc<T, K = void> {
outputType: K;
decodeInput(argStr: string): T;
encodeInput(x: T): string;
decodeOutput(argStr: string): K;
}
type address = any;
type uint256 = any;
export interface ABIFuncParamless<T = void> {
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;

View File

@ -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",

View File

@ -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"