Skip to content

Commit

Permalink
Add initial provider API tests for Infura client
Browse files Browse the repository at this point in the history
We are working on migrating the extension to a unified network
controller, but before we do so we want to extract some of the existing
pieces, specifically `createInfuraClient` and `createJsonRpcClient`,
which provide the majority of the behavior exhibited within the provider
API that the existing NetworkController exposes. This necessitates that
we understand and test that behavior as a whole.

With that in mind, this commit starts with the Infura-specific network
client and adds some initial functional tests for `createInfuraClient`,
specifically covering three pieces of middleware provided by
`eth-json-rpc-middleware`: `createNetworkAndChainIdMiddleware`,
`createBlockCacheMiddleware`, and `createBlockRefMiddleware`.

These tests exercise logic that originate from multiple different places
and combine in sometimes surprising ways, and as a result, understanding
the nature of the tests can be tricky. I've tried to explain the logic
(both of the implementation and the tests) via comments. Additionally,
debugging why a certain test is failing is not the most fun thing in the
world, so to aid with this, I've added some logging to the underlying
packages used when a request passes through the middleware stack.
Because some middleware change the request being made, or make new
requests altogether, this greatly helps to peel back the curtain, as
failures from Nock do not supply much meaningful information on their
own. This logging is disabled by default, but can be activated by
setting `DEBUG_PROVIDER_TESTS=1` alongside the `jest` command.

Also note that we are using a custom version of `eth-block-tracker`
which provides a `destroy` method, which we use in tests to properly
ensure that the block tracker is stopped before moving on to the next
step. This change comes from [this PR][1] which has yet to be merged.

[1]: MetaMask/eth-block-tracker#106
  • Loading branch information
mcmire committed Aug 12, 2022
1 parent 86f02e4 commit 440cbba
Show file tree
Hide file tree
Showing 8 changed files with 1,284 additions and 12 deletions.
834 changes: 834 additions & 0 deletions app/scripts/controllers/network/createInfuraClient.test.js

Large diffs are not rendered by default.

272 changes: 272 additions & 0 deletions app/scripts/controllers/network/provider-test-helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import nock from 'nock';
import sinon from 'sinon';
import { JsonRpcEngine } from 'json-rpc-engine';
import { providerFromEngine } from 'eth-json-rpc-middleware';
import EthQuery from 'eth-query';
import createInfuraClient from './createInfuraClient';

/**
* @typedef {import('nock').Scope} NockScope
*
* A object returned by `nock(...)` for mocking requests to a particular base
* URL.
*/

/**
* @typedef {{makeRpcCall: (request: Partial<JsonRpcRequest>) => Promise<any>, makeRpcCallsInSeries: (requests: Partial<JsonRpcRequest>[]) => Promise<any>}} InfuraClient
*
* Provides methods to interact with the suite of middleware that
* `createInfuraClient` exposes.
*/

/**
* @typedef {{network: string}} WithInfuraClientOptions
*
* The options bag that `withInfuraClient` takes.
*/

/**
* @typedef {(client: InfuraClient) => Promise<any>} WithInfuraClientCallback
*
* The callback that `withInfuraClient` takes.
*/

/**
* @typedef {[WithInfuraClientOptions, WithInfuraClientCallback] | [WithInfuraClientCallback]} WithInfuraClientArgs
*
* The arguments to `withInfuraClient`.
*/

/**
* @typedef {{ nockScope: NockScope, blockNumber: string }} MockNextBlockTrackerRequestOptions
*
* The options to `mockNextBlockTrackerRequest`.
*/

/**
* @typedef {{ nockScope: NockScope, request: object, response: object, delay?: number }} MockSuccessfulInfuraRpcCallOptions
*
* The options to `mockSuccessfulInfuraRpcCall`.
*/

/**
* @typedef {{mockNextBlockTrackerRequest: (options: Omit<MockNextBlockTrackerRequestOptions, 'nockScope'>) => void, mockSuccessfulInfuraRpcCall: (options: Omit<MockSuccessfulInfuraRpcCallOptions, 'nockScope'>) => NockScope}} InfuraCommunications
*
* Provides methods to mock different kinds of requests to Infura.
*/

/**
* @typedef {{network: string}} InfuraCommunicationsOptions
*
* The options bag that `mockingInfuraCommunications` takes.
*/

/**
* @typedef {(client: InfuraClient) => Promise<any>} InfuraCommunicationsOptionsCallback
*
* The callback that `mockingInfuraCommunications` takes.
*/

/**
* @typedef {[MockingInfuraCommunicationsOptions, MockingInfuraCommunicationsCallback] | [WithInfuraClientCallback]} MockingInfuraCommunicationsArgs
*
* The arguments to `mockingInfuraCommunications`.
*/

const INFURA_PROJECT_ID = 'abc123';
const DEFAULT_LATEST_BLOCK_NUMBER = '0x42';

/**
* If you're having trouble writing a test and you're wondering why the test
* keeps failing, you can set `process.env.DEBUG_PROVIDER_TESTS` to `1`. This
* will turn on some extra logging.
*
* @param {any[]} args - The arguments that `console.log` takes.
*/
function debug(...args) {
if (process.env.DEBUG_PROVIDER_TESTS === '1') {
console.log(...args);
}
}

/**
* Builds a Nock scope object for mocking requests to a particular network that
* Infura supports.
*
* @param {object} options - The options.
* @param {string} options.network - The Infura network you're testing with
* (default: "mainnet").
* @returns {NockScope} The nock scope.
*/
function buildScopeForMockingInfuraRequests({ network = 'mainnet' } = {}) {
return nock(`https://${network}.infura.io`).filteringRequestBody((body) => {
const copyOfBody = JSON.parse(body);
// some ids are random, so remove them entirely from the request to
// make it possible to mock these requests
delete copyOfBody.id;
return JSON.stringify(copyOfBody);
});
}

/**
* Mocks the next request for the latest block that the block tracker will make.
*
* @param {MockNextBlockTrackerRequestOptions} args - The arguments.
* @param {NockScope} args.nockScope - A nock scope (a set of mocked requests
* scoped to a certain base URL).
* @param {string} args.blockNumber - The block number that the block tracker
* should report, as a 0x-prefixed hex string.
*/
async function mockNextBlockTrackerRequest({
nockScope,
blockNumber = DEFAULT_LATEST_BLOCK_NUMBER,
}) {
await mockSuccessfulInfuraRpcCall({
nockScope,
request: { method: 'eth_blockNumber', params: [] },
response: { result: blockNumber },
});
}

/**
* Mocks a JSON-RPC request sent to Infura with the given response.
*
* @param {MockSuccessfulInfuraRpcCallOptions} args - The arguments.
* @param {NockScope} args.nockScope - A nock scope (a set of mocked requests
* scoped to a certain base URL).
* @param {object} args.request - The request data.
* @param {object} args.response - The response that the request should have.
* @param {number} args.delay - The amount of time that should pass before the
* request resolves with the response.
* @returns {NockScope} The nock scope.
*/
function mockSuccessfulInfuraRpcCall({ nockScope, request, response, delay }) {
// eth-query always passes `params`, so even if we don't supply this property
// for consistency with makeRpcCall, assume that the `body` contains it
const { method, params = [], ...rest } = request;
const completeResponse = {
id: 1,
jsonrpc: '2.0',
...response,
};
const nockRequest = nockScope.post(`/v3/${INFURA_PROJECT_ID}`, {
jsonrpc: '2.0',
method,
params,
...rest,
});

if (delay !== undefined) {
nockRequest.delay(delay);
}

return nockRequest.reply(200, completeResponse);
}

/**
* Makes a JSON-RPC call through the given eth-query object.
*
* @param {any} ethQuery - The eth-query object.
* @param {object} request - The request data.
* @returns {Promise<any>} A promise that either resolves with the result from
* the JSON-RPC response if it is successful or rejects with the error from the
* JSON-RPC response otherwise.
*/
function makeRpcCall(ethQuery, request) {
return new Promise((resolve, reject) => {
debug('[makeRpcCall] making request', request);
ethQuery.sendAsync(request, (error, result) => {
debug('[makeRpcCall > ethQuery handler] error', error, 'result', result);
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}

/**
* Sets up request mocks for requests to Infura.
*
* @param {MockingInfuraCommunicationsArgs} args - Either an options bag + a
* function, or just a function. The options bag, at the moment, may contain
* `network` (that is, the Infura network; defaults to "mainnet"). The function
* is called with an object that allows you to mock different kinds of requests.
* @returns {Promise<any>} The return value of the given function.
*/
export async function mockingInfuraCommunications(...args) {
const [options, fn] = args.length === 2 ? args : [{}, args[0]];
const { network = 'mainnet' } = options;

const nockScope = buildScopeForMockingInfuraRequests({ network });
const curriedMockNextBlockTrackerRequest = (localOptions) =>
mockNextBlockTrackerRequest({ nockScope, ...localOptions });
const curriedMockSuccessfulInfuraRpcCall = (localOptions) =>
mockSuccessfulInfuraRpcCall({ nockScope, ...localOptions });

try {
return await fn({
mockNextBlockTrackerRequest: curriedMockNextBlockTrackerRequest,
mockSuccessfulInfuraRpcCall: curriedMockSuccessfulInfuraRpcCall,
});
} finally {
nock.isDone();
nock.cleanAll();
}
}

/**
* Builds a provider from the Infura middleware along with a block tracker, runs
* the given function with those two things, and then ensures the block tracker
* is stopped at the end.
*
* @param {WithInfuraClientArgs} args - Either an options bag + a function, or
* just a function. The options bag, at the moment, may contain `network` (that
* is, the Infura network; defaults to "mainnet"). The function is called with
* an object that allows you to interact with the client via a couple of methods
* on that object.
* @returns {Promise<any>} The return value of the given function.
*/
export async function withInfuraClient(...args) {
const [options, fn] = args.length === 2 ? args : [{}, args[0]];
const { network = 'mainnet' } = options;

const { networkMiddleware, blockTracker } = createInfuraClient({
network,
projectId: INFURA_PROJECT_ID,
});

const engine = new JsonRpcEngine();
engine.push(networkMiddleware);
const provider = providerFromEngine(engine);

const ethQuery = new EthQuery(provider);
const curriedMakeRpcCall = (request) => makeRpcCall(ethQuery, request);
const makeRpcCallsInSeries = async (requests) => {
const responses = [];
for (const request of requests) {
responses.push(await curriedMakeRpcCall(request));
}
return responses;
};

// Faking timers ends up doing two things:
// 1. Halting the block tracker (which depends on `setTimeout` to periodically
// request the latest block) set up in `eth-json-rpc-middleware`
// 2. Halting the retry logic in `eth-json-rpc-infura` (which also depends on
// `setTimeout`)
const clock = sinon.useFakeTimers();

try {
return await fn({
makeRpcCall: curriedMakeRpcCall,
makeRpcCallsInSeries,
clock,
});
} finally {
await blockTracker.destroy();

clock.restore();
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@
"debounce-stream": "^2.0.0",
"deep-freeze-strict": "1.1.1",
"end-of-stream": "^1.4.4",
"eth-block-tracker": "^5.0.1",
"eth-block-tracker": "MetaMask/eth-block-tracker#2f0b9b2abec07570d63d9485ea1466910eb3e059",
"eth-ens-namehash": "^2.0.8",
"eth-json-rpc-filters": "^4.2.1",
"eth-json-rpc-infura": "^5.1.0",
Expand Down
26 changes: 26 additions & 0 deletions patches/eth-block-tracker+5.0.1.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
diff --git a/node_modules/eth-block-tracker/dist/PollingBlockTracker.js b/node_modules/eth-block-tracker/dist/PollingBlockTracker.js
index b0c1ab8..b4d88b0 100644
--- a/node_modules/eth-block-tracker/dist/PollingBlockTracker.js
+++ b/node_modules/eth-block-tracker/dist/PollingBlockTracker.js
@@ -9,6 +9,11 @@ const pify_1 = __importDefault(require("pify"));
const BaseBlockTracker_1 = require("./BaseBlockTracker");
const createRandomId = (0, json_rpc_random_id_1.default)();
const sec = 1000;
+const debug = (...args) => {
+ if (process.env.DEBUG_PROVIDER_TESTS === '1') {
+ console.log(...args)
+ }
+}
class PollingBlockTracker extends BaseBlockTracker_1.BaseBlockTracker {
constructor(opts = {}) {
var _a;
@@ -76,7 +81,9 @@ class PollingBlockTracker extends BaseBlockTracker_1.BaseBlockTracker {
if (this._setSkipCacheFlag) {
req.skipCache = true;
}
+ debug('[eth-block-tracker > PollingBlockTracker] making request', req);
const res = await (0, pify_1.default)((cb) => this._provider.sendAsync(req, cb))();
+ debug('[eth-block-tracker > PollingBlockTracker] got response', res);
if (res.error) {
throw new Error(`PollingBlockTracker - encountered error fetching block:\n${res.error.message}`);
}
24 changes: 24 additions & 0 deletions patches/eth-json-rpc-infura+5.1.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
diff --git a/node_modules/eth-json-rpc-infura/src/index.js b/node_modules/eth-json-rpc-infura/src/index.js
index 72fbdd4..7cfa3d4 100644
--- a/node_modules/eth-json-rpc-infura/src/index.js
+++ b/node_modules/eth-json-rpc-infura/src/index.js
@@ -1,6 +1,11 @@
const createAsyncMiddleware = require('json-rpc-engine/src/createAsyncMiddleware')
const { ethErrors } = require('eth-rpc-errors')
const fetch = require('node-fetch')
+const debug = (...args) => {
+ if (process.env.DEBUG_PROVIDER_TESTS === '1') {
+ console.log(...args)
+ }
+}

const RETRIABLE_ERRORS = [
// ignore server overload errors
@@ -43,6 +48,7 @@ function createInfuraMiddleware (opts = {}) {
// an error was caught while performing the request
// if not retriable, resolve with the encountered error
if (!isRetriableError(err)) {
+ debug('[eth-json-rpc-infura] Non-retriable request error encountered. req = ', req, ', res = ', res, 'error = ', err)
// abort with error
throw err
}
Loading

0 comments on commit 440cbba

Please sign in to comment.