Ether Unit Types and Send UX Improvements (#174)

* remove 'transate' property and ng-scopes

* use bigs (surprised flow did not catch this)

* fix dropdown not expanding -- switch to simpledropdown

* Don't use generics for no real reason

*    Create Ether, Wei, and GWei types, and annotate.

    Also contains refactors and UX improvements

    1. clear previously generated rawTX/signedTx when changes to transaction inputs are made.

    2. reset generated rawTx/signedTx while new generateTx is loading

* add toString helper method and use in place of .amount.toString()

* support optional base in toString helper method and use

* incorporate PR suggestions (destructure, resolve via callback)
This commit is contained in:
Daniel Ternyak 2017-09-08 14:01:31 -05:00 committed by GitHub
parent b59298ec0e
commit 38dd22953a
8 changed files with 169 additions and 101 deletions

View File

@ -51,7 +51,7 @@ import type {
} from 'libs/transaction'; } from 'libs/transaction';
import type { TransactionWithoutGas } from 'libs/messages'; import type { TransactionWithoutGas } from 'libs/messages';
import type { UNIT } from 'libs/units'; import type { UNIT } from 'libs/units';
import { toWei } from 'libs/units'; import { Wei, Ether, GWei } from 'libs/units';
import { import {
generateCompleteTransaction, generateCompleteTransaction,
getBalanceMinusGasCosts, getBalanceMinusGasCosts,
@ -94,13 +94,13 @@ type Props = {
} }
}, },
wallet: BaseWallet, wallet: BaseWallet,
balance: Big, balance: Ether,
node: NodeConfig, node: NodeConfig,
nodeLib: RPCNode, nodeLib: RPCNode,
network: NetworkConfig, network: NetworkConfig,
tokens: Token[], tokens: Token[],
tokenBalances: TokenBalance[], tokenBalances: TokenBalance[],
gasPrice: string, gasPrice: Wei,
broadcastTx: (signedTx: string) => BroadcastTxRequestedAction, broadcastTx: (signedTx: string) => BroadcastTxRequestedAction,
showNotification: ( showNotification: (
level: string, level: string,
@ -366,6 +366,7 @@ export class SendTransaction extends React.Component {
this.estimateGas(); this.estimateGas();
} }
} catch (error) { } catch (error) {
this.setState({ generateDisabled: true });
this.props.showNotification('danger', error.message, 5000); this.props.showNotification('danger', error.message, 5000);
} }
} }
@ -404,36 +405,63 @@ export class SendTransaction extends React.Component {
this.setState({ gasLimit: value, gasChanged: true }); this.setState({ gasLimit: value, gasChanged: true });
}; };
onAmountChange = (value: string, unit: string) => { handleEverythingAmountChange = (value: string, unit: string): string => {
if (value === 'everything') {
if (unit === 'ether') { if (unit === 'ether') {
const { balance, gasPrice } = this.props; const { balance, gasPrice } = this.props;
const { gasLimit } = this.state; const { gasLimit } = this.state;
const weiBalance = toWei(balance, 'ether'); const weiBalance = balance.toWei();
const bigGasLimit = new Big(gasLimit);
value = getBalanceMinusGasCosts( value = getBalanceMinusGasCosts(
new Big(gasLimit), bigGasLimit,
new Big(gasPrice), gasPrice,
weiBalance weiBalance
); ).toString();
} else { } else {
const tokenBalance = this.props.tokenBalances.find( const tokenBalance = this.props.tokenBalances.find(
tokenBalance => tokenBalance.symbol === unit tokenBalance => tokenBalance.symbol === unit
); );
if (!tokenBalance) { if (!tokenBalance) {
return; throw new Error(`${unit}: not found in token balances;`);
} }
value = tokenBalance.balance.toString(); value = tokenBalance.balance.toString();
} }
return value;
};
onAmountChange = (value: string, unit: string) => {
if (value === 'everything') {
value = this.handleEverythingAmountChange(value, unit);
}
let transaction = this.state.transaction;
let generateDisabled = this.state.generateDisabled;
if (unit && unit !== this.state.unit) {
value = '';
transaction = null;
generateDisabled = true;
} }
let token = this.props.tokens.find(x => x.symbol === unit); let token = this.props.tokens.find(x => x.symbol === unit);
this.setState({ this.setState({
value, value,
unit, unit,
token token,
transaction,
generateDisabled
});
};
resetJustTx = async (): Promise<*> => {
new Promise(resolve => {
this.setState(
{
transaction: null
},
resolve
);
}); });
}; };
generateTxFromState = async () => { generateTxFromState = async () => {
await this.resetJustTx();
const { nodeLib, wallet, gasPrice, network } = this.props; const { nodeLib, wallet, gasPrice, network } = this.props;
const { token, unit, value, to, data, gasLimit } = this.state; const { token, unit, value, to, data, gasLimit } = this.state;
const chainId = network.chainId; const chainId = network.chainId;
@ -444,12 +472,13 @@ export class SendTransaction extends React.Component {
to, to,
data data
}; };
const bigGasLimit = new Big(gasLimit);
try { try {
const signedTx = await generateCompleteTransaction( const signedTx = await generateCompleteTransaction(
wallet, wallet,
nodeLib, nodeLib,
gasPrice, gasPrice,
gasLimit, bigGasLimit,
chainId, chainId,
transactionInput transactionInput
); );
@ -483,13 +512,13 @@ export class SendTransaction extends React.Component {
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState) {
return { return {
wallet: state.wallet.inst, wallet: state.wallet.inst,
balance: state.wallet.balance, balance: new Ether(state.wallet.balance),
tokenBalances: getTokenBalances(state), tokenBalances: getTokenBalances(state),
node: getNodeConfig(state), node: getNodeConfig(state),
nodeLib: getNodeLib(state), nodeLib: getNodeLib(state),
network: getNetworkConfig(state), network: getNetworkConfig(state),
tokens: getTokens(state), tokens: getTokens(state),
gasPrice: toWei(new Big(getGasPriceGwei(state)), 'gwei').toString(), gasPrice: new GWei(getGasPriceGwei(state)).toWei(),
transactions: state.wallet.transactions transactions: state.wallet.transactions
}; };
} }

View File

@ -2,9 +2,10 @@
import Big from 'bignumber.js'; import Big from 'bignumber.js';
import type { TransactionWithoutGas } from 'libs/messages'; import type { TransactionWithoutGas } from 'libs/messages';
import type { Token } from 'config/data'; import type { Token } from 'config/data';
import type { Wei } from 'libs/units';
export interface INode { export interface INode {
getBalance(_address: string): Promise<Big>, getBalance(_address: string): Promise<Wei>,
getTokenBalance(_address: string, _token: Token): Promise<Big>, getTokenBalance(_address: string, _token: Token): Promise<Big>,
getTokenBalances(_address: string, _tokens: Token[]): Promise<Big>, getTokenBalances(_address: string, _tokens: Token[]): Promise<Big>,
estimateGas(_tx: TransactionWithoutGas): Promise<Big>, estimateGas(_tx: TransactionWithoutGas): Promise<Big>,

View File

@ -10,6 +10,7 @@ import RPCClient, {
sendRawTx sendRawTx
} from './client'; } from './client';
import type { Token } from 'config/data'; import type { Token } from 'config/data';
import { Wei } from 'libs/units';
export default class RpcNode implements INode { export default class RpcNode implements INode {
client: RPCClient; client: RPCClient;
@ -17,12 +18,12 @@ export default class RpcNode implements INode {
this.client = new RPCClient(endpoint); this.client = new RPCClient(endpoint);
} }
getBalance(address: string): Promise<Big> { getBalance(address: string): Promise<Wei> {
return this.client.call(getBalance(address)).then(response => { return this.client.call(getBalance(address)).then(response => {
if (response.error) { if (response.error) {
throw new Error(response.error.message); throw new Error(response.error.message);
} }
return new Big(String(response.result)); return new Wei(String(response.result));
}); });
} }

View File

@ -4,17 +4,15 @@ import translate from 'translations';
import { padToEven, addHexPrefix, toChecksumAddress } from 'ethereumjs-util'; import { padToEven, addHexPrefix, toChecksumAddress } from 'ethereumjs-util';
import { isValidETHAddress } from 'libs/validators'; import { isValidETHAddress } from 'libs/validators';
import ERC20 from 'libs/erc20'; import ERC20 from 'libs/erc20';
import { toTokenUnit } from 'libs/units'; import { stripHex, valueToHex } from 'libs/values';
import { stripHex } from 'libs/values'; import { Wei, Ether, toTokenUnit } from 'libs/units';
import { RPCNode } from 'libs/nodes';
import { TransactionWithoutGas } from 'libs/messages';
import type { INode } from 'libs/nodes/INode'; import type { INode } from 'libs/nodes/INode';
import type { BaseWallet } from 'libs/wallet'; import type { BaseWallet } from 'libs/wallet';
import type { Token } from 'config/data'; import type { Token } from 'config/data';
import type EthTx from 'ethereumjs-tx'; import type EthTx from 'ethereumjs-tx';
import { toUnit } from 'libs/units';
import { valueToHex } from 'libs/values';
import type { UNIT } from 'libs/units'; import type { UNIT } from 'libs/units';
import { RPCNode } from 'libs/nodes';
import { TransactionWithoutGas } from 'libs/messages';
export type TransactionInput = { export type TransactionInput = {
token: ?Token, token: ?Token,
@ -34,8 +32,8 @@ export type BaseTransaction = {|
to: string, to: string,
value: string, value: string,
data: string, data: string,
gasLimit: string, gasLimit: Big,
gasPrice: string, gasPrice: Wei,
chainId: number chainId: number
|}; |};
@ -84,73 +82,69 @@ export async function generateCompleteTransactionFromRawTransaction(
wallet: BaseWallet, wallet: BaseWallet,
token: ?Token token: ?Token
): Promise<CompleteTransaction> { ): Promise<CompleteTransaction> {
const { to, data, gasLimit, gasPrice, from, chainId, nonce } = tx;
// Reject bad addresses // Reject bad addresses
if (!isValidETHAddress(tx.to)) { if (!isValidETHAddress(to)) {
throw new Error(translate('ERROR_5')); throw new Error(translate('ERROR_5', true));
} }
// Reject token transactions without data // Reject token transactions without data
if (token && !tx.data) { if (token && !data) {
throw new Error('Tokens must be sent with data'); throw new Error('Tokens must be sent with data');
} }
// Reject gas limit under 21000 (Minimum for transaction) // Reject gas limit under 21000 (Minimum for transaction)
// Reject if limit over 5000000 // Reject if limit over 5000000
// TODO: Make this dynamic, the limit shifts // TODO: Make this dynamic, the limit shifts
const limitBig = new Big(tx.gasLimit); if (gasLimit.lessThan(21000)) {
if (limitBig.lessThan(21000)) { throw new Error('Gas limit must be at least 21000 for transactions');
throw new Error(
translate('Gas limit must be at least 21000 for transactions')
);
} }
// Reject gasLimit over 5000000gwei
if (limitBig.greaterThan(5000000)) { if (gasLimit.greaterThan(5000000)) {
throw new Error(translate('GETH_GasLimit')); throw new Error(translate('GETH_GasLimit', true));
} }
// Reject gasPrice over 1000gwei (1000000000000)
// Reject gas over 1000gwei (1000000000000) if (gasPrice.amount.greaterThan(new Big('1000000000000'))) {
const gasPriceBig = new Big(tx.gasPrice);
if (gasPriceBig.greaterThan(new Big('1000000000000'))) {
throw new Error( throw new Error(
'Gas price too high. Please contact support if this was not a mistake.' 'Gas price too high. Please contact support if this was not a mistake.'
); );
} }
// build gasCost by multiplying gasPrice * gasLimit
const gasCost: Wei = new Wei(gasPrice.amount.times(gasLimit));
// Ensure their balance exceeds the amount they're sending // Ensure their balance exceeds the amount they're sending
// TODO: Include gas price too, tokens should probably check ETH too
let value; let value;
let balance; let balance;
const ETHBalance: Wei = await node.getBalance(from);
if (token) { if (token) {
value = new Big(ERC20.$transfer(tx.data).value); value = new Big(ERC20.$transfer(tx.data).value);
balance = toTokenUnit(await node.getTokenBalance(tx.from, token), token); balance = toTokenUnit(await node.getTokenBalance(tx.from, token), token);
} else { } else {
value = new Big(tx.value); value = new Big(tx.value);
balance = await node.getBalance(tx.from); balance = ETHBalance.amount;
} }
if (value.gt(balance)) {
if (value.gte(balance)) { throw new Error(translate('GETH_Balance', true));
throw new Error(translate('GETH_Balance')); }
// ensure gas cost is not greaterThan current eth balance
// TODO check that eth balance is not lesser than txAmount + gasCost
if (gasCost.amount.gt(ETHBalance.amount)) {
throw new Error(
`gasCost: ${gasCost.amount} greaterThan ETHBalance: ${ETHBalance.amount}`
);
} }
// Taken from v3's `sanitizeHex`, ensures that the value is a %2 === 0 // Taken from v3's `sanitizeHex`, ensures that the value is a %2 === 0
// prefix'd hex value. // prefix'd hex value.
const cleanHex = hex => addHexPrefix(padToEven(stripHex(hex))); const cleanHex = hex => addHexPrefix(padToEven(stripHex(hex)));
const cleanedRawTx = { const cleanedRawTx = {
nonce: cleanHex(tx.nonce), nonce: cleanHex(nonce),
gasPrice: cleanHex(new Big(tx.gasPrice).toString(16)), gasPrice: cleanHex(gasPrice.toString(16)),
gasLimit: cleanHex(new Big(tx.gasLimit).toString(16)), gasLimit: cleanHex(gasLimit.toString(16)),
to: cleanHex(tx.to), to: cleanHex(to),
value: token ? '0x00' : cleanHex(value.toString(16)), value: token ? '0x00' : cleanHex(value.toString(16)),
data: tx.data ? cleanHex(tx.data) : '', data: data ? cleanHex(data) : '',
chainId: tx.chainId || 1 chainId: chainId || 1
}; };
// Sign the transaction // Sign the transaction
const rawTxJson = JSON.stringify(cleanedRawTx); const rawTxJson = JSON.stringify(cleanedRawTx);
const signedTx = await wallet.signRawTransaction(cleanedRawTx); const signedTx = await wallet.signRawTransaction(cleanedRawTx);
// Repeat all of this shit for Flow typechecking. Sealed objects don't // Repeat all of this shit for Flow typechecking. Sealed objects don't
// like spreads, so we have to be explicit. // like spreads, so we have to be explicit.
return { return {
@ -174,7 +168,7 @@ export async function formatTxInput(
return { return {
to, to,
from: await wallet.getAddress(), from: await wallet.getAddress(),
value: valueToHex(value), value: valueToHex(new Ether(value)),
data data
}; };
} else { } else {
@ -182,11 +176,12 @@ export async function formatTxInput(
throw new Error('No matching token'); throw new Error('No matching token');
} }
const bigAmount = new Big(value); const bigAmount = new Big(value);
const ERC20Data = ERC20.transfer(to, bigAmount);
return { return {
to: token.address, to: token.address,
from: await wallet.getAddress(), from: await wallet.getAddress(),
value: '0x0', value: '0x0',
data: ERC20.transfer(to, toTokenUnit(bigAmount, token)) data: ERC20Data
}; };
} }
} }
@ -194,28 +189,26 @@ export async function formatTxInput(
export async function generateCompleteTransaction( export async function generateCompleteTransaction(
wallet: BaseWallet, wallet: BaseWallet,
nodeLib: RPCNode, nodeLib: RPCNode,
gasPrice: string, gasPrice: Wei,
gasLimit: string, gasLimit: Big,
chainId: number, chainId: number,
transactionInput: TransactionInput transactionInput: TransactionInput
): Promise<CompleteTransaction> { ): Promise<CompleteTransaction> {
const { token } = transactionInput; const { token } = transactionInput;
const { from, to, value, data } = await formatTxInput(
const formattedTx = await formatTxInput(wallet, transactionInput); wallet,
transactionInput
const from = await wallet.getAddress(); );
const transaction: ExtendedRawTransaction = { const transaction: ExtendedRawTransaction = {
nonce: await nodeLib.getTransactionCount(from), nonce: await nodeLib.getTransactionCount(from),
from, from,
to: formattedTx.to, to,
gasLimit, gasLimit,
value: formattedTx.value, value,
data: formattedTx.data, data,
chainId, chainId,
gasPrice gasPrice
}; };
return await generateCompleteTransactionFromRawTransaction( return await generateCompleteTransactionFromRawTransaction(
nodeLib, nodeLib,
transaction, transaction,
@ -226,11 +219,11 @@ export async function generateCompleteTransaction(
// TODO determine best place for helper function // TODO determine best place for helper function
export function getBalanceMinusGasCosts( export function getBalanceMinusGasCosts(
weiGasLimit: Big, gasLimit: Big,
weiGasPrice: Big, gasPrice: Wei,
weiBalance: Big balance: Wei
): Big { ): Ether {
const weiGasCosts = weiGasPrice.times(weiGasLimit); const weiGasCosts = gasPrice.amount.times(gasLimit);
const weiBalanceMinusGasCosts = weiBalance.minus(weiGasCosts); const weiBalanceMinusGasCosts = balance.amount.minus(weiGasCosts);
return toUnit(weiBalanceMinusGasCosts, 'wei', 'ether'); return new Ether(weiBalanceMinusGasCosts);
} }

View File

@ -31,6 +31,53 @@ const UNITS = {
export type UNIT = $Keys<typeof UNITS>; export type UNIT = $Keys<typeof UNITS>;
class Unit {
unit: UNIT;
amount: Big;
constructor(amount: Big, unit: UNIT) {
if (!(unit in UNITS)) {
throw new Error(`Supplied unit: ${unit} is not a valid unit.`);
}
this.unit = unit;
this.amount = amount;
}
toString(base?: number) {
return this.amount.toString(base);
}
toWei(): Wei {
return new Wei(toWei(this.amount, this.unit));
}
toGWei(): GWei {
return new GWei(toUnit(this.amount, this.unit, 'gwei'));
}
toEther(): Ether {
return new Ether(toUnit(this.amount, this.unit, 'ether'));
}
}
export class Ether extends Unit {
constructor(amount: Big | number | string) {
super(new Big(amount), 'ether');
}
}
export class Wei extends Unit {
constructor(amount: Big | number | string) {
super(new Big(amount), 'wei');
}
}
export class GWei extends Unit {
constructor(amount: Big | number | string) {
super(new Big(amount), 'gwei');
}
}
function getValueOfUnit(unit: UNIT) { function getValueOfUnit(unit: UNIT) {
return new Big(UNITS[unit]); return new Big(UNITS[unit]);
} }

View File

@ -1,16 +1,13 @@
// @flow // @flow
import Big from 'bignumber.js'; import { Ether } from 'libs/units';
import { toWei } from 'libs/units';
export function stripHex(address: string): string { export function stripHex(address: string): string {
return address.replace('0x', '').toLowerCase(); return address.replace('0x', '').toLowerCase();
} }
export function valueToHex(n: Big | number | string): string { export function valueToHex(value: Ether): string {
// Convert it to a Big to handle any and all values.
const big = new Big(n);
// Values are in ether, so convert to wei for RPC calls // Values are in ether, so convert to wei for RPC calls
const wei = toWei(big, 'ether'); const wei = value.toWei();
// Finally, hex it up! // Finally, hex it up!
return `0x${wei.toString(16)}`; return `0x${wei.toString(16)}`;
} }

View File

@ -15,8 +15,8 @@ export default class TrezorWallet extends DeterministicWallet {
// Args // Args
this.getPath(), this.getPath(),
stripHex(tx.nonce), stripHex(tx.nonce),
stripHex(tx.gasPrice), stripHex(tx.gasPrice.toString()),
stripHex(tx.gasLimit), stripHex(tx.gasLimit.toString()),
stripHex(tx.to), stripHex(tx.to),
stripHex(tx.value), stripHex(tx.value),
stripHex(tx.data), stripHex(tx.data),

View File

@ -23,7 +23,7 @@ import {
} from 'libs/wallet'; } from 'libs/wallet';
import { INode } from 'libs/nodes/INode'; import { INode } from 'libs/nodes/INode';
import { determineKeystoreType } from 'libs/keystore'; import { determineKeystoreType } from 'libs/keystore';
import type { Wei } from 'libs/units';
import { getNodeLib } from 'selectors/config'; import { getNodeLib } from 'selectors/config';
import { getWalletInst, getTokens } from 'selectors/wallet'; import { getWalletInst, getTokens } from 'selectors/wallet';
@ -38,8 +38,8 @@ function* updateAccountBalance(): Generator<Yield, Return, Next> {
const node: INode = yield select(getNodeLib); const node: INode = yield select(getNodeLib);
const address = yield wallet.getAddress(); const address = yield wallet.getAddress();
// network request // network request
let balance = yield apply(node, node.getBalance, [address]); let balance: Wei = yield apply(node, node.getBalance, [address]);
yield put(setBalance(balance)); yield put(setBalance(balance.amount));
} catch (error) { } catch (error) {
yield put({ type: 'updateAccountBalance_error', error }); yield put({ type: 'updateAccountBalance_error', error });
} }