Repository: alchemyplatform/alchemy-sdk-js (ts)

Issue:
FR: Add support for batched requests
Implement JsonRpcBatchProvider into the existing provider to facilitate making batch requests. This will unblock adding syntactic sugar on top of existing methods to make it easier to combine responses from different endpoints into a single one.

Pull request description:
Add support for automatic batching in json-rpc requests
Fixes #159.

This PR adds support for automatic batching of requests based loosely on ethers.js's [JsonRpcBatchProvider](https://github.com/ethers-io/ethers.js/blob/ce8f1e4015c0f27bf178238770b1325136e3351a/packages/providers/src.ts/json-rpc-batch-provider.ts). The main logic is encapsulated in the `RequestBatcher` class and we additionally enforce a max batch size of 100.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b921de45..eaf17077 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
 ### Minor Changes
 
 - Added support for ENS resolution to the `NotifyNamespace` and `WebsocketNamespace` methods.
+- Added the `batchRequests` setting in `AlchemySettings`. Enabling this setting will automatically batch and send json-rpc requests to reduce network overhead.
 - Included new export for the `Interface` class from ethers to simplify encoding ABI when using `eth_call`.
 
 ## 2.2.1
diff --git a/src/api/alchemy-config.ts b/src/api/alchemy-config.ts
index 96b62155..c2f63874 100644
--- a/src/api/alchemy-config.ts
+++ b/src/api/alchemy-config.ts
@@ -27,6 +27,9 @@ export class AlchemyConfig {
   /** The maximum number of retries to perform. */
   readonly maxRetries: number;
 
+  /** Setting to enable automatic batching on json-rpc requests. Defaults to false.*/
+  readonly batchRequests: boolean;
+
   /**
    * The optional hardcoded URL to send requests to instead of using the network
    * and apiKey.
@@ -58,6 +61,7 @@ export class AlchemyConfig {
     this.maxRetries = config?.maxRetries || DEFAULT_MAX_RETRIES;
     this.url = config?.url;
     this.authToken = config?.authToken;
+    this.batchRequests = config?.batchRequsets || false;
   }
 
   /**
diff --git a/src/api/alchemy-provider.ts b/src/api/alchemy-provider.ts
index 938a940a..d4335fc2 100644
--- a/src/api/alchemy-provider.ts
+++ b/src/api/alchemy-provider.ts
@@ -10,6 +10,8 @@ import {
 } from '@ethersproject/providers';
 import { ConnectionInfo, fetchJson } from '@ethersproject/web';
 
+import { JsonRpcRequest, JsonRpcResponse } from '../internal/internal-types';
+import { RequestBatcher } from '../internal/request-batcher';
 import { Network } from '../types/types';
 import {
   CustomNetworks,
@@ -38,6 +40,13 @@ export class AlchemyProvider
 {
   readonly apiKey: string;
   readonly maxRetries: number;
+  readonly batchRequests: boolean;
+
+  /**
+   * VISIBLE ONLY FOR TESTING
+   *@internal
+   */
+  readonly batcher: RequestBatcher;
 
   /** @internal */
   constructor(config: AlchemyConfig) {
@@ -68,6 +77,17 @@ export class AlchemyProvider
 
     this.apiKey = config.apiKey;
     this.maxRetries = config.maxRetries;
+    this.batchRequests = config.batchRequests;
+
+    // TODO: support individual headers when calling batch
+    const batcherConnection = { ...this.connection };
+    batcherConnection.headers!['Alchemy-Ethers-Sdk-Method'] = 'batchSend';
+    const sendBatchFn = (
+      requests: JsonRpcRequest[]
+    ): Promise<JsonRpcResponse[]> => {
+      return fetchJson(batcherConnection, JSON.stringify(requests));
+    };
+    this.batcher = new RequestBatcher(sendBatchFn);
   }
 
   /**
@@ -218,7 +238,12 @@ export class AlchemyProvider
    *
    * @internal
    */
-  _send(method: string, params: Array<any>, methodName: string): Promise<any> {
+  _send(
+    method: string,
+    params: Array<any>,
+    methodName: string,
+    forceBatch = false
+  ): Promise<any> {
     const request = {
       method,
       params,
@@ -226,6 +251,15 @@ export class AlchemyProvider
       jsonrpc: '2.0'
     };
 
+    // START MODIFIED CODE
+    const connection = { ...this.connection };
+    connection.headers!['Alchemy-Ethers-Sdk-Method'] = methodName;
+
+    if (this.batchRequests || forceBatch) {
+      return this.batcher.enqueueRequest(request as JsonRpcRequest);
+    }
+    // END MODIFIED CODE
+
     this.emit('debug', {
       action: 'request',
       request: deepCopy(request),
@@ -239,11 +273,6 @@ export class AlchemyProvider
       return this._cache[method];
     }
 
-    // START MODIFIED CODE
-    const connection = { ...this.connection };
-    connection.headers!['Alchemy-Ethers-Sdk-Method'] = methodName;
-    // END MODIFIED CODE
-
     const result = fetchJson(
       this.connection,
       JSON.stringify(request),
diff --git a/src/internal/request-batcher.ts b/src/internal/request-batcher.ts
new file mode 100644
index 00000000..629110f7
--- /dev/null
+++ b/src/internal/request-batcher.ts
@@ -0,0 +1,119 @@
+import { JsonRpcRequest, JsonRpcResponse } from './internal-types';
+
+/** Maximum size of a batch on the rpc provider. */
+const DEFAULT_MAX_REQUEST_BATCH_SIZE = 100;
+
+/** Timeout interval before the pending batch is sent. */
+const DEFAULT_REQUEST_BATCH_DELAY_MS = 10;
+
+/**
+ * Internal class to enqueue requests and automatically send/process batches.
+ *
+ * The underlying batching mechanism is loosely based on ethers.js's
+ * `JsonRpcBatchProvider`.
+ *
+ * @internal
+ */
+export class RequestBatcher {
+  /** Timeout timer that periodically sends the pending batch. */
+  private pendingBatchTimer: NodeJS.Timer | undefined;
+
+  /**
+   * Array of enqueued requests along with the constructed promise handlers for
+   * each request.
+   */
+  private pendingBatch: Array<BatchRequest> = [];
+
+  constructor(
+    private readonly sendBatchFn: SendBatchFn,
+    private readonly maxBatchSize = DEFAULT_MAX_REQUEST_BATCH_SIZE
+  ) {}
+
+  /**
+   * Enqueues the provided request. The batch is immediately sent if the maximum
+   * batch size is reached. Otherwise, the request is enqueued onto a batch that
+   * is sent after 10ms.
+   *
+   * Returns a promise that resolves with the result of the request.
+   */
+  async enqueueRequest(request: JsonRpcRequest): Promise<any> {
+    const inflightRequest: BatchRequest = {
+      request,
+      resolve: undefined,
+      reject: undefined
+    };
+
+    const promise = new Promise((resolve, reject) => {
+      inflightRequest.resolve = resolve;
+      inflightRequest.reject = reject;
+    });
+
+    this.pendingBatch.push(inflightRequest);
+
+    if (this.pendingBatch.length === this.maxBatchSize) {
+      // Send batch immediately if we are at the maximum batch size.
+      void this.sendBatchRequest();
+    } else if (!this.pendingBatchTimer) {
+      // Schedule batch for next event loop + short duration
+      this.pendingBatchTimer = setTimeout(
+        () => this.sendBatchRequest(),
+        DEFAULT_REQUEST_BATCH_DELAY_MS
+      );
+    }
+
+    return promise;
+  }
+
+  /**
+   * Sends the currently queued batches and resets the batch and timer. Processes
+   * the batched response results back to the original promises.
+   */
+  private async sendBatchRequest(): Promise<void> {
+    // Get the current batch and clear it, so new requests
+    // go into the next batch
+    const batch = this.pendingBatch;
+    this.pendingBatch = [];
+    if (this.pendingBatchTimer) {
+      clearTimeout(this.pendingBatchTimer);
+      this.pendingBatchTimer = undefined;
+    }
+
+    // Get the request as an array of requests
+    const request = batch.map(inflight => inflight.request);
+
+    return this.sendBatchFn(request).then(
+      result => {
+        // For each result, feed it to the correct Promise, depending
+        // on whether it was a success or error
+        batch.forEach((inflightRequest, index) => {
+          const payload = result[index];
+          if (payload.error) {
+            const error = new Error(payload.error.message);
+            (error as any).code = payload.error.code;
+            (error as any).data = payload.error.data;
+            inflightRequest.reject!(error);
+          } else {
+            inflightRequest.resolve!(payload.result);
+          }
+        });
+      },
+      error => {
+        batch.forEach(inflightRequest => {
+          inflightRequest.reject!(error);
+        });
+      }
+    );
+  }
+}
+
+/** Function type to match the `fetchJson` function in ethers. */
+type SendBatchFn = (reqs: JsonRpcRequest[]) => Promise<JsonRpcResponse[]>;
+
+/**
+ * Internal interface to represent a request on a batch along with the promises to resolve it.
+ */
+interface BatchRequest {
+  request: JsonRpcRequest;
+  resolve?: (result: any) => void;
+  reject?: (error: Error) => void;
+}
diff --git a/src/types/types.ts b/src/types/types.ts
index 26bebfe9..50471763 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -40,6 +40,14 @@ export interface AlchemySettings {
    * in the Alchemy Dashboard on the Notify tab.
    */
   authToken?: string;
+
+  /**
+   * Optional setting that automatically batches and sends json-rpc requests for higher
+   * throughput and reduced network IO. Defaults to false.
+   *
+   * This implementation is based on the `JsonRpcBatchProvider` in ethers.
+   */
+  batchRequsets?: boolean;
 }
 
 /**


Test patch:
diff --git a/test/integration/provider.test.ts b/test/integration/provider.test.ts
index b99a7412..3846fd01 100644
--- a/test/integration/provider.test.ts
+++ b/test/integration/provider.test.ts
@@ -78,7 +78,6 @@ describe('AlchemyProvider', () => {
         hashesOnly: true
       },
       (res: any) => {
-        console.log(res);
         expect(res.transaction).toBeDefined();
         expect(typeof res.transaction.hash).toEqual('string');
         if (eventCount === 5) {
@@ -148,6 +147,18 @@ describe('AlchemyProvider', () => {
     });
   });
 
+  it('batching mode resolves promises', async () => {
+    alchemy = new Alchemy({
+      apiKey: process.env.ALCHEMY_API_KEY,
+      batchRequsets: true
+    });
+
+    const p1 = alchemy.core.getGasPrice();
+    const p2 = alchemy.core.getBlockNumber();
+    await p1;
+    await p2;
+  });
+
   // // TODO(ethers): Write tests to make sure that the SDK's provider instance
   // // has the correct network mappings compared to Ethers.
   // it('network mappings should be correct', () => {});
diff --git a/test/unit/provider.test.ts b/test/unit/provider.test.ts
index 900ae4e7..53c97c7b 100644
--- a/test/unit/provider.test.ts
+++ b/test/unit/provider.test.ts
@@ -1,4 +1,6 @@
 import { Alchemy, Network } from '../../src';
+import { RequestBatcher } from '../../src/internal/request-batcher';
+import { Mocked } from '../test-util';
 
 describe('AlchemyProvider', () => {
   describe('translates Network to ethers', () => {
@@ -15,6 +17,39 @@ describe('AlchemyProvider', () => {
     }
   });
 
+  describe('supports batching', () => {
+    let alchemy: Alchemy;
+    let batcher: Mocked<RequestBatcher>;
+    beforeEach(async () => {
+      alchemy = new Alchemy({
+        batchRequsets: true
+      });
+      const provider = await alchemy.config.getProvider();
+      batcher = provider.batcher as Mocked<RequestBatcher>;
+      batcher.enqueueRequest = jest.fn();
+    });
+    it('uses batcher when batching is enabled', async () => {
+      const mockReturn1 = {
+        jsonrpc: '2.0',
+        id: 1,
+        result: 'mock-return1'
+      };
+      const mockReturn2 = {
+        jsonrpc: '2.0',
+        id: 2,
+        result: 'mock-return2'
+      };
+      batcher.enqueueRequest
+        .mockResolvedValueOnce(mockReturn1)
+        .mockResolvedValueOnce(mockReturn2);
+      const p1 = alchemy.core.send('eth_blockNumber', []);
+      const p2 = alchemy.core.send('eth_getBalance', []);
+      expect(await p1).toEqual(mockReturn1);
+      expect(await p2).toEqual(mockReturn2);
+      expect(batcher.enqueueRequest.mock.calls.length).toEqual(2);
+    });
+  });
+
   it('accepts a URL hard override', async () => {
     const alchemy = new Alchemy({
       apiKey: 'demo-key',
diff --git a/test/unit/request-batcher.test.ts b/test/unit/request-batcher.test.ts
new file mode 100644
index 00000000..3ee2f37c
--- /dev/null
+++ b/test/unit/request-batcher.test.ts
@@ -0,0 +1,106 @@
+import {
+  JsonRpcRequest,
+  JsonRpcResponse
+} from '../../src/internal/internal-types';
+import { RequestBatcher } from '../../src/internal/request-batcher';
+
+describe('RequestBatcher', () => {
+  let batcher: RequestBatcher;
+  let fetchJsonMock: jest.Mock;
+  beforeEach(() => {
+    fetchJsonMock = jest.fn();
+    batcher = new RequestBatcher(fetchJsonMock);
+  });
+
+  function blockNumberRequest(id: number): JsonRpcRequest {
+    return {
+      jsonrpc: '2.0',
+      id,
+      method: 'eth_blockNumber',
+      params: []
+    };
+  }
+
+  function blockNumberResponse(id: number, result: string): JsonRpcResponse {
+    return {
+      jsonrpc: '2.0',
+      id,
+      result
+    };
+  }
+
+  it('enqueues requests into batches', async () => {
+    fetchJsonMock.mockResolvedValueOnce([
+      blockNumberResponse(1, '0x1'),
+      blockNumberResponse(2, '0x2'),
+      blockNumberResponse(3, '0x3')
+    ]);
+    const bn1 = batcher.enqueueRequest(blockNumberRequest(1));
+    const bn2 = batcher.enqueueRequest(blockNumberRequest(2));
+    const bn3 = batcher.enqueueRequest(blockNumberRequest(3));
+    await bn2;
+    expect(await bn1).toEqual('0x1');
+    expect(await bn2).toEqual('0x2');
+    expect(await bn3).toEqual('0x3');
+    console.log(fetchJsonMock.mock.calls[0][0]);
+    const rawRequest = fetchJsonMock.mock.calls[0][0];
+    expect(rawRequest.length).toEqual(3);
+  });
+
+  it('sends requests based on maximum batch size', async () => {
+    batcher = new RequestBatcher(fetchJsonMock, 2);
+    fetchJsonMock
+      .mockResolvedValueOnce([
+        blockNumberResponse(1, '0x1'),
+        blockNumberResponse(2, '0x2')
+      ])
+      .mockResolvedValueOnce([
+        blockNumberResponse(3, '0x3'),
+        blockNumberResponse(4, '0x4')
+      ]);
+
+    const bn1 = batcher.enqueueRequest(blockNumberRequest(1));
+    const bn2 = batcher.enqueueRequest(blockNumberRequest(2));
+    const bn3 = batcher.enqueueRequest(blockNumberRequest(3));
+    const bn4 = batcher.enqueueRequest(blockNumberRequest(4));
+    await bn4;
+    expect(await bn1).toEqual('0x1');
+    expect(await bn2).toEqual('0x2');
+    expect(await bn3).toEqual('0x3');
+    expect(await bn4).toEqual('0x4');
+
+    let rawRequest = fetchJsonMock.mock.calls[0][0];
+    expect(rawRequest.length).toEqual(2);
+    rawRequest = fetchJsonMock.mock.calls[1][0];
+    expect(rawRequest.length).toEqual(2);
+  });
+
+  it('surfaces errors in batches', async () => {
+    async function verifyThrows(
+      promise: Promise<any>,
+      expected: string
+    ): Promise<void> {
+      try {
+        await promise;
+      } catch (e) {
+        expect(e).toEqual(expected);
+        return;
+      }
+
+      throw new Error('Promise should have thrown' + expected);
+    }
+    batcher = new RequestBatcher(fetchJsonMock, 2);
+    fetchJsonMock
+      .mockRejectedValueOnce('Test error 1')
+      .mockRejectedValueOnce('Test error 2');
+
+    const bn1 = batcher.enqueueRequest(blockNumberRequest(1));
+    const bn2 = batcher.enqueueRequest(blockNumberRequest(2));
+    const bn3 = batcher.enqueueRequest(blockNumberRequest(3));
+    const bn4 = batcher.enqueueRequest(blockNumberRequest(4));
+    await verifyThrows(bn1, 'Test error 1');
+    await verifyThrows(bn2, 'Test error 1');
+    await verifyThrows(bn3, 'Test error 2');
+    await verifyThrows(bn4, 'Test error 2');
+  });
+});