RPC Error Handling (#384)

* create ensureOkResponse and check against RPC responses

* Merge with develop branch

* added single unit test

* main nodes added

* getBalance method

* remove console.log

* minor conflict fix - readd polyfill to integration test

* added two more method tests

* seperate rpcnode from extended classes

* fixes etherscan

* added all tests

* revert files with only formatting changes

* remove console.logs - still need to update snapshot before tests will pass

* updated snapshot due to RpcNode fixes for Infura and Etherscan nodes

* added RpcNodeTest config so we don't rely on constants in code

* undo formatting changes

* Multiple fixes to error handling tokens.

* Fixed TSC errors

* Minor styling edit - change async func to promise

* changed shape of tokenBalances

* change balance type back to stricter TokenValue type

* remove package.json change and include test for error state.

* minor change removing unneeded line of code

* added longer timeout for api

* update snapshot
This commit is contained in:
Eddie Wang 2017-11-30 00:35:17 -05:00 committed by Daniel Ternyak
parent a40b22fc68
commit 980366694c
24 changed files with 333 additions and 80 deletions

View File

@ -71,7 +71,10 @@ export function setBalanceRejected(): types.SetBalanceRejectedAction {
export type TSetTokenBalances = typeof setTokenBalances;
export function setTokenBalances(payload: {
[key: string]: TokenValue;
[key: string]: {
balance: TokenValue;
error: string | null;
};
}): types.SetTokenBalancesAction {
return {
type: TypeKeys.WALLET_SET_TOKEN_BALANCES,

View File

@ -48,7 +48,10 @@ export interface SetBalanceRejectedAction {
export interface SetTokenBalancesAction {
type: TypeKeys.WALLET_SET_TOKEN_BALANCES;
payload: {
[key: string]: TokenValue;
[key: string]: {
balance: TokenValue;
error: string | null;
};
};
}

View File

@ -3,8 +3,8 @@ import { checkHttpStatus, parseJSON } from './utils';
export function getAllRates() {
const mappedRates = {};
return _getAllRates().then((bityRates) => {
bityRates.objects.forEach((each) => {
return _getAllRates().then(bityRates => {
bityRates.objects.forEach(each => {
const pairName = each.pair;
mappedRates[pairName] = parseFloat(each.rate_we_sell);
});
@ -26,7 +26,7 @@ export function postOrder(
mode,
pair
}),
headers: bityConfig.postConfig.headers
headers: new Headers(bityConfig.postConfig.headers)
})
.then(checkHttpStatus)
.then(parseJSON);
@ -38,7 +38,7 @@ export function getOrderStatus(orderId: string) {
body: JSON.stringify({
orderid: orderId
}),
headers: bityConfig.postConfig.headers
headers: new Headers(bityConfig.postConfig.headers)
})
.then(checkHttpStatus)
.then(parseJSON);
@ -48,4 +48,4 @@ function _getAllRates() {
return fetch(`${bityConfig.bityURL}/v1/rate2/`)
.then(checkHttpStatus)
.then(parseJSON);
}
}

View File

@ -87,6 +87,8 @@ export default class EquivalentValues extends React.Component<Props, CmpState> {
});
} else if (ratesError) {
valuesEl = <h5>{ratesError}</h5>;
} else if (tokenBalances && tokenBalances.length === 0) {
valuesEl = <h5>No tokens found!</h5>;
} else {
valuesEl = (
<div className="EquivalentValues-values-loader">

View File

@ -55,6 +55,7 @@ export interface Token {
address: string;
symbol: string;
decimal: number;
error?: string | null;
}
export interface NetworkContract {

View File

@ -9,8 +9,14 @@ export interface TxObj {
export interface INode {
ping(): Promise<boolean>;
getBalance(address: string): Promise<Wei>;
getTokenBalance(address: string, token: Token): Promise<TokenValue>;
getTokenBalances(address: string, tokens: Token[]): Promise<TokenValue[]>;
getTokenBalance(
address: string,
token: Token
): Promise<{ balance: TokenValue; error: string | null }>;
getTokenBalances(
address: string,
tokens: Token[]
): Promise<{ balance: TokenValue; error: string | null }[]>;
estimateGas(tx: TransactionWithoutGas): Promise<Wei>;
getTransactionCount(address: string): Promise<string>;
sendRawTx(tx: string): Promise<string>;

View File

@ -6,7 +6,9 @@ export default class EtherscanClient extends RPCClient {
public encodeRequest(request: EtherscanRequest): string {
const encoded = new URLSearchParams();
Object.keys(request).forEach(key => {
encoded.set(key, request[key]);
if (request[key]) {
encoded.set(key, request[key]);
}
});
return encoded.toString();
}
@ -14,9 +16,9 @@ export default class EtherscanClient extends RPCClient {
public call = (request: EtherscanRequest): Promise<JsonRpcResponse> =>
fetch(this.endpoint, {
method: 'POST',
headers: {
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
},
}),
body: this.encodeRequest(request)
}).then(r => r.json());

View File

@ -15,7 +15,7 @@ export default class EtherscanRequests extends RPCRequests {
public sendRawTx(signedTx: string): SendRawTxRequest {
return {
module: 'proxy',
method: 'eth_sendRawTransaction',
action: 'eth_sendRawTransaction',
hex: signedTx
};
}
@ -23,7 +23,7 @@ export default class EtherscanRequests extends RPCRequests {
public estimateGas(transaction): EstimateGasRequest {
return {
module: 'proxy',
method: 'eth_estimateGas',
action: 'eth_estimateGas',
to: transaction.to,
value: transaction.value,
data: transaction.data,

View File

@ -5,7 +5,7 @@ export interface EtherscanReqBase {
export interface SendRawTxRequest extends EtherscanReqBase {
module: 'proxy';
method: 'eth_sendRawTransaction';
action: 'eth_sendRawTransaction';
hex: string;
}
@ -27,7 +27,7 @@ export type GetTokenBalanceRequest = CallRequest;
export interface EstimateGasRequest extends EtherscanReqBase {
module: 'proxy';
method: 'eth_estimateGas';
action: 'eth_estimateGas';
to: string;
value: string | number;
data: string;

View File

@ -2,7 +2,7 @@ import { randomBytes } from 'crypto';
import RPCClient from '../rpc/client';
export default class InfuraClient extends RPCClient {
public id(): string {
return `0x${randomBytes(5).toString('hex')}`;
public id(): number {
return parseInt(randomBytes(5).toString('hex'), 16);
}
}

View File

@ -9,7 +9,7 @@ export default class RPCClient {
this.headers = headers;
}
public id(): string {
public id(): string | number {
return randomBytes(16).toString('hex');
}
@ -22,10 +22,10 @@ export default class RPCClient {
public call = (request: RPCRequest | any): Promise<JsonRpcResponse> => {
return fetch(this.endpoint, {
method: 'POST',
headers: {
headers: this.createHeaders({
'Content-Type': 'application/json',
...this.headers
},
}),
body: JSON.stringify(this.decorateRequest(request))
}).then(r => r.json());
};
@ -33,11 +33,19 @@ export default class RPCClient {
public batch = (requests: RPCRequest[] | any): Promise<JsonRpcResponse[]> => {
return fetch(this.endpoint, {
method: 'POST',
headers: {
headers: this.createHeaders({
'Content-Type': 'application/json',
...this.headers
},
}),
body: JSON.stringify(requests.map(this.decorateRequest))
}).then(r => r.json());
};
private createHeaders = headerObject => {
const headers = new Headers();
Object.keys(headerObject).forEach(name => {
headers.append(name, headerObject[name]);
});
return headers;
};
}

View File

@ -6,13 +6,15 @@ import { stripHexPrefix } from 'libs/values';
import { INode, TxObj } from '../INode';
import RPCClient from './client';
import RPCRequests from './requests';
function errorOrResult(response) {
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
import {
isValidGetBalance,
isValidEstimateGas,
isValidCallRequest,
isValidTokenBalance,
isValidTransactionCount,
isValidCurrentBlock,
isValidRawTxApi
} from '../../validators';
export default class RpcNode implements INode {
public client: RPCClient;
@ -31,78 +33,87 @@ export default class RpcNode implements INode {
}
public sendCallRequest(txObj: TxObj): Promise<string> {
return this.client.call(this.requests.ethCall(txObj)).then(r => {
if (r.error) {
throw Error(r.error.message);
}
return r.result;
});
return this.client
.call(this.requests.ethCall(txObj))
.then(isValidCallRequest)
.then(response => response.result);
}
public getBalance(address: string): Promise<Wei> {
return this.client
.call(this.requests.getBalance(address))
.then(errorOrResult)
.then(result => Wei(result));
.then(isValidGetBalance)
.then(({ result }) => Wei(result));
}
public estimateGas(transaction: TransactionWithoutGas): Promise<Wei> {
return this.client
.call(this.requests.estimateGas(transaction))
.then(errorOrResult)
.then(result => Wei(result));
.then(isValidEstimateGas)
.then(({ result }) => Wei(result));
}
public getTokenBalance(address: string, token: Token): Promise<TokenValue> {
public getTokenBalance(
address: string,
token: Token
): Promise<{ balance: TokenValue; error: string | null }> {
return this.client
.call(this.requests.getTokenBalance(address, token))
.then(response => {
if (response.error) {
// TODO - Error handling
return TokenValue('0');
}
return TokenValue(response.result);
});
.then(isValidTokenBalance)
.then(({ result }) => {
return {
balance: TokenValue(result),
error: null
};
})
.catch(err => ({
balance: TokenValue('0'),
error: 'Caught error:' + err
}));
}
public getTokenBalances(
address: string,
tokens: Token[]
): Promise<TokenValue[]> {
): Promise<{ balance: TokenValue; error: string | null }[]> {
return this.client
.batch(tokens.map(t => this.requests.getTokenBalance(address, t)))
.then(response => {
return response.map(item => {
// FIXME wrap in maybe-like
if (item.error) {
return TokenValue('0');
.then(response =>
response.map(item => {
if (isValidTokenBalance(item)) {
return {
balance: TokenValue(item.result),
error: null
};
} else {
return {
balance: TokenValue('0'),
error: 'Invalid object shape'
};
}
return TokenValue(item.result);
});
});
// TODO - Error handling
})
);
}
public getTransactionCount(address: string): Promise<string> {
return this.client
.call(this.requests.getTransactionCount(address))
.then(errorOrResult);
.then(isValidTransactionCount)
.then(({ result }) => result);
}
public getCurrentBlock(): Promise<string> {
return this.client
.call(this.requests.getCurrentBlock())
.then(errorOrResult)
.then(result => new BN(stripHexPrefix(result)).toString());
.then(isValidCurrentBlock)
.then(({ result }) => new BN(stripHexPrefix(result)).toString());
}
public sendRawTx(signedTx: string): Promise<string> {
return this.client
.call(this.requests.sendRawTx(signedTx))
.then(response => {
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
.then(isValidRawTxApi)
.then(({ result }) => {
return result;
});
}
}

View File

@ -57,7 +57,13 @@ export default class Web3Node implements INode {
);
}
public getTokenBalance(address: string, token: Token): Promise<TokenValue> {
public getTokenBalance(
address: string,
token: Token
): Promise<{
balance: TokenValue;
error: string | null;
}> {
return new Promise(resolve => {
this.web3.eth.call(
{
@ -68,10 +74,10 @@ export default class Web3Node implements INode {
(err, res) => {
if (err) {
// TODO - Error handling
return resolve(TokenValue('0'));
return resolve({ balance: TokenValue('0'), error: err });
}
// web3 returns string
resolve(TokenValue(res));
resolve({ balance: TokenValue(res), error: null });
}
);
});
@ -80,11 +86,14 @@ export default class Web3Node implements INode {
public getTokenBalances(
address: string,
tokens: Token[]
): Promise<TokenValue[]> {
): Promise<{ balance: TokenValue; error: string | null }[]> {
return new Promise(resolve => {
const batch = this.web3.createBatch();
const totalCount = tokens.length;
const returnArr = new Array<TokenValue>(totalCount);
const returnArr = new Array<{
balance: TokenValue;
error: string | null;
}>(totalCount);
let finishCount = 0;
tokens.forEach((token, index) =>
@ -104,10 +113,16 @@ export default class Web3Node implements INode {
function finish(index, err, res) {
if (err) {
// TODO - Error handling
returnArr[index] = TokenValue('0');
returnArr[index] = {
balance: TokenValue('0'),
error: err
};
} else {
// web3 returns string
returnArr[index] = TokenValue(res);
returnArr[index] = {
balance: TokenValue(res),
error: err
};
}
finishCount++;

View File

@ -2,6 +2,8 @@ import { toChecksumAddress } from 'ethereumjs-util';
import { RawTransaction } from 'libs/transaction';
import WalletAddressValidator from 'wallet-address-validator';
import { normalise } from './ens';
import { Validator } from 'jsonschema';
import { JsonRpcResponse } from './nodes/rpc/types';
export function isValidETHAddress(address: string): boolean {
if (!address) {
@ -177,3 +179,67 @@ export const isValidByteCode = (byteCode: string) =>
export const isValidAbiJson = (abiJson: string) =>
abiJson && abiJson.startsWith('[') && abiJson.endsWith(']');
// JSONSchema Validations for Rpc responses
const v = new Validator();
export const schema = {
RpcNode: {
type: 'object',
additionalProperties: false,
properties: {
jsonrpc: { type: 'string' },
id: { oneOf: [{ type: 'string' }, { type: 'integer' }] },
result: { type: 'string' },
status: { type: 'string' },
message: { type: 'string', maxLength: 2 }
}
}
};
function isValidResult(response: JsonRpcResponse, schemaFormat): boolean {
return v.validate(response, schemaFormat).valid;
}
function formatErrors(response: JsonRpcResponse, apiType: string) {
if (response.error) {
return `${response.error.message} ${response.error.data}`;
}
return `Invalid ${apiType} Error`;
}
const isValidEthCall = (response: JsonRpcResponse, schemaType) => (
apiName,
cb?
) => {
if (!isValidResult(response, schemaType)) {
if (cb) {
return cb(response);
}
throw new Error(formatErrors(response, apiName));
}
return response;
};
export const isValidGetBalance = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Get Balance');
export const isValidEstimateGas = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Estimate Gas');
export const isValidCallRequest = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Call Request');
export const isValidTokenBalance = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Token Balance', () => ({
result: 'Failed'
}));
export const isValidTransactionCount = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Transaction Count');
export const isValidCurrentBlock = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Current Block');
export const isValidRawTxApi = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Raw Tx');

View File

@ -15,7 +15,10 @@ export interface State {
// in ETH
balance: Balance | { wei: null };
tokens: {
[key: string]: TokenValue;
[key: string]: {
balance: TokenValue;
error: string | null;
};
};
transactions: BroadcastTransactionStatus[];
}

View File

@ -14,6 +14,7 @@ export interface TokenBalance {
balance: TokenValue;
custom: boolean;
decimal: number;
error: string | null;
}
export type MergedToken = Token & {
@ -38,8 +39,11 @@ export function getTokenBalances(state: AppState): TokenBalance[] {
return tokens.map(t => ({
symbol: t.symbol,
balance: state.wallet.tokens[t.symbol]
? state.wallet.tokens[t.symbol]
? state.wallet.tokens[t.symbol].balance
: TokenValue('0'),
error: state.wallet.tokens[t.symbol]
? state.wallet.tokens[t.symbol].error
: null,
custom: t.custom,
decimal: t.decimal
}));

View File

@ -26,8 +26,9 @@ export default function(
.join(',');
const popup = window.open('about:blank', 'printWindow', featuresStr);
popup.document.open();
popup.document.write(`
if (popup) {
popup.document.open();
popup.document.write(`
<html>
<head>
<style>${options.styles}</style>
@ -50,4 +51,5 @@ export default function(
</body>
</html>
`);
}
}

View File

@ -1,2 +1,17 @@
global.fetch = require('node-fetch')
window.BASE_API = 'http://localhost:4000/api/v1'
'use strict';
var realFetch = require('node-fetch');
module.exports = function(url, options) {
if (/^\/\//.test(url)) {
url = 'https:' + url;
}
return realFetch.call(this, url, options);
};
if (!global.fetch) {
global.fetch = module.exports;
global.Response = realFetch.Response;
global.Headers = realFetch.Headers;
global.Request = realFetch.Request;
}
window.BASE_API = 'http://localhost:4000/api/v1';

View File

@ -20,6 +20,7 @@
"font-awesome": "4.7.0",
"hdkey": "0.7.1",
"idna-uts46": "1.1.0",
"jsonschema": "1.2.0",
"lodash": "4.17.4",
"moment": "2.19.3",
"qrcode": "1.0.0",
@ -104,6 +105,7 @@
"types-rlp": "0.0.1",
"typescript": "2.5.2",
"url-loader": "0.6.2",
"url-search-params-polyfill": "2.0.1",
"webpack": "3.8.1",
"webpack-dev-middleware": "1.12.2",
"webpack-hot-middleware": "2.21.0"

View File

@ -0,0 +1,5 @@
module.exports = {
RpcNodes: ['eth_mew', 'etc_epool', 'etc_epool', 'rop_mew'],
EtherscanNodes: ['eth_ethscan', 'kov_ethscan', 'rin_ethscan'],
InfuraNodes: ['eth_infura', 'rop_infura', 'rin_infura']
};

93
spec/config/data.spec.ts Normal file
View File

@ -0,0 +1,93 @@
import { NODES, NodeConfig } from '../../common/config/data';
import { RPCNode } from '../../common/libs/nodes';
import { Validator } from 'jsonschema';
import { schema } from '../../common/libs/validators';
import 'url-search-params-polyfill';
import EtherscanNode from 'libs/nodes/etherscan';
import InfuraNode from 'libs/nodes/infura';
import RpcNodeTestConfig from './RpcNodeTestConfig';
const v = new Validator();
const validRequests = {
address: '0x72948fa4200d10ffaa7c594c24bbba6ef627d4a3',
transaction: {
data: '',
from: '0x72948fa4200d10ffaa7c594c24bbba6ef627d4a3',
to: '0x72948fa4200d10ffaa7c594c24bbba6ef627d4a3',
value: '0xde0b6b3a7640000'
},
token: {
address: '0x4156d3342d5c385a87d264f90653733592000581',
symbol: 'SALT',
decimal: 8
}
};
const testGetBalance = (n: RPCNode) => {
return n.client
.call(n.requests.getBalance(validRequests.address))
.then(data => v.validate(data, schema.RpcNode));
};
const testEstimateGas = (n: RPCNode) => {
return n.client
.call(n.requests.estimateGas(validRequests.transaction))
.then(data => v.validate(data, schema.RpcNode));
};
const testGetTokenBalance = (n: RPCNode) => {
const { address, token } = validRequests;
return n.client
.call(n.requests.getTokenBalance(address, token))
.then(data => v.validate(data, schema.RpcNode));
};
const RPCTests = {
getBalance: testGetBalance,
estimateGas: testEstimateGas,
getTokenBalance: testGetTokenBalance
};
function testRpcRequests(node: RPCNode, service: string) {
Object.keys(RPCTests).forEach(testType => {
describe(`RPC (${service}) should work`, () => {
it(
`RPC: ${testType} ${service}`,
() => {
return RPCTests[testType](node).then(d =>
expect(d.valid).toBeTruthy()
);
},
10000
);
});
});
}
const mapNodeEndpoints = (nodes: { [key: string]: NodeConfig }) => {
const { RpcNodes, EtherscanNodes, InfuraNodes } = RpcNodeTestConfig;
RpcNodes.forEach(n => {
testRpcRequests(
nodes[n].lib as RPCNode,
`${nodes[n].service} ${nodes[n].network}`
);
});
EtherscanNodes.forEach(n => {
testRpcRequests(
nodes[n].lib as EtherscanNode,
`${nodes[n].service} ${nodes[n].network}`
);
});
InfuraNodes.forEach(n => {
testRpcRequests(
nodes[n].lib as InfuraNode,
`${nodes[n].service} ${nodes[n].network}`
);
});
};
mapNodeEndpoints(NODES);

View File

@ -56,6 +56,7 @@ exports[`render snapshot 1`] = `
"client": RPCClient {
"batch": [Function],
"call": [Function],
"createHeaders": [Function],
"decorateRequest": [Function],
"endpoint": "https://api.myetherapi.com/rop",
"headers": Object {},

View File

@ -60,7 +60,16 @@ describe('wallet reducer', () => {
});
it('should handle WALLET_SET_TOKEN_BALANCES', () => {
const tokenBalances = { OMG: TokenValue('20') };
const tokenBalances = {
OMG: {
balance: TokenValue('20'),
error: null
},
WTT: {
balance: TokenValue('0'),
error: 'The request failed to execute'
}
};
expect(
wallet(undefined, walletActions.setTokenBalances(tokenBalances))
).toEqual({

View File

@ -178,6 +178,7 @@ Object {
"client": RPCClient {
"batch": [Function],
"call": [Function],
"createHeaders": [Function],
"decorateRequest": [Function],
"endpoint": "",
"headers": Object {},
@ -202,6 +203,7 @@ Object {
"client": RPCClient {
"batch": [Function],
"call": [Function],
"createHeaders": [Function],
"decorateRequest": [Function],
"endpoint": "",
"headers": Object {},