From 3f363113e102d0acf29b7b2635acf6160a028cc3 Mon Sep 17 00:00:00 2001 From: Den Date: Mon, 8 Apr 2024 08:39:38 +0200 Subject: [PATCH 1/7] feat: introduce `FailoverRpcProvider` --- .changeset/shiny-coats-fly.md | 7 + packages/accounts/src/connection.ts | 6 +- .../providers/src/failover-rpc-provider.ts | 357 ++++++++++++++ packages/providers/src/index.ts | 1 + packages/providers/test/providers.test.js | 449 ++++++++++++------ 5 files changed, 668 insertions(+), 152 deletions(-) create mode 100644 .changeset/shiny-coats-fly.md create mode 100644 packages/providers/src/failover-rpc-provider.ts diff --git a/.changeset/shiny-coats-fly.md b/.changeset/shiny-coats-fly.md new file mode 100644 index 0000000000..f6f90b4842 --- /dev/null +++ b/.changeset/shiny-coats-fly.md @@ -0,0 +1,7 @@ +--- +"@near-js/accounts": minor +"@near-js/providers": minor +"@near-js/wallet-account": minor +--- + +Introduce FailoverRpcProvider that switches between providers in case of a failure of one of them diff --git a/packages/accounts/src/connection.ts b/packages/accounts/src/connection.ts index 3c27a3a45f..81ea06be3f 100644 --- a/packages/accounts/src/connection.ts +++ b/packages/accounts/src/connection.ts @@ -1,5 +1,5 @@ import { Signer, InMemorySigner } from '@near-js/signers'; -import { Provider, JsonRpcProvider } from '@near-js/providers'; +import { Provider, JsonRpcProvider, FailoverRpcProvider } from '@near-js/providers'; /** * @param config Contains connection info details @@ -10,6 +10,10 @@ function getProvider(config: any): Provider { case undefined: return config; case 'JsonRpcProvider': return new JsonRpcProvider({ ...config.args }); + case 'FailoverRpcProvider': { + const providers = (config?.args || []).map((arg) => new JsonRpcProvider(arg)); + return new FailoverRpcProvider(providers); + } default: throw new Error(`Unknown provider type ${config.type}`); } } diff --git a/packages/providers/src/failover-rpc-provider.ts b/packages/providers/src/failover-rpc-provider.ts new file mode 100644 index 0000000000..9a07edfd9f --- /dev/null +++ b/packages/providers/src/failover-rpc-provider.ts @@ -0,0 +1,357 @@ +/** + * @module + * @description + * This module contains the {@link FailoverRpcProvider} client class + * which can be used to interact with multiple [NEAR RPC APIs](https://docs.near.org/api/rpc/introduction). + * @see {@link "@near-js/types".provider | provider} for a list of request and response types + */ +import { Logger } from '@near-js/utils'; +import { + AccessKeyWithPublicKey, + BlockId, + BlockReference, + BlockResult, + BlockChangeResult, + ChangeResult, + ChunkId, + ChunkResult, + EpochValidatorInfo, + FinalExecutionOutcome, + GasPrice, + LightClientProof, + LightClientProofRequest, + NextLightClientBlockRequest, + NextLightClientBlockResponse, + NearProtocolConfig, + NodeStatusResult, + QueryResponseKind, + TypedError, + RpcQueryRequest, +} from '@near-js/types'; +import { SignedTransaction } from '@near-js/transactions'; +import { Provider } from './provider'; + +/** + * Client class to interact with the [NEAR RPC API](https://docs.near.org/api/rpc/introduction). + * @see [https://github.com/near/nearcore/tree/master/chain/jsonrpc](https://github.com/near/nearcore/tree/master/chain/jsonrpc) + */ +export class FailoverRpcProvider extends Provider { + /** @hidden */ + readonly providers: Provider[]; + + private currentProviderIndex: number; + + /** + * @param providers list of providers + */ + constructor(providers: Provider[]) { + super(); + + if (providers.length === 0) { + throw new Error('At least one provider must be specified'); + } + + this.providers = providers; + this.currentProviderIndex = 0; + } + + private switchToNextProvider(): void { + if (this.providers.length === 1) return; + + if (this.providers.length - 1 <= this.currentProviderIndex) { + this.currentProviderIndex = 0; + } else { + this.currentProviderIndex += 1; + } + + Logger.debug( + `Switched to provider at the index ${this.currentProviderIndex}` + ); + } + + private get currentProvider(): Provider { + const provider = this.providers[this.currentProviderIndex]; + + if (!provider) + throw new Error( + `Provider wasn't found at index ${this.currentProviderIndex}` + ); + + return provider; + } + + private async withBackoff(getResult: (provider: Provider) => Promise) { + for (let i = 0; i < this.providers.length; i++) { + try { + // each provider implements own retry logic + const result = await getResult(this.currentProvider); + + if (result) return result; + } catch { + this.switchToNextProvider(); + } + } + + throw new TypedError( + `Exceeded ${this.providers.length} providers to execute request`, + 'RetriesExceeded' + ); + } + + /** + * Gets the RPC's status + * @see [https://docs.near.org/docs/develop/front-end/rpc#general-validator-status](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) + */ + async status(): Promise { + return this.withBackoff((currentProvider) => currentProvider.status()); + } + + /** + * Sends a signed transaction to the RPC and waits until transaction is fully complete + * @see [https://docs.near.org/docs/develop/front-end/rpc#send-transaction-await](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) + * + * @param signedTransaction The signed transaction being sent + */ + async sendTransaction( + signedTransaction: SignedTransaction + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.sendTransaction(signedTransaction) + ); + } + + /** + * Sends a signed transaction to the RPC and immediately returns transaction hash + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#send-transaction-async) + * @param signedTransaction The signed transaction being sent + * @returns {Promise} + */ + async sendTransactionAsync( + signedTransaction: SignedTransaction + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.sendTransactionAsync(signedTransaction) + ); + } + + /** + * Gets a transaction's status from the RPC + * @see [https://docs.near.org/docs/develop/front-end/rpc#transaction-status](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) + * + * @param txHash A transaction hash as either a Uint8Array or a base58 encoded string + * @param accountId The NEAR account that signed the transaction + */ + async txStatus( + txHash: Uint8Array | string, + accountId: string + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.txStatus(txHash, accountId) + ); + } + + /** + * Gets a transaction's status from the RPC with receipts + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#transaction-status-with-receipts) + * @param txHash The hash of the transaction + * @param accountId The NEAR account that signed the transaction + * @returns {Promise} + */ + async txStatusReceipts( + txHash: Uint8Array | string, + accountId: string + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.txStatusReceipts(txHash, accountId) + ); + } + + /** + * Query the RPC by passing an {@link "@near-js/types".provider/request.RpcQueryRequest | RpcQueryRequest } + * @see [https://docs.near.org/api/rpc/contracts](https://docs.near.org/api/rpc/contracts) + * + * @typeParam T the shape of the returned query response + */ + async query( + params: RpcQueryRequest + ): Promise; + async query( + path: string, + data: string + ): Promise; + async query( + paramsOrPath: any, + data?: any + ): Promise { + if (data) { + return this.withBackoff((currentProvider) => currentProvider.query(paramsOrPath, data) + ); + } + + return this.withBackoff((currentProvider) => currentProvider.query(paramsOrPath) + ); + } + + /** + * Query for block info from the RPC + * pass block_id OR finality as blockQuery, not both + * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) + * + * @param blockQuery {@link BlockReference} (passing a {@link BlockId} is deprecated) + */ + async block(blockQuery: BlockId | BlockReference): Promise { + return this.withBackoff((currentProvider) => currentProvider.block(blockQuery)); + } + + /** + * Query changes in block from the RPC + * pass block_id OR finality as blockQuery, not both + * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) + */ + async blockChanges(blockQuery: BlockReference): Promise { + return this.withBackoff((currentProvider) => currentProvider.blockChanges(blockQuery) + ); + } + + /** + * Queries for details about a specific chunk appending details of receipts and transactions to the same chunk data provided by a block + * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) + * + * @param chunkId Hash of a chunk ID or shard ID + */ + async chunk(chunkId: ChunkId): Promise { + return this.withBackoff((currentProvider) => currentProvider.chunk(chunkId)); + } + + /** + * Query validators of the epoch defined by the given block id. + * @see [https://docs.near.org/api/rpc/network#validation-status](https://docs.near.org/api/rpc/network#validation-status) + * + * @param blockId Block hash or height, or null for latest. + */ + async validators(blockId: BlockId | null): Promise { + return this.withBackoff((currentProvider) => currentProvider.validators(blockId)); + } + + /** + * Gets the protocol config at a block from RPC + * + * @param blockReference specifies the block to get the protocol config for + */ + async experimental_protocolConfig( + blockReference: BlockReference | { sync_checkpoint: 'genesis' } + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.experimental_protocolConfig(blockReference) + ); + } + + /** + * Gets a light client execution proof for verifying execution outcomes + * @see [https://github.com/nearprotocol/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-proof](https://github.com/nearprotocol/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-proof) + */ + async lightClientProof( + request: LightClientProofRequest + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.lightClientProof(request) + ); + } + + /** + * Returns the next light client block as far in the future as possible from the last known hash + * to still be able to validate from that hash. This will either return the last block of the + * next epoch, or the last final known block. + * + * @see [https://github.com/near/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-block](https://github.com/near/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-block) + */ + async nextLightClientBlock( + request: NextLightClientBlockRequest + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.nextLightClientBlock(request) + ); + } + + /** + * Gets access key changes for a given array of accountIds + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-access-key-changes-all) + * @returns {Promise} + */ + async accessKeyChanges( + accountIdArray: string[], + blockQuery: BlockReference + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.accessKeyChanges(accountIdArray, blockQuery) + ); + } + + /** + * Gets single access key changes for a given array of access keys + * pass block_id OR finality as blockQuery, not both + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-access-key-changes-single) + * @returns {Promise} + */ + async singleAccessKeyChanges( + accessKeyArray: AccessKeyWithPublicKey[], + blockQuery: BlockReference + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.singleAccessKeyChanges( + accessKeyArray, + blockQuery + ) + ); + } + + /** + * Gets account changes for a given array of accountIds + * pass block_id OR finality as blockQuery, not both + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-account-changes) + * @returns {Promise} + */ + async accountChanges( + accountIdArray: string[], + blockQuery: BlockReference + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.accountChanges(accountIdArray, blockQuery) + ); + } + + /** + * Gets contract state changes for a given array of accountIds + * pass block_id OR finality as blockQuery, not both + * Note: If you pass a keyPrefix it must be base64 encoded + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-contract-state-changes) + * @returns {Promise} + */ + async contractStateChanges( + accountIdArray: string[], + blockQuery: BlockReference, + keyPrefix = '' + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.contractStateChanges( + accountIdArray, + blockQuery, + keyPrefix + ) + ); + } + + /** + * Gets contract code changes for a given array of accountIds + * pass block_id OR finality as blockQuery, not both + * Note: Change is returned in a base64 encoded WASM file + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-contract-code-changes) + * @returns {Promise} + */ + async contractCodeChanges( + accountIdArray: string[], + blockQuery: BlockReference + ): Promise { + return this.withBackoff((currentProvider) => currentProvider.contractCodeChanges(accountIdArray, blockQuery) + ); + } + + /** + * Returns gas price for a specific block_height or block_hash. + * @see [https://docs.near.org/api/rpc/gas](https://docs.near.org/api/rpc/gas) + * + * @param blockId Block hash or height, or null for latest. + */ + async gasPrice(blockId: BlockId | null): Promise { + return this.withBackoff((currentProvider) => currentProvider.gasPrice(blockId)); + } +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 8c6eda4492..98ce912fdb 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -1,4 +1,5 @@ export { exponentialBackoff } from './exponential-backoff'; export { JsonRpcProvider } from './json-rpc-provider'; +export { FailoverRpcProvider } from './failover-rpc-provider'; export { Provider } from './provider'; export { fetchJson } from './fetch_json'; diff --git a/packages/providers/test/providers.test.js b/packages/providers/test/providers.test.js index f66a34c5a1..5d29090759 100644 --- a/packages/providers/test/providers.test.js +++ b/packages/providers/test/providers.test.js @@ -1,174 +1,321 @@ const { getTransactionLastResult } = require('@near-js/utils'); const { Worker } = require('near-workspaces'); -const { JsonRpcProvider } = require('../lib'); +const { JsonRpcProvider, FailoverRpcProvider } = require('../lib'); jest.setTimeout(20000); -describe('providers', () => { - let worker; - let provider; +["json provider", "fallback provider"].forEach((name) => { + describe(name, () => { + let worker; + let provider; - beforeAll(async () => { - worker = await Worker.init(); - provider = new JsonRpcProvider({ url: worker.manager.config.rpcAddr }); - await new Promise(resolve => setTimeout(resolve, 2000)); - }); + beforeAll(async () => { + worker = await Worker.init(); - afterAll(async () => { - await worker.tearDown(); - }); + if (name === "json provider") { + provider = new JsonRpcProvider({ + url: worker.manager.config.rpcAddr, + }); + } else if (name === "fallback provider") { + provider = new FailoverRpcProvider([ + new JsonRpcProvider({ + url: worker.manager.config.rpcAddr, + }), + ]); + } - test('json rpc fetch node status', async () => { - let response = await provider.status(); - expect(response.chain_id).toBeTruthy(); - }); - - test('json rpc fetch block info', async () => { - let stat = await provider.status(); - let height = stat.sync_info.latest_block_height - 1; - let response = await provider.block({ blockId: height }); - expect(response.header.height).toEqual(height); - - let sameBlock = await provider.block({ blockId: response.header.hash }); - expect(sameBlock.header.height).toEqual(height); - - let optimisticBlock = await provider.block({ finality: 'optimistic' }); - expect(optimisticBlock.header.height - height).toBeLessThan(5); - - let nearFinalBlock = await provider.block({ finality: 'near-final' }); - expect(nearFinalBlock.header.height - height).toBeLessThan(5); - - let finalBlock = await provider.block({ finality: 'final' }); - expect(finalBlock.header.height - height).toBeLessThan(5); - }); - - test('json rpc fetch block changes', async () => { - let stat = await provider.status(); - let height = stat.sync_info.latest_block_height - 1; - let response = await provider.blockChanges({ blockId: height }); - - expect(response).toMatchObject({ - block_hash: expect.any(String), - changes: expect.any(Array) + await new Promise((resolve) => setTimeout(resolve, 2000)); }); - }); - - test('json rpc fetch chunk info', async () => { - let stat = await provider.status(); - let height = stat.sync_info.latest_block_height - 1; - let response = await provider.chunk([height, 0]); - expect(response.header.shard_id).toEqual(0); - let sameChunk = await provider.chunk(response.header.chunk_hash); - expect(sameChunk.header.chunk_hash).toEqual(response.header.chunk_hash); - expect(sameChunk.header.shard_id).toEqual(0); - }); - - test('json rpc fetch validators info', async () => { - let validators = await provider.validators(null); - expect(validators.current_validators.length).toBeGreaterThanOrEqual(1); - }); - - test('json rpc query with block_id', async () => { - const stat = await provider.status(); - let block_id = stat.sync_info.latest_block_height - 1; - - const response = await provider.query({ - block_id, - request_type: 'view_account', - account_id: 'test.near' + + afterAll(async () => { + await worker.tearDown(); }); - - expect(response).toEqual({ - block_height: expect.any(Number), - block_hash: expect.any(String), - amount: expect.any(String), - locked: expect.any(String), - code_hash: '11111111111111111111111111111111', - storage_usage: 182, - storage_paid_at: 0, + + test('rpc fetch node status', async () => { + let response = await provider.status(); + expect(response.chain_id).toBeTruthy(); }); - }); - - test('json rpc query view_account', async () => { - const response = await provider.query({ - request_type: 'view_account', - finality: 'final', - account_id: 'test.near' + + test('rpc fetch block info', async () => { + let stat = await provider.status(); + let height = stat.sync_info.latest_block_height - 1; + let response = await provider.block({ blockId: height }); + expect(response.header.height).toEqual(height); + + let sameBlock = await provider.block({ blockId: response.header.hash }); + expect(sameBlock.header.height).toEqual(height); + + let optimisticBlock = await provider.block({ finality: 'optimistic' }); + expect(optimisticBlock.header.height - height).toBeLessThan(5); + + let nearFinalBlock = await provider.block({ finality: 'near-final' }); + expect(nearFinalBlock.header.height - height).toBeLessThan(5); + + let finalBlock = await provider.block({ finality: 'final' }); + expect(finalBlock.header.height - height).toBeLessThan(5); }); - - expect(response).toEqual({ - block_height: expect.any(Number), - block_hash: expect.any(String), - amount: expect.any(String), - locked: expect.any(String), - code_hash: '11111111111111111111111111111111', - storage_usage: 182, - storage_paid_at: 0, + + test('rpc fetch block changes', async () => { + let stat = await provider.status(); + let height = stat.sync_info.latest_block_height - 1; + let response = await provider.blockChanges({ blockId: height }); + + expect(response).toMatchObject({ + block_hash: expect.any(String), + changes: expect.any(Array) + }); }); + + test('rpc fetch chunk info', async () => { + let stat = await provider.status(); + let height = stat.sync_info.latest_block_height - 1; + let response = await provider.chunk([height, 0]); + expect(response.header.shard_id).toEqual(0); + let sameChunk = await provider.chunk(response.header.chunk_hash); + expect(sameChunk.header.chunk_hash).toEqual(response.header.chunk_hash); + expect(sameChunk.header.shard_id).toEqual(0); + }); + + test('rpc fetch validators info', async () => { + let validators = await provider.validators(null); + expect(validators.current_validators.length).toBeGreaterThanOrEqual(1); + }); + + test('rpc query with block_id', async () => { + const stat = await provider.status(); + let block_id = stat.sync_info.latest_block_height - 1; + + const response = await provider.query({ + block_id, + request_type: 'view_account', + account_id: 'test.near' + }); + + expect(response).toEqual({ + block_height: expect.any(Number), + block_hash: expect.any(String), + amount: expect.any(String), + locked: expect.any(String), + code_hash: '11111111111111111111111111111111', + storage_usage: 182, + storage_paid_at: 0, + }); + }); + + test('rpc query view_account', async () => { + const response = await provider.query({ + request_type: 'view_account', + finality: 'final', + account_id: 'test.near' + }); + + expect(response).toEqual({ + block_height: expect.any(Number), + block_hash: expect.any(String), + amount: expect.any(String), + locked: expect.any(String), + code_hash: '11111111111111111111111111111111', + storage_usage: 182, + storage_paid_at: 0, + }); + }); + + test('json rpc fetch protocol config', async () => { + const status = await provider.status(); + const blockHeight = status.sync_info.latest_block_height; + const blockHash = status.sync_info.latest_block_hash; + for (const blockReference of [{ sync_checkpoint: 'genesis' }, { blockId: blockHeight }, { blockId: blockHash }, { finality: 'final' }, { finality: 'optimistic' }]) { + const response = await provider.experimental_protocolConfig(blockReference); + expect('chain_id' in response).toBe(true); + expect('genesis_height' in response).toBe(true); + expect('runtime_config' in response).toBe(true); + expect('storage_amount_per_byte' in response.runtime_config).toBe(true); + } + }); + + test('json rpc gas price', async () => { + let status = await provider.status(); + let positiveIntegerRegex = /^[+]?\d+([.]\d+)?$/; + + let response1 = await provider.gasPrice(status.sync_info.latest_block_height); + expect(response1.gas_price).toMatch(positiveIntegerRegex); + + let response2 = await provider.gasPrice(status.sync_info.latest_block_hash); + expect(response2.gas_price).toMatch(positiveIntegerRegex); + + let response3 = await provider.gasPrice(); + expect(response3.gas_price).toMatch(positiveIntegerRegex); + }); + + test('near json rpc fetch node status', async () => { + let response = await provider.status(); + expect(response.chain_id).toBeTruthy(); + }); + }) +}); + +describe("json provider", () => { + test("JsonRpc connection object exist without connectionInfo provided", async () => { + const provider = new JsonRpcProvider(); + expect(provider.connection).toStrictEqual({ url: "" }); }); - - test('final tx result', async () => { - const result = { - status: { SuccessValue: 'e30=' }, - transaction: { id: '11111', outcome: { status: { SuccessReceiptId: '11112' }, logs: [], receipt_ids: ['11112'], gas_burnt: 1 } }, - receipts: [ - { id: '11112', outcome: { status: { SuccessValue: 'e30=' }, logs: [], receipt_ids: ['11112'], gas_burnt: 9001 } }, - { id: '11113', outcome: { status: { SuccessValue: '' }, logs: [], receipt_ids: [], gas_burnt: 0 } } - ] - }; - expect(getTransactionLastResult(result)).toEqual({}); - }); - - test('final tx result with null', async () => { - const result = { - status: 'Failure', - transaction: { id: '11111', outcome: { status: { SuccessReceiptId: '11112' }, logs: [], receipt_ids: ['11112'], gas_burnt: 1 } }, - receipts: [ - { id: '11112', outcome: { status: 'Failure', logs: [], receipt_ids: ['11112'], gas_burnt: 9001 } }, - { id: '11113', outcome: { status: { SuccessValue: '' }, logs: [], receipt_ids: [], gas_burnt: 0 } } - ] - }; - expect(getTransactionLastResult(result)).toEqual(null); - }); - - test('json rpc fetch protocol config', async () => { - const status = await provider.status(); - const blockHeight = status.sync_info.latest_block_height; - const blockHash = status.sync_info.latest_block_hash; - for (const blockReference of [{ sync_checkpoint: 'genesis' }, { blockId: blockHeight }, { blockId: blockHash }, { finality: 'final' }, { finality: 'optimistic' }]) { - const response = await provider.experimental_protocolConfig(blockReference); - expect('chain_id' in response).toBe(true); - expect('genesis_height' in response).toBe(true); - expect('runtime_config' in response).toBe(true); - expect('storage_amount_per_byte' in response.runtime_config).toBe(true); - } +}); + +describe("failover provider", () => { + beforeAll(async () => {}); + + test("FailoverRpc throws error on empty list of providers in constructor", async () => { + expect(() => new FailoverRpcProvider([])).toThrow(); }); - - test('json rpc gas price', async () => { - let status = await provider.status(); - let positiveIntegerRegex = /^[+]?\d+([.]\d+)?$/; - - let response1 = await provider.gasPrice(status.sync_info.latest_block_height); - expect(response1.gas_price).toMatch(positiveIntegerRegex); - - let response2 = await provider.gasPrice(status.sync_info.latest_block_hash); - expect(response2.gas_price).toMatch(positiveIntegerRegex); - - let response3 = await provider.gasPrice(); - expect(response3.gas_price).toMatch(positiveIntegerRegex); + + test("FailoverRpc uses first provider as default", async () => { + const jsonProviders = [ + Object.setPrototypeOf( + { + status() { + return "first"; + }, + }, + JsonRpcProvider.prototype + ), + Object.setPrototypeOf( + { + status() { + return "second"; + }, + }, + JsonRpcProvider.prototype + ), + ]; + + const provider = new FailoverRpcProvider(jsonProviders); + + expect(await provider.status()).toBe("first"); }); - - test('JsonRpc connection object exist without connectionInfo provided', async () => { - const provider = new JsonRpcProvider(); - expect(provider.connection).toStrictEqual({ url: '' }); + + test("FailoverRpc switches to next provider in case of error", async () => { + const jsonProviders = [ + Object.setPrototypeOf( + { + status() { + throw new Error(); + }, + }, + JsonRpcProvider.prototype + ), + Object.setPrototypeOf( + { + status() { + return "second"; + }, + }, + JsonRpcProvider.prototype + ), + ]; + + const provider = new FailoverRpcProvider(jsonProviders); + + expect(await provider.status()).toBe("second"); }); - - test('near json rpc fetch node status', async () => { - let response = await provider.status(); - expect(response.chain_id).toBeTruthy(); + + test("FailoverRpc returns error if all providers are unavailable", async () => { + const jsonProviders = [ + Object.setPrototypeOf( + { + status() { + throw new Error(); + }, + }, + JsonRpcProvider.prototype + ), + Object.setPrototypeOf( + { + status() { + throw new Error(); + }, + }, + JsonRpcProvider.prototype + ), + ]; + + const provider = new FailoverRpcProvider(jsonProviders); + + await expect(() => provider.status()).rejects.toThrow(); }); }); +test("final tx result", async () => { + const result = { + status: { SuccessValue: "e30=" }, + transaction: { + id: "11111", + outcome: { + status: { SuccessReceiptId: "11112" }, + logs: [], + receipt_ids: ["11112"], + gas_burnt: 1, + }, + }, + receipts: [ + { + id: "11112", + outcome: { + status: { SuccessValue: "e30=" }, + logs: [], + receipt_ids: ["11112"], + gas_burnt: 9001, + }, + }, + { + id: "11113", + outcome: { + status: { SuccessValue: "" }, + logs: [], + receipt_ids: [], + gas_burnt: 0, + }, + }, + ], + }; + expect(getTransactionLastResult(result)).toEqual({}); +}); + +test("final tx result with null", async () => { + const result = { + status: "Failure", + transaction: { + id: "11111", + outcome: { + status: { SuccessReceiptId: "11112" }, + logs: [], + receipt_ids: ["11112"], + gas_burnt: 1, + }, + }, + receipts: [ + { + id: "11112", + outcome: { + status: "Failure", + logs: [], + receipt_ids: ["11112"], + gas_burnt: 9001, + }, + }, + { + id: "11113", + outcome: { + status: { SuccessValue: "" }, + logs: [], + receipt_ids: [], + gas_burnt: 0, + }, + }, + ], + }; + expect(getTransactionLastResult(result)).toEqual(null); +}); + // TODO: Use a near-workspaces Worker when time traveling is available test('json rpc get next light client block', async () => { const provider = new JsonRpcProvider({ url: 'https://rpc.testnet.near.org' }); From b9e7490dd7a0fac8682b53be9d8780538789186d Mon Sep 17 00:00:00 2001 From: Den Date: Mon, 8 Apr 2024 08:39:57 +0200 Subject: [PATCH 2/7] refactor: accept request retry options in JsonRpcProvider constructor --- packages/providers/src/json-rpc-provider.ts | 30 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/providers/src/json-rpc-provider.ts b/packages/providers/src/json-rpc-provider.ts index cc706b7bdc..3b577552f9 100644 --- a/packages/providers/src/json-rpc-provider.ts +++ b/packages/providers/src/json-rpc-provider.ts @@ -56,6 +56,21 @@ const REQUEST_RETRY_WAIT_BACKOFF = 1.5; /// Keep ids unique across all connections. let _nextId = 123; +type RequestOptions = { + /** + * Number of retries before giving up on a request + */ + retries: number; + /** + * Wait until next retry in milliseconds + */ + wait: number; + /** + * Exponential back off for waiting to retry again + */ + backoff: number; +} + /** * Client class to interact with the [NEAR RPC API](https://docs.near.org/api/rpc/introduction). * @see [https://github.com/near/nearcore/tree/master/chain/jsonrpc](https://github.com/near/nearcore/tree/master/chain/jsonrpc) @@ -64,12 +79,21 @@ export class JsonRpcProvider extends Provider { /** @hidden */ readonly connection: ConnectionInfo; + /** @hidden */ + readonly options: RequestOptions; + /** * @param connectionInfo Connection info */ - constructor(connectionInfo: ConnectionInfo) { + constructor(connectionInfo: ConnectionInfo, options?: Partial) { super(); this.connection = connectionInfo || { url: '' }; + const defaultOptions: RequestOptions = { + retries: REQUEST_RETRY_NUMBER, + wait: REQUEST_RETRY_WAIT, + backoff: REQUEST_RETRY_WAIT_BACKOFF + }; + this.options = Object.assign({}, defaultOptions, options); } /** @@ -342,7 +366,7 @@ export class JsonRpcProvider extends Provider { * @param params Parameters to the method */ async sendJsonRpc(method: string, params: object): Promise { - const response = await exponentialBackoff(REQUEST_RETRY_WAIT, REQUEST_RETRY_NUMBER, REQUEST_RETRY_WAIT_BACKOFF, async () => { + const response = await exponentialBackoff(this.options.wait, this.options.retries, this.options.backoff, async () => { try { const request = { method, @@ -399,7 +423,7 @@ export class JsonRpcProvider extends Provider { // This member MUST NOT exist if there was an error invoking the method. if (typeof result === 'undefined') { throw new TypedError( - `Exceeded ${REQUEST_RETRY_NUMBER} attempts for request to ${method}.`, 'RetriesExceeded'); + `Exceeded ${this.options.retries} attempts for request to ${method}.`, 'RetriesExceeded'); } return result; } From 4ce15fa570c89ba02f94c424a205546ff74f6d48 Mon Sep 17 00:00:00 2001 From: Den Date: Mon, 8 Apr 2024 08:40:13 +0200 Subject: [PATCH 3/7] feat: export FailoverRpcProvider from near-api-js package --- packages/near-api-js/src/providers/failover-rpc-provider.ts | 1 + packages/near-api-js/src/providers/index.ts | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 packages/near-api-js/src/providers/failover-rpc-provider.ts diff --git a/packages/near-api-js/src/providers/failover-rpc-provider.ts b/packages/near-api-js/src/providers/failover-rpc-provider.ts new file mode 100644 index 0000000000..4f5997dfea --- /dev/null +++ b/packages/near-api-js/src/providers/failover-rpc-provider.ts @@ -0,0 +1 @@ +export { FailoverRpcProvider } from '@near-js/providers'; \ No newline at end of file diff --git a/packages/near-api-js/src/providers/index.ts b/packages/near-api-js/src/providers/index.ts index 02bb8ce9d0..8e3c4ac028 100644 --- a/packages/near-api-js/src/providers/index.ts +++ b/packages/near-api-js/src/providers/index.ts @@ -2,11 +2,13 @@ import { Provider, FinalExecutionOutcome, ExecutionOutcomeWithId, getTransactionLastResult, FinalExecutionStatus, FinalExecutionStatusBasic } from './provider'; import { JsonRpcProvider, TypedError, ErrorContext } from './json-rpc-provider'; +import { FailoverRpcProvider } from './failover-rpc-provider'; export { Provider, FinalExecutionOutcome, JsonRpcProvider, + FailoverRpcProvider, ExecutionOutcomeWithId, FinalExecutionStatus, FinalExecutionStatusBasic, From d243ac7b90f55f82a14c6dd10d463fb1abe255a0 Mon Sep 17 00:00:00 2001 From: Den Date: Mon, 8 Apr 2024 08:40:22 +0200 Subject: [PATCH 4/7] feat: extend NearConfig to accept `provider` --- packages/wallet-account/package.json | 1 + packages/wallet-account/src/near.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/wallet-account/package.json b/packages/wallet-account/package.json index 6130024cd9..bd06be7979 100644 --- a/packages/wallet-account/package.json +++ b/packages/wallet-account/package.json @@ -19,6 +19,7 @@ "@near-js/transactions": "workspace:*", "@near-js/types": "workspace:*", "@near-js/utils": "workspace:*", + "@near-js/providers": "workspace:*", "borsh": "1.0.0" }, "devDependencies": { diff --git a/packages/wallet-account/src/near.ts b/packages/wallet-account/src/near.ts index a5837d0f96..0b614e34c9 100644 --- a/packages/wallet-account/src/near.ts +++ b/packages/wallet-account/src/near.ts @@ -18,6 +18,7 @@ import { PublicKey } from '@near-js/crypto'; import { KeyStore } from '@near-js/keystores'; import { Signer } from '@near-js/signers'; import { LoggerService } from '@near-js/utils'; +import { Provider } from '@near-js/providers'; export interface NearConfig { /** Holds {@link "@near-js/crypto".key_pair.KeyPair | KeyPair} for signing transactions */ @@ -80,6 +81,13 @@ export interface NearConfig { * Specifies the logger to use. Pass `false` to turn off logging. */ logger?: LoggerService | false; + /** + * Specifies NEAR RPC API connections, and is used to make JSON RPC calls to interact with NEAR + * @see {@link "@near-js/providers".json-rpc-provider.JsonRpcProvider | JsonRpcProvider} + * @see {@link "@near-js/providers".json-rpc-provider.FailoverRpcProvider | FailoverRpcProvider} + * @see [List of available and public JSON RPC endpoints](https://docs.near.org/api/rpc/providers) + */ + provider?: Provider; } /** @@ -98,7 +106,7 @@ export class Near { this.config = config; this.connection = Connection.fromConfig({ networkId: config.networkId, - provider: { type: 'JsonRpcProvider', args: { url: config.nodeUrl, headers: config.headers } }, + provider: config.provider || { type: 'JsonRpcProvider', args: { url: config.nodeUrl, headers: config.headers } }, signer: config.signer || { type: 'InMemorySigner', keyStore: config.keyStore || config.deps?.keyStore }, jsvmAccountId: config.jsvmAccountId || `jsvm.${config.networkId}` }); From 54a8c20addf9cff7c36778681ace309d48868d98 Mon Sep 17 00:00:00 2001 From: Den Date: Mon, 8 Apr 2024 08:41:04 +0200 Subject: [PATCH 5/7] chore: create cookbook example around using FailoverRpcProvider --- packages/cookbook/api-keys/backup-provider.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/cookbook/api-keys/backup-provider.js diff --git a/packages/cookbook/api-keys/backup-provider.js b/packages/cookbook/api-keys/backup-provider.js new file mode 100644 index 0000000000..bef26cb5fc --- /dev/null +++ b/packages/cookbook/api-keys/backup-provider.js @@ -0,0 +1,27 @@ +// demonstrates how to use multiple providers with different API-KEYs +const { providers } = require('near-api-js'); + +const RPC_API_ENDPOINT_1 = ''; +const API_KEY_1 = ''; + +const RPC_API_ENDPOINT_2 = ''; +const API_KEY_2 = ''; + +const jsonProviders = [ + new providers.JsonRpcProvider({ + url: RPC_API_ENDPOINT_1, + headers: { 'x-api-key': API_KEY_1 }, + }), + new providers.JsonRpcProvider({ + url: RPC_API_ENDPOINT_2, + headers: { 'x-api-key': API_KEY_2 }, + }), +]; +const provider = new providers.FailoverRpcProvider(jsonProviders); + +getNetworkStatus(); + +async function getNetworkStatus() { + const result = await provider.status(); + console.log(result); +} From 1c434b5d9c3eaa865a287b802f603136ba6d36c0 Mon Sep 17 00:00:00 2001 From: Den Date: Mon, 8 Apr 2024 08:50:12 +0200 Subject: [PATCH 6/7] test: fix linting for providers.test file --- packages/providers/test/providers.test.js | 74 +++++++++++------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/providers/test/providers.test.js b/packages/providers/test/providers.test.js index 5d29090759..ad8f3ae0f1 100644 --- a/packages/providers/test/providers.test.js +++ b/packages/providers/test/providers.test.js @@ -4,7 +4,7 @@ const { JsonRpcProvider, FailoverRpcProvider } = require('../lib'); jest.setTimeout(20000); -["json provider", "fallback provider"].forEach((name) => { +['json provider', 'fallback provider'].forEach((name) => { describe(name, () => { let worker; let provider; @@ -12,11 +12,11 @@ jest.setTimeout(20000); beforeAll(async () => { worker = await Worker.init(); - if (name === "json provider") { + if (name === 'json provider') { provider = new JsonRpcProvider({ url: worker.manager.config.rpcAddr, }); - } else if (name === "fallback provider") { + } else if (name === 'fallback provider') { provider = new FailoverRpcProvider([ new JsonRpcProvider({ url: worker.manager.config.rpcAddr, @@ -151,29 +151,29 @@ jest.setTimeout(20000); let response = await provider.status(); expect(response.chain_id).toBeTruthy(); }); - }) + }); }); -describe("json provider", () => { - test("JsonRpc connection object exist without connectionInfo provided", async () => { +describe('json provider', () => { + test('JsonRpc connection object exist without connectionInfo provided', async () => { const provider = new JsonRpcProvider(); - expect(provider.connection).toStrictEqual({ url: "" }); + expect(provider.connection).toStrictEqual({ url: '' }); }); }); -describe("failover provider", () => { +describe('failover provider', () => { beforeAll(async () => {}); - test("FailoverRpc throws error on empty list of providers in constructor", async () => { + test('FailoverRpc throws error on empty list of providers in constructor', async () => { expect(() => new FailoverRpcProvider([])).toThrow(); }); - test("FailoverRpc uses first provider as default", async () => { + test('FailoverRpc uses first provider as default', async () => { const jsonProviders = [ Object.setPrototypeOf( { status() { - return "first"; + return 'first'; }, }, JsonRpcProvider.prototype @@ -181,7 +181,7 @@ describe("failover provider", () => { Object.setPrototypeOf( { status() { - return "second"; + return 'second'; }, }, JsonRpcProvider.prototype @@ -190,10 +190,10 @@ describe("failover provider", () => { const provider = new FailoverRpcProvider(jsonProviders); - expect(await provider.status()).toBe("first"); + expect(await provider.status()).toBe('first'); }); - test("FailoverRpc switches to next provider in case of error", async () => { + test('FailoverRpc switches to next provider in case of error', async () => { const jsonProviders = [ Object.setPrototypeOf( { @@ -206,7 +206,7 @@ describe("failover provider", () => { Object.setPrototypeOf( { status() { - return "second"; + return 'second'; }, }, JsonRpcProvider.prototype @@ -215,10 +215,10 @@ describe("failover provider", () => { const provider = new FailoverRpcProvider(jsonProviders); - expect(await provider.status()).toBe("second"); + expect(await provider.status()).toBe('second'); }); - test("FailoverRpc returns error if all providers are unavailable", async () => { + test('FailoverRpc returns error if all providers are unavailable', async () => { const jsonProviders = [ Object.setPrototypeOf( { @@ -244,32 +244,32 @@ describe("failover provider", () => { }); }); -test("final tx result", async () => { +test('final tx result', async () => { const result = { - status: { SuccessValue: "e30=" }, + status: { SuccessValue: 'e30=' }, transaction: { - id: "11111", + id: '11111', outcome: { - status: { SuccessReceiptId: "11112" }, + status: { SuccessReceiptId: '11112' }, logs: [], - receipt_ids: ["11112"], + receipt_ids: ['11112'], gas_burnt: 1, }, }, receipts: [ { - id: "11112", + id: '11112', outcome: { - status: { SuccessValue: "e30=" }, + status: { SuccessValue: 'e30=' }, logs: [], - receipt_ids: ["11112"], + receipt_ids: ['11112'], gas_burnt: 9001, }, }, { - id: "11113", + id: '11113', outcome: { - status: { SuccessValue: "" }, + status: { SuccessValue: '' }, logs: [], receipt_ids: [], gas_burnt: 0, @@ -280,32 +280,32 @@ test("final tx result", async () => { expect(getTransactionLastResult(result)).toEqual({}); }); -test("final tx result with null", async () => { +test('final tx result with null', async () => { const result = { - status: "Failure", + status: 'Failure', transaction: { - id: "11111", + id: '11111', outcome: { - status: { SuccessReceiptId: "11112" }, + status: { SuccessReceiptId: '11112' }, logs: [], - receipt_ids: ["11112"], + receipt_ids: ['11112'], gas_burnt: 1, }, }, receipts: [ { - id: "11112", + id: '11112', outcome: { - status: "Failure", + status: 'Failure', logs: [], - receipt_ids: ["11112"], + receipt_ids: ['11112'], gas_burnt: 9001, }, }, { - id: "11113", + id: '11113', outcome: { - status: { SuccessValue: "" }, + status: { SuccessValue: '' }, logs: [], receipt_ids: [], gas_burnt: 0, From 306f9a6102d359a9a3ccac60d65e3c661363179d Mon Sep 17 00:00:00 2001 From: Den Date: Tue, 9 Apr 2024 18:15:52 +0200 Subject: [PATCH 7/7] fix: include updated pnpm-lock file --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ce6e85b14..4535a32cc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -534,6 +534,9 @@ importers: '@near-js/keystores': specifier: workspace:* version: link:../keystores + '@near-js/providers': + specifier: workspace:* + version: link:../providers '@near-js/signers': specifier: workspace:* version: link:../signers