synapsecns/sanguine

View on GitHub
packages/rfq-loadtest/src/index.ts

Summary

Maintainability
A
0 mins
Test Coverage
import fs from 'fs/promises'

import * as viemChains from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
import {
  createPublicClient,
  createWalletClient,
  formatUnits,
  http,
  parseUnits,
  publicActions,
  PublicClient,
  WalletClient,
} from 'viem'
import yaml from 'js-yaml'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
import axios from 'axios'
import { createNonceManager, jsonRpc } from 'viem/nonce'

import { ABI } from './abi.js'
import { delay, print, getRandomInt } from './utils.js'

const argv = await yargs(hideBin(process.argv))
  .option('configFile', {
    alias: 'c',
    type: 'string',
    description: 'Path to the config file',
    demandOption: true,
  })
  .option('pKeyIndex', {
    alias: 'p',
    type: 'number',
    description: 'Index of the private key',
    demandOption: true,
  })
  .help().argv

const configFilePath = argv.configFile
const configFileContent = await fs.readFile(configFilePath, 'utf-8')

let config: any
try {
  config = yaml.load(configFileContent) as object

  if (typeof config !== 'object' || config === null) {
    throw new Error()
  }

  if (
    typeof config.VOLLEY_MILLISECONDS_BETWEEN !== 'number' ||
    typeof config.VOLLEY_MIN_COUNT !== 'number' ||
    typeof config.VOLLEY_MAX_COUNT !== 'number'
  ) {
    throw new Error('Invalid configuration values for volley settings')
  }

  if (typeof config.CHAINS !== 'object' || config.CHAINS === null) {
    throw new Error('Invalid configuration for CHAINS')
  }

  if (typeof config.TEST_ROUTES !== 'object' || config.TEST_ROUTES === null) {
    throw new Error('Invalid configuration for TEST_ROUTES')
  }

  if (typeof config.TEST_BRIDGE_AMOUNT_UNITS !== 'number') {
    throw new Error('Invalid configuration for TEST_BRIDGE_AMOUNT_UNITS')
  }
  if (typeof config.MINIMUM_GAS_UNITS !== 'number') {
    throw new Error('Invalid configuration for MINIMUM_GAS_UNITS')
  }
  if (typeof config.REBALANCE_TO_UNITS !== 'number') {
    throw new Error('Invalid configuration for REBALANCE_TO_UNITS')
  }

  Object.entries(config.TEST_ROUTES).forEach(
    ([route, details]: [string, any]) => {
      if (typeof details !== 'object' || details === null) {
        throw new Error(`Invalid configuration for route: ${route}`)
      }
      if (
        typeof details.fromChainId !== 'number' ||
        typeof details.toChainId !== 'number' ||
        typeof details.testDistributionPercentage !== 'number'
      ) {
        throw new Error(`Invalid configuration values for route: ${route}`)
      }
    }
  )
} catch (error: any) {
  throw new Error(
    `Failed to parse ${configFilePath}. Check your syntax, structure, data, and for duplicates. \n${error.message}`
  )
}

const privateKeyIndex = argv.pKeyIndex
if (typeof privateKeyIndex !== 'number' || privateKeyIndex < 1) {
  throw new Error('pKeyIndex must be a positive integer')
}

const privateKeyName = `PRIVATE_KEY_${privateKeyIndex}`
const privateKey: `0x${string}` = config[privateKeyName] as `0x${string}`

if (!privateKey) {
  throw new Error(`${privateKeyName} is not defined in the config file`)
}

// construct enriched versions of viem chain objects ("vChains") based on what was supplied in config file
const vChains: any = {}
Object.entries(config.CHAINS).forEach(
  ([chainId, chainConfig]: [string, any]) => {
    const viemChain = Object.values(viemChains).find(
      (chain) => chain.id === parseInt(chainId, 10)
    )
    if (!viemChain) {
      throw new Error(
        `No viem chain config found for chain ID ${chainId}. Bump viem version or add manually`
      )
    }

    vChains[chainId] = {
      ...viemChain,
      ...chainConfig,
      vCliRead: {} as PublicClient,
      vCliSim: {} as WalletClient,
      vCliWrite: {} as WalletClient,
    }
  }
)

const nonceManager = createNonceManager({
  source: jsonRpc(),
})

const walletAccount = privateKeyToAccount(privateKey, { nonceManager })

print(`Using ${privateKeyName}: ${walletAccount.address}`)

Object.keys(vChains).forEach((chainId: string) => {
  const chain = vChains[chainId]

  chain.vCliRead = createPublicClient({
    chain,
    transport: http(chain.rpcUrl_Read),
  }) as PublicClient

  chain.vCliSim = createWalletClient({
    chain,
    transport: http(chain.rpcUrl_Sim),
  }).extend(publicActions)

  chain.vCliWrite = createWalletClient({
    chain,
    transport: http(chain.rpcUrl_Write),
  }).extend(publicActions)

  Promise.all([
    chain.vCliRead.readContract({
      address: chain.FastRouterAddr,
      abi: ABI.fastRouterV2,
      functionName: 'fastBridge',
    }),
    // not just used to report block height. also serves as connectivity test for all three clients.
    chain.vCliRead.getBlockNumber(),
    chain.vCliSim.getBlockNumber(),
    chain.vCliWrite.getBlockNumber(),
  ]).then(
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    ([fastBridgeAddr, blockNumber_Read, blockNumber_Sim, blockNumber_Write]: [
      string,
      bigint,
      bigint,
      bigint
    ]) => {
      print(
        `Connected to chain ID ${chainId
          .toString()
          .padStart(7)}. FastBridge at: ${fastBridgeAddr.slice(
          0,
          6
        )}... Current block height: ${blockNumber_Read}`
      )
    }
  )
})

let cachedResponseRFQ: any

let sendCounter = 0

let lastAction: string = 'none'

const testBridgeAmountUnits = config.TEST_BRIDGE_AMOUNT_UNITS

mainFlow()

async function mainFlow() {
  await delay(1500)

  while (!walletAccount.address) {
    print(`%ts Awaiting Initialization...`)
    await delay(5000)
  }

  checkBals()

  looper_getRequestParams()

  await delay(2500)

  while (!cachedResponseRFQ) {
    print(`%ts Awaiting cached RFQ API response to populate...`)
    await delay(5000)
  }

  bridgeLooper()
}

async function checkBals() {
  for (;;) {
    await Promise.all(
      Object.keys(vChains).map(async (chainId: string) => {
        const chain = vChains[chainId]
        try {
          const balance = await chain.vCliRead.getBalance({
            address: walletAccount.address,
          })
          chain.balanceRaw = balance
          chain.balanceUnits = formatUnits(balance, 18)
        } catch (error: any) {
          print(
            `Error fetching balance for chain ID ${chainId}: ${error.message}`
          )
        }
      })
    )

    await delay(15_000)
  }
}

const minGasUnits = config.MINIMUM_GAS_UNITS
const rebalToUnits = config.REBALANCE_TO_UNITS
async function bridgeLooper() {

    let retryCount = 0

  for (;;) {
    // Find the chain with the lowest balance below our minimum gas -- if any
    const rebalToChain: any = Object.values(vChains).find(
      (chain: any) => chain.balanceUnits < minGasUnits
    )

    if (rebalToChain) {
      const rebalFromChain: any = Object.values(vChains).reduce(
        (prev: any, current: any) => {
          return prev.balanceUnits > current.balanceUnits ? prev : current
        }
      )

      const rebalLabel = `%ts Rebal: ${rebalFromChain.id} > ${rebalToChain.id}`

      print(rebalLabel)

      // avoid repeating rebal actions. just loop until it lands on-chain.
      if (lastAction === `rebal${rebalFromChain.id}>${rebalToChain.id}`) {
        print(
          `${rebalLabel} Last action was identical (${lastAction}). Not repeating. Re-evaluating momentarily...`
        )

        if (retryCount > 5) {
          // abort after X attempts - if running in repeater mode this will effectively re-send the rebal tx if it is still needed
          print(`${rebalLabel} Max retries. Exiting process...`)
          await delay(1500)
          process.exit()
        }

        await delay(7500)
        retryCount++
        continue
      }

      retryCount=0

      // leave rebalFrom chain with X units
      const rebalAmount = rebalToUnits - rebalToChain.balanceUnits

      if (rebalFromChain.balanceUnits < rebalToUnits * 1.1) {
        // if we hit this point, it indicates the wallet has no funds left to keep playing. hang process.
        print(
          `${rebalLabel} - Insuff Funds on From Chain ${rebalFromChain.balanceUnits}. Ending tests.`
        )
        await delay(60_000)
        return
      }

      await sendBridge(
        rebalFromChain,
        rebalToChain,
        Number(rebalAmount.toFixed(18)),
        false,
        rebalLabel
      )

      lastAction = `rebal${rebalFromChain.id}>${rebalToChain.id}`

      await delay(config.VOLLEY_MILLISECONDS_BETWEEN)
      continue
    }

    let fromChain: any
    let toChain: any

    const totalPercentage: number = Object.values(config.TEST_ROUTES).reduce(
      (acc: number, route: any) => acc + route.testDistributionPercentage,
      0
    )
    const randomizer: number = getRandomInt(1, totalPercentage)
    let cumulative = 0

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    for (const [route, details] of Object.entries(config.TEST_ROUTES) as [
      string,
      any
    ][]) {
      cumulative += details.testDistributionPercentage
      if (randomizer <= cumulative) {
        fromChain = vChains[`${details.fromChainId}`]
        toChain = vChains[`${details.toChainId}`]
        break
      }
    }

    const countToSend = getRandomInt(
      config.VOLLEY_MIN_COUNT,
      config.VOLLEY_MAX_COUNT
    )
    const printLabel = `%ts Batch${(sendCounter + countToSend)
      .toString()
      .padStart(5, '0')} of ${countToSend} : ${fromChain.id
      .toString()
      .padStart(7)} >> ${toChain.id.toString().padEnd(7)}`

    for (let i = 0; i < countToSend; i++) {
      // sendCounter is applied as a tag on the amount just for sloppy tracking purposes. not actually important.
      sendBridge(
        fromChain,
        toChain,
        Number(
          (testBridgeAmountUnits + sendCounter / 100000000000).toFixed(18)
        ),
        true,
        printLabel
      )
      sendCounter++
      await delay(50)
    }

    lastAction = 'testVolley'
    await delay(config.VOLLEY_MILLISECONDS_BETWEEN)
  }
}

async function getRequestParams(
  fromChain: any,
  toChain: any,
  sendAmountUnits: number
) {
  // 480 is not supported currently on the API - using Opti/Base as proxies. Can be improved later as needed.
  if (toChain.id === 480) {
    toChain = fromChain.id === 8453 ? vChains['10'] : vChains['8453']
  }
  if (fromChain.id === 480) {
    fromChain = toChain.id === 8453 ? vChains['10'] : vChains['8453']
  }

  const requestURL = `https://api.synapseprotocol.com/bridge?fromChain=${
    fromChain.id
  }&toChain=${
    toChain.id
  }&fromToken=${'0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'}&toToken=${'0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'}&amount=${sendAmountUnits}&destAddress=${
    walletAccount.address
  }`

  let responseRFQ: any
  let response: any
  try {
    response = await axios.request({
      url: requestURL,
    })

    if ((response.data?.length ?? 0) === 0) {
      throw new Error(`No data returned from api`)
    }

    responseRFQ = Array.isArray(response.data)
      ? response.data.find(
          (item: any) => item.bridgeModuleName === 'SynapseRFQ'
        )
      : null
    if (!responseRFQ) {
      throw new Error(`No RFQ response returned from api`)
    }
  } catch (error: any) {
    throw new Error(
      `RFQ Api Fail: ${error.message.substr(0, 50)} -- ${requestURL}`
    )
  }

  return responseRFQ
}

async function looper_getRequestParams() {
  for (;;) {
    // in future iteration, this could be improved to dynamically pull a response for each route that is involved w/ testing.
    // for now, all tests just use a cached route btwn two OP stacks as proxies for Worldchain tests -- because this is close enough.
    cachedResponseRFQ = await getRequestParams(
      vChains['8453'],
      vChains['10'],
      testBridgeAmountUnits
    )

    await delay(10_000)
  }
}

async function sendBridge(
  fromChain: any,
  toChain: any,
  sendAmountUnits: number,
  useCachedRequest: boolean,
  printLabel: string
) {
  const sendAmountRaw = parseUnits(sendAmountUnits.toString(), 18)

  printLabel = printLabel + ` ${sendAmountUnits} ETH`

  const _responseRFQ = useCachedRequest
    ? cachedResponseRFQ
    : await getRequestParams(fromChain, toChain, sendAmountUnits)

  const contractCall: any = {
    address: fromChain.FastRouterAddr as `0x${string}`,
    abi: ABI.fastRouterV2,
    functionName: 'bridge',
    account: walletAccount,
    chain: fromChain,
    args: [
      walletAccount.address, //recipient
      toChain.id, // chainId
      '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // token
      sendAmountRaw, // amount
      [
        //originQuery
        '0x0000000000000000000000000000000000000000', //routerAdapter
        _responseRFQ.originQuery.tokenOut, //tokenOut
        sendAmountRaw, //minAmountOut
        BigInt(_responseRFQ.originQuery.deadline.hex), //deadline
        _responseRFQ.originQuery.rawParams, //rawParms
      ],
      [
        //destQuery
        '0x0000000000000000000000000000000000000000', //routerAdapter
        _responseRFQ.destQuery.tokenOut, //tokenOut
        BigInt(_responseRFQ.destQuery.minAmountOut.hex), //minAmountOut
        BigInt(_responseRFQ.destQuery.deadline.hex), //deadline
        _responseRFQ.destQuery.rawParams, //rawParms
      ],
    ],
    value: sendAmountRaw,
  }

  let estGasUnits

  try {
    //@ts-ignore
    estGasUnits = await fromChain.vCliSim.estimateContractGas(contractCall)
  } catch (error: any) {
    throw new Error(`${printLabel} Bridge Sim error: ${error.message}`)
  }

  if (estGasUnits <= 50000n) {
    throw new Error(`${printLabel} estimated gas units too low. possible error`)
  }

  contractCall.gas = Math.floor(Number(estGasUnits) * 1.3)

  let txHash
  try {
    txHash = await fromChain.vCliWrite.writeContract(contractCall)
  } catch (error: any) {
    throw new Error(`${printLabel} Send failed: ${error.message}`)
  }

  print(`${printLabel} Submitted ${txHash}`)
}