From 5b8070987b8f9f8d384d3d5570a8612c06a0ffeb Mon Sep 17 00:00:00 2001 From: Jordan Sheinfeld Date: Tue, 10 Dec 2024 14:59:30 +0200 Subject: [PATCH] add fallback for web3 json-rpc. --- src/api/render-node.test.ts | 2 +- src/cli-args.test.ts | 4 +- src/config.example.ts | 2 +- src/config.test.ts | 6 +-- src/config.ts | 43 +++++++++++++++++---- src/env-var-args.ts | 3 +- src/ethereum/block-sync.test.ts | 2 +- src/ethereum/ethereum-reader.ts | 68 ++++++++++++++++++++++++++++----- src/ethereum/test-driver.ts | 4 +- src/main.ts | 6 ++- 10 files changed, 110 insertions(+), 30 deletions(-) diff --git a/src/api/render-node.test.ts b/src/api/render-node.test.ts index 4d2e77d..7fc7b8b 100644 --- a/src/api/render-node.test.ts +++ b/src/api/render-node.test.ts @@ -12,7 +12,7 @@ test.serial('[integration] getNodeManagement responds according to Ethereum and t.timeout(5 * 60 * 1000); const ethereum = new EthereumTestDriver(true); - const ethereumEndpoint = 'http://localhost:7545'; + const ethereumEndpoint = ['http://localhost:7545']; const maticEndpoint = 'mock-endpoint'; const finalityBufferBlocks = 5; diff --git a/src/cli-args.test.ts b/src/cli-args.test.ts index 4cbdb51..d685c12 100644 --- a/src/cli-args.test.ts +++ b/src/cli-args.test.ts @@ -62,7 +62,7 @@ test('parseOptions with no config and no environment variables', (t) => { }); test('parseOptions: environment variables and no config', (t) => { - const mockEthereumEndpoint = 'https://mainnet.infura.io/v3/1234567890'; + const mockEthereumEndpoint = ['https://mainnet.infura.io/v3/1234567890']; const mockNodeAddress = '0x1234567890'; process.env.ETHEREUM_ENDPOINT = mockEthereumEndpoint; process.env.NODE_ADDRESS = mockNodeAddress; @@ -76,7 +76,7 @@ test('parseOptions: environment variables and no config', (t) => { }); test('parseOptions: env vars take precedence', (t) => { - const mockEthereumEndpoint = 'https://mainnet.infura.io/v3/1234567890'; + const mockEthereumEndpoint = ['https://mainnet.infura.io/v3/1234567890']; const mockNodeAddress = '0x1234567890'; process.env.ETHEREUM_ENDPOINT = mockEthereumEndpoint; process.env.NODE_ADDRESS = mockNodeAddress; diff --git a/src/config.example.ts b/src/config.example.ts index 8992a68..96db79a 100644 --- a/src/config.example.ts +++ b/src/config.example.ts @@ -5,7 +5,7 @@ export const exampleConfig: ServiceConfiguration = { Port: 8080, EthereumGenesisContract: '0xD859701C81119aB12A1e62AF6270aD2AE05c7AB3', EthereumFirstBlock: 11191390, - EthereumEndpoint: 'http://ganache:7545', + EthereumEndpoint: ['http://ganache:7545'], DeploymentDescriptorUrl: 'https://deployment.orbs.network/mainnet.json', ElectionsAuditOnly: false, StatusJsonPath: './status/status.json', diff --git a/src/config.test.ts b/src/config.test.ts index 768870c..1e33592 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -7,7 +7,7 @@ test('accepts legal config', (t) => { BootstrapMode: false, Port: 2, EthereumGenesisContract: 'foo', - EthereumEndpoint: 'http://localhost:7545', + EthereumEndpoint: ['http://localhost:7545'], EthereumPollIntervalSeconds: 0.1, EthereumRequestsPerSecondLimit: 0, ElectionsStaleUpdateSeconds: 7 * 24 * 60 * 60, @@ -34,7 +34,7 @@ test('declines illegal config (1)', (t) => { BootstrapMode: false, Port: 2, EthereumGenesisContract: 'foo', - EthereumEndpoint: 'http://localhost:7545', + EthereumEndpoint: ['http://localhost:7545'], EthereumPollIntervalSeconds: 0.1, EthereumRequestsPerSecondLimit: 0, ElectionsStaleUpdateSeconds: 7 * 24 * 60 * 60, @@ -60,7 +60,7 @@ test('declines illegal config (2)', (t) => { BootstrapMode: false, Port: 2, EthereumGenesisContract: 'foo', - EthereumEndpoint: 'foo-bar:123', + EthereumEndpoint: ['foo-bar:123'], EthereumPollIntervalSeconds: 0.1, EthereumRequestsPerSecondLimit: 0, ElectionsStaleUpdateSeconds: 7 * 24 * 60 * 60, diff --git a/src/config.ts b/src/config.ts index 763fec9..c871969 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,10 @@ -import validate from 'validate.js'; +import validate, { isEmpty } from 'validate.js'; export interface ServiceConfiguration { BootstrapMode: boolean; Port: number; EthereumGenesisContract: string; - EthereumEndpoint: string; + EthereumEndpoint: string[]; /** @deprecated Use `EthereumEndpoint` instead */ MaticEndpoint?: string; DeploymentDescriptorUrl: string; @@ -47,6 +47,25 @@ export const defaultServiceConfiguration = { Verbose: false, }; +validate.validators.array = function(value, options, key, attributes) { + if (!Array.isArray(value)) { + return `${key} must be an array.`; + } + + if (options && options.item) { + const errors = value.map((item, index) => { + const error = validate.single(item, options.item); + if (error) { + return `Item ${index + 1}: ${error.join(", ")}`; + } + }).filter(error => error); + + if (errors.length > 0) { + return errors; + } + } +}; + export function validateServiceConfiguration(c: Partial): string[] | undefined { const serviceConfigConstraints = { BootstrapMode: { @@ -101,12 +120,20 @@ export function validateServiceConfiguration(c: Partial): numericality: { noStrings: true }, }, EthereumEndpoint: { - presence: { allowEmpty: false }, - type: 'string', - url: { - allowLocal: true, - }, - }, + presence: true, // Ensure the attribute is present + type: "array", // Ensure it's an array + array: { + item: { + presence: true, // Ensure each item is not empty + type: "string", // Ensure each item in the array is a string + format: { + pattern: /^(https?:\/\/[^\s$.?#].[^\s]*)$/i, // URL regex pattern + message: "must be a valid URL" + } + } + } + }, + EthereumGenesisContract: { presence: { allowEmpty: false }, type: 'string', diff --git a/src/env-var-args.ts b/src/env-var-args.ts index 3ad8521..01bc54e 100644 --- a/src/env-var-args.ts +++ b/src/env-var-args.ts @@ -12,7 +12,8 @@ export function setConfigEnvVars(config: ServiceConfiguration): void { config.BootstrapMode = process.env.BOOTSTRAP_MODE ? process.env.BOOTSTRAP_MODE === 'true' : config.BootstrapMode; config.Port = process.env.PORT ? Number(process.env.PORT) : config.Port; config.EthereumGenesisContract = process.env.ETHEREUM_GENESIS_CONTRACT ?? config.EthereumGenesisContract; - config.EthereumEndpoint = process.env.ETHEREUM_ENDPOINT ?? config.EthereumEndpoint; + // parse ETHEREUM_ENDPOINT, if it has multiple values, split by comma + config.EthereumEndpoint = process.env.ETHEREUM_ENDPOINT ? process.env.ETHEREUM_ENDPOINT.split(',') : config.EthereumEndpoint; config.DeploymentDescriptorUrl = process.env.DEPLOYMENT_DESCRIPTOR_URL ?? config.DeploymentDescriptorUrl; config.ElectionsAuditOnly = process.env.ELECTIONS_AUDIT_ONLY ? process.env.ELECTIONS_AUDIT_ONLY === 'true' diff --git a/src/ethereum/block-sync.test.ts b/src/ethereum/block-sync.test.ts index 2c30c6b..d05e5f1 100644 --- a/src/ethereum/block-sync.test.ts +++ b/src/ethereum/block-sync.test.ts @@ -9,7 +9,7 @@ test.serial('[integration] BlockSync reads registry for contract addresses', asy t.timeout(5 * 60 * 1000); const ethereum = new EthereumTestDriver(true); - const ethereumEndpoint = 'http://localhost:7545'; + const ethereumEndpoint = ['http://localhost:7545']; const finalityBufferBlocks = 5; // setup Ethereum state diff --git a/src/ethereum/ethereum-reader.ts b/src/ethereum/ethereum-reader.ts index ce3189f..4c579ed 100644 --- a/src/ethereum/ethereum-reader.ts +++ b/src/ethereum/ethereum-reader.ts @@ -9,15 +9,17 @@ import https from 'https'; const HTTP_TIMEOUT_SEC = 20; const subDomain = 'eth-api' -const domain = 'orbs.network' +const domain = 'orbs.network' +let timer: NodeJS.Timeout | null = null; export type EthereumConfiguration = { - EthereumEndpoint: string; + EthereumEndpoint: string[]; EthereumRequestsPerSecondLimit: number; }; export class EthereumReader { - private web3: Web3; + private currentWeb3Index = 0; + private web3s: Web3[]; private throttled?: pThrottle.ThrottledFunction<[], void>; private agent: https.Agent; private blockTimeSinceFail: number; @@ -29,21 +31,49 @@ export class EthereumReader { maxSockets: 5, }); this.blockTimeSinceFail = 0; - this.web3 = new Web3( - new Web3.providers.HttpProvider(config.EthereumEndpoint, { + + this.web3s = config.EthereumEndpoint.map(endpoint => new Web3( + new Web3.providers.HttpProvider(endpoint, { keepAlive: true, timeout: HTTP_TIMEOUT_SEC * 1000, }) - ); + )); + if (config.EthereumRequestsPerSecondLimit > 0) { this.throttled = pThrottle(() => Promise.resolve(), config.EthereumRequestsPerSecondLimit, 1000); } } + getWeb3(): Web3 { + return this.web3s[this.currentWeb3Index]; + } + + switchWeb3() { + this.currentWeb3Index = (this.currentWeb3Index + 1) % this.web3s.length; + + if (this.currentWeb3Index != 0) { + if (timer!=null) { + clearTimeout (timer); + } + + timer = setTimeout(() => { + this.currentWeb3Index = 0; + console.log('switchWeb3: switching to web3 to first provider.'); + }, 1000 * 60 * 60); // after an hour, return to the first web3 + } + } + async getBlockNumber(): Promise { if (this.throttled) await this.throttled(); this.requestStats.add(1); - return this.web3.eth.getBlockNumber(); + + try { + return await this.getWeb3().eth.getBlockNumber(); + } catch (error) { + console.error("Error fetching block number:", error); + this.switchWeb3(); + return await this.getWeb3().eth.getBlockNumber(); + } } // orbs GET api dediated to serve block time from cache @@ -92,7 +122,16 @@ export class EthereumReader { // fallback to web3 if (this.throttled) await this.throttled(); this.requestStats.add(1); - const block = await this.web3.eth.getBlock(blockNumber); + + let block + try { + block = await this.getWeb3().eth.getBlock(blockNumber); + } catch (error) { + console.error("Error fetching block number:", error); + this.switchWeb3(); + block = await this.getWeb3().eth.getBlock(blockNumber); + } + if (!block) { throw new Error(`web3.eth.getBlock for ${blockNumber} return empty block.`); } @@ -102,7 +141,18 @@ export class EthereumReader { getContractForEvent(eventName: EventName, address: string): Contract { const contractName = contractByEventName(eventName); const abi = getAbiForContract(address, contractName); - return new this.web3.eth.Contract(abi, address); + + try { + const web3instance = this.getWeb3(); + //return new this.getWeb3().eth.Contract(abi, address); + return new web3instance.eth.Contract(abi, address); + } catch (error) { + console.error("Error fetching contract:", error); + this.switchWeb3(); + const web3instance = this.getWeb3(); + return new web3instance.eth.Contract(abi, address); + //return new this.getWeb3().eth.Contract(abi, address); + } } // throws error if fails, caller needs to decrease page size if needed diff --git a/src/ethereum/test-driver.ts b/src/ethereum/test-driver.ts index b82915b..81f7bb2 100644 --- a/src/ethereum/test-driver.ts +++ b/src/ethereum/test-driver.ts @@ -155,8 +155,8 @@ export class EthereumTestDriver { return await d.web3.eth.getBlockNumber(); } - async getCurrentBlockPreDeploy(ethereumEndpoint: string): Promise { - const web3 = new Web3(ethereumEndpoint); + async getCurrentBlockPreDeploy(ethereumEndpoint: string[]): Promise { + const web3 = new Web3(ethereumEndpoint[0]); return await web3.eth.getBlockNumber(); } diff --git a/src/main.ts b/src/main.ts index 9969c5a..1dd2996 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,13 +10,15 @@ process.on('uncaughtException', function (err) { }); function censorConfig(conf: ServiceConfiguration) { + const censoredEthEndpointArray = conf.EthereumEndpoint.map((endpoint) => endpoint.slice(0, 30) + "**********"); + const external: {[key: string]: any} = { ...conf.ExternalLaunchConfig, - EthereumEndpoint: conf.EthereumEndpoint.slice(0, -10) + "**********", + EthereumEndpoint: censoredEthEndpointArray, } const censored = { ...conf, - EthereumEndpoint: conf.EthereumEndpoint.slice(0, -10) + "**********", + EthereumEndpoint: censoredEthEndpointArray, ExternalLaunchConfig: external }