/ src / utils / rotationProvider.ts
rotationProvider.ts
  1  import { BaseProvider, Network, StaticJsonRpcProvider } from '@ethersproject/providers';
  2  import { logger } from 'ethers';
  3  
  4  const DEFAULT_FALL_FORWARD_DELAY = 60000;
  5  const MAX_RETRIES = 1;
  6  
  7  interface RotationProviderConfig {
  8    maxRetries?: number;
  9    fallFowardDelay?: number;
 10  }
 11  
 12  function sleep(ms: number) {
 13    return new Promise((resolve) => setTimeout(resolve, ms));
 14  }
 15  
 16  /**
 17   * Returns the network as long as all agree. Throws an error if any two networks do not match
 18   * @param networks the list of networks to verify
 19   * @returns Network
 20   */
 21  export function checkNetworks(networks: Network[]): Network {
 22    if (networks.length === 0) {
 23      logger.throwArgumentError('no networks provided', 'networks', networks);
 24    }
 25  
 26    let result: Network | undefined;
 27  
 28    for (let i = 0; i < networks.length; i++) {
 29      const network = networks[i];
 30  
 31      if (!network) {
 32        logger.throwArgumentError('network not defined', 'networks', networks);
 33      }
 34  
 35      if (!result) {
 36        result = network;
 37        continue;
 38      }
 39  
 40      // Make sure the network matches the previous networks
 41      if (
 42        !(
 43          result.name.toLowerCase() === network.name.toLowerCase() &&
 44          result.chainId === network.chainId &&
 45          (result.ensAddress?.toLowerCase() === network.ensAddress?.toLowerCase() ||
 46            (result.ensAddress == null && network.ensAddress == null))
 47        )
 48      ) {
 49        logger.throwArgumentError('provider mismatch', 'networks', networks);
 50      }
 51    }
 52  
 53    if (!result) {
 54      logger.throwArgumentError('no networks defined', 'networks', networks);
 55    }
 56  
 57    return result;
 58  }
 59  
 60  /**
 61   * The provider will rotate rpcs on error.
 62   * If provider rotates away from the first RPC, rotate back after a set interval to prioritize using most reliable RPC.
 63   * If provider rotates through all rpcs, delay to avoid spamming rpcs with requests.
 64   */
 65  export class RotationProvider extends BaseProvider {
 66    readonly providers: StaticJsonRpcProvider[];
 67    private currentProviderIndex = 0;
 68    private firstRotationTimestamp = 0;
 69    // number of full loops through provider array before throwing an error
 70    private maxRetries = 0;
 71    private retries = 0;
 72    // if we rotate away from first rpc, return back after this delay
 73    private fallForwardDelay: number;
 74  
 75    private lastError = '';
 76  
 77    constructor(urls: string[], chainId: number, config?: RotationProviderConfig) {
 78      super(chainId);
 79      this.providers = urls.map((url) => new StaticJsonRpcProvider(url, chainId));
 80  
 81      this.maxRetries = config?.maxRetries || MAX_RETRIES;
 82      this.fallForwardDelay = config?.fallFowardDelay || DEFAULT_FALL_FORWARD_DELAY;
 83    }
 84  
 85    /**
 86     * If we rotate away from the first RPC, rotate back after a set interval to prioritize using most reliable RPC
 87     */
 88    async fallForwardRotation() {
 89      const now = new Date().getTime();
 90      const diff = now - this.firstRotationTimestamp;
 91      if (diff < this.fallForwardDelay) {
 92        await sleep(this.fallForwardDelay - diff);
 93        this.currentProviderIndex = 0;
 94      }
 95    }
 96  
 97    /**
 98     * If rpc fails, rotate to next available and trigger rotation or fall forward delay where applicable
 99     * @param prevIndex last updated index, checked to avoid having multiple active rotations
100     */
101    private async rotateUrl(prevIndex: number) {
102      // don't rotate when another rotation was already triggered
103      if (prevIndex !== this.currentProviderIndex) return;
104      // if we rotate away from the first url, switch back after FALL_FORWARD_DELAY
105      if (this.currentProviderIndex === 0) {
106        this.currentProviderIndex += 1;
107        this.firstRotationTimestamp = new Date().getTime();
108        this.fallForwardRotation();
109      } else if (this.currentProviderIndex === this.providers.length - 1) {
110        this.retries += 1;
111        if (this.retries > this.maxRetries) {
112          this.retries = 0;
113          throw new Error(
114            `RotationProvider exceeded max number of retries. Last error: ${this.lastError}`
115          );
116        }
117        this.currentProviderIndex = 0;
118      } else {
119        this.currentProviderIndex += 1;
120      }
121    }
122  
123    async detectNetwork(): Promise<Network> {
124      const networks = await Promise.all(this.providers.map((c) => c.getNetwork()));
125      return checkNetworks(networks);
126    }
127  
128    // eslint-disable-next-line @typescript-eslint/no-explicit-any
129    async send(method: string, params: Array<any>): Promise<any> {
130      const index = this.currentProviderIndex;
131      try {
132        return await this.providers[index].send(method, params);
133      } catch (e) {
134        console.error(e.message);
135        await this.rotateUrl(index);
136        return this.send(method, params);
137      }
138    }
139  
140    // eslint-disable-next-line
141    async perform(method: string, params: any): Promise<any> {
142      const index = this.currentProviderIndex;
143      try {
144        return await this.providers[index].perform(method, params);
145      } catch (e) {
146        console.error(e.message);
147        this.lastError = e.message;
148        this.emit('debug', {
149          action: 'perform',
150          provider: this.providers[index],
151        });
152        await this.rotateUrl(index);
153        return await this.perform(method, params);
154      }
155    }
156  }