diff --git a/.github/workflows/warp-monitor-docker.yml b/.github/workflows/warp-monitor-docker.yml new file mode 100644 index 00000000000..a163627a1cc --- /dev/null +++ b/.github/workflows/warp-monitor-docker.yml @@ -0,0 +1,125 @@ +name: Build and Push Warp Monitor Image to GCR +on: + push: + branches: [main] + tags: + - '**' + pull_request: + paths: + - 'typescript/warp-monitor/**' + - '.github/workflows/warp-monitor-docker.yml' + workflow_dispatch: + inputs: + include_arm64: + description: 'Include arm64 in the build' + required: false + default: 'false' + +concurrency: + group: build-push-warp-monitor-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-env: + runs-on: ubuntu-latest + outputs: + gcloud-service-key: ${{ steps.gcloud-service-key.outputs.defined }} + steps: + - id: gcloud-service-key + env: + GCLOUD_SERVICE_KEY: ${{ secrets.GCLOUD_SERVICE_KEY }} + if: "${{ env.GCLOUD_SERVICE_KEY != '' }}" + run: echo "defined=true" >> $GITHUB_OUTPUT + + build-and-push-to-gcr: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + pull-requests: write + + needs: [check-env] + if: needs.check-env.outputs.gcloud-service-key == 'true' + + steps: + - name: Generate GitHub App Token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HYPER_GONK_APP_ID }} + private-key: ${{ secrets.HYPER_GONK_PRIVATE_KEY }} + + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive + fetch-depth: 0 + + - name: Generate tag data + id: taggen + run: | + echo "TAG_DATE=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT + echo "TAG_SHA=$(echo '${{ github.event.pull_request.head.sha || github.sha }}' | cut -b 1-7)" >> $GITHUB_OUTPUT + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + gcr.io/abacus-labs-dev/hyperlane-warp-monitor + tags: | + type=ref,event=branch + type=ref,event=pr + type=raw,value=${{ steps.taggen.outputs.TAG_SHA }}-${{ steps.taggen.outputs.TAG_DATE }} + + - name: Set up Depot CLI + uses: depot/setup-action@v1 + + - name: Login to GCR + uses: docker/login-action@v3 + with: + registry: gcr.io + username: _json_key + password: ${{ secrets.GCLOUD_SERVICE_KEY }} + + - name: Determine platforms + id: determine-platforms + run: | + if [ "${{ github.event.inputs.include_arm64 }}" == "true" ]; then + echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + else + echo "platforms=linux/amd64" >> $GITHUB_OUTPUT + fi + + - name: Get Foundry version + id: foundry-version + run: | + FOUNDRY_VERSION=$(cat solidity/.foundryrc) + echo "FOUNDRY_VERSION=$FOUNDRY_VERSION" >> $GITHUB_OUTPUT + + - name: Build and push + id: build + uses: depot/build-push-action@v1 + with: + project: 3cpjhx94qv + context: ./ + file: ./typescript/warp-monitor/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: ${{ steps.determine-platforms.outputs.platforms }} + build-args: | + FOUNDRY_VERSION=${{ steps.foundry-version.outputs.FOUNDRY_VERSION }} + SERVICE_VERSION=${{ steps.taggen.outputs.TAG_SHA }}-${{ steps.taggen.outputs.TAG_DATE }} + + - name: Comment image tags on PR + if: github.event_name == 'pull_request' + uses: ./.github/actions/docker-image-comment + with: + comment_tag: warp-monitor-docker-image + image_name: Warp Monitor Docker Image + emoji: 🕵️ + image_tags: ${{ steps.meta.outputs.tags }} + pr_number: ${{ github.event.pull_request.number }} + github_token: ${{ steps.generate-token.outputs.token }} + job_status: ${{ job.status }} diff --git a/Dockerfile b/Dockerfile index ddebd938a4c..18c9ead4581 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ COPY typescript/rebalancer/package.json ./typescript/rebalancer/ COPY typescript/sdk/package.json ./typescript/sdk/ COPY typescript/tsconfig/package.json ./typescript/tsconfig/ COPY typescript/utils/package.json ./typescript/utils/ +COPY typescript/warp-monitor/package.json ./typescript/warp-monitor/ COPY typescript/widgets/package.json ./typescript/widgets/ COPY solidity/package.json ./solidity/ COPY solhint-plugin/package.json ./solhint-plugin/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb7f873a80c..8d1bcb5ca0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2116,6 +2116,76 @@ importers: specifier: ^1.3.0 version: 1.3.0 + typescript/warp-monitor: + dependencies: + '@google-cloud/pino-logging-gcp-config': + specifier: 'catalog:' + version: 1.3.0 + '@hyperlane-xyz/core': + specifier: workspace:* + version: link:../../solidity + '@hyperlane-xyz/registry': + specifier: 'catalog:' + version: 23.7.0 + '@hyperlane-xyz/sdk': + specifier: workspace:* + version: link:../sdk + '@hyperlane-xyz/utils': + specifier: workspace:* + version: link:../utils + ethers: + specifier: 'catalog:' + version: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + pino: + specifier: 'catalog:' + version: 8.21.0 + prom-client: + specifier: 'catalog:' + version: 14.2.0 + devDependencies: + '@hyperlane-xyz/eslint-config': + specifier: workspace:^ + version: link:../eslint-config + '@hyperlane-xyz/tsconfig': + specifier: workspace:^ + version: link:../tsconfig + '@types/chai': + specifier: 'catalog:' + version: 4.3.20 + '@types/mocha': + specifier: 'catalog:' + version: 10.0.10 + '@types/node': + specifier: 'catalog:' + version: 20.19.25 + '@types/sinon': + specifier: 'catalog:' + version: 17.0.4 + '@vercel/ncc': + specifier: 'catalog:' + version: 0.38.4 + chai: + specifier: 'catalog:' + version: 4.5.0 + eslint: + specifier: 'catalog:' + version: 9.31.0(jiti@2.6.1) + mocha: + specifier: 'catalog:' + version: 11.7.5 + prettier: + specifier: 'catalog:' + version: 3.5.3 + sinon: + specifier: 'catalog:' + version: 13.0.2 + tsx: + specifier: 'catalog:' + version: 4.19.1 + typescript: + specifier: 'catalog:' + version: 5.8.3 + typescript/widgets: dependencies: '@chain-registry/types': diff --git a/scripts/check-package-json.sh b/scripts/check-package-json.sh index 1b549b82f5a..2651c87725e 100755 --- a/scripts/check-package-json.sh +++ b/scripts/check-package-json.sh @@ -79,7 +79,15 @@ done echo "Checking Dockerfile COPY statements..." +# Packages with their own Dockerfiles (not included in main Dockerfile) +EXCLUDED_PACKAGES="typescript/warp-monitor/package.json" + for pkg in $PACKAGE_FILES; do + # Skip packages that have their own Dockerfiles + if echo "$EXCLUDED_PACKAGES" | grep -q "$pkg"; then + continue + fi + # Check if Dockerfile has a COPY statement for this package.json if ! grep -q "COPY $pkg" Dockerfile; then echo "ERROR: Dockerfile is missing COPY statement for $pkg" diff --git a/typescript/infra/helm/warp-routes/templates/_helpers.tpl b/typescript/infra/helm/warp-routes/templates/_helpers.tpl index 3f5f8c02762..ccebe506583 100644 --- a/typescript/infra/helm/warp-routes/templates/_helpers.tpl +++ b/typescript/infra/helm/warp-routes/templates/_helpers.tpl @@ -70,22 +70,18 @@ The warp-routes container env: - name: LOG_FORMAT value: json - - name: REGISTRY_COMMIT - value: {{ .Values.hyperlane.registryCommit }} - # Use `args` instead of `command` to preserve the Docker ENTRYPOINT. - # The entrypoint script (docker-entrypoint.sh) handles REGISTRY_COMMIT checkout. - # Using `command` would bypass the entrypoint and break registry version pinning. - args: - - pnpm - - exec - - tsx - - ./typescript/infra/scripts/warp-routes/monitor/monitor-warp-route-balances.ts - - -v - - "30000" - - --warpRouteId - - {{ .Values.warpRouteId }} - - -e - - {{ .Values.environment}} + - name: LOG_LEVEL + value: info + {{- if .Values.hyperlane.registryUri }} + - name: REGISTRY_URI + value: {{ .Values.hyperlane.registryUri }} + {{- end }} + - name: WARP_ROUTE_ID + value: {{ .Values.warpRouteId }} + - name: CHECK_FREQUENCY + value: "30000" + - name: COINGECKO_API_KEY + value: $(COINGECKO_API_KEY) envFrom: - secretRef: name: {{ include "hyperlane.fullname" . }}-secret diff --git a/typescript/infra/helm/warp-routes/values.yaml b/typescript/infra/helm/warp-routes/values.yaml index 69d2be36d5a..74b9c4e506d 100644 --- a/typescript/infra/helm/warp-routes/values.yaml +++ b/typescript/infra/helm/warp-routes/values.yaml @@ -1,11 +1,11 @@ image: - repository: gcr.io/hyperlane-labs-dev/hyperlane-monorepo + repository: gcr.io/abacus-labs-dev/hyperlane-warp-monitor tag: hyperlane: runEnv: mainnet3 context: hyperlane chains: [] - registryCommit: + registryUri: '' nameOverride: '' fullnameOverride: '' externalSecrets: diff --git a/typescript/infra/scripts/warp-routes/monitor/monitor-warp-route-balances.ts b/typescript/infra/scripts/warp-routes/monitor/monitor-warp-route-balances.ts deleted file mode 100644 index f56200084fb..00000000000 --- a/typescript/infra/scripts/warp-routes/monitor/monitor-warp-route-balances.ts +++ /dev/null @@ -1,610 +0,0 @@ -import { Contract, PopulatedTransaction } from 'ethers'; - -import { IXERC20VS__factory } from '@hyperlane-xyz/core'; -import { - ChainMap, - ChainMetadata, - CoinGeckoTokenPriceGetter, - EvmHypXERC20Adapter, - EvmHypXERC20LockboxAdapter, - EvmTokenAdapter, - IHypXERC20Adapter, - MultiProtocolProvider, - SealevelHypTokenAdapter, - Token, - TokenStandard, - TokenType, - WarpCore, - WarpRouteDeployConfig, -} from '@hyperlane-xyz/sdk'; -import { - Address, - ProtocolType, - objMap, - objMerge, - sleep, -} from '@hyperlane-xyz/utils'; - -import { getWarpCoreConfig } from '../../../config/registry.js'; -import { getCoinGeckoApiKey } from '../../../src/coingecko/utils.js'; -import { startMetricsServer } from '../../../src/utils/metrics.js'; -import { - getArgs, - getWarpRouteIdInteractive, - withWarpRouteId, -} from '../../agent-utils.js'; -import { getEnvironmentConfig } from '../../core-utils.js'; - -import { - metricsRegister, - updateManagedLockboxBalanceMetrics, - updateNativeWalletBalanceMetrics, - updateTokenBalanceMetrics, - updateXERC20LimitsMetrics, -} from './metrics.js'; -import { NativeWalletBalance, WarpRouteBalance, XERC20Limit } from './types.js'; -import { logger, setLoggerBindings, tryFn } from './utils.js'; - -interface XERC20Info { - limits: XERC20Limit; - xERC20Address: Address; -} - -async function main() { - const { - checkFrequency, - environment, - warpRouteId: warpRouteIdArg, - } = await withWarpRouteId(getArgs()) - .describe('checkFrequency', 'frequency to check balances in ms') - .demandOption('checkFrequency') - .alias('v', 'checkFrequency') // v as in Greek letter nu - .number('checkFrequency') - .parse(); - - const warpRouteId = - warpRouteIdArg || (await getWarpRouteIdInteractive(environment)); - - setLoggerBindings({ - warp_route: warpRouteId, - }); - - startMetricsServer(metricsRegister); - - const envConfig = getEnvironmentConfig(environment); - const registry = await envConfig.getRegistry(); - const chainMetadata = await registry.getMetadata(); - const chainAddresses = await registry.getAddresses(); - - // The Sealevel warp adapters require the Mailbox address, so we - // get mailboxes for all chains and merge them with the chain metadata. - const mailboxes = objMap(chainAddresses, (_, { mailbox }) => ({ - mailbox, - })); - const multiProtocolProvider = new MultiProtocolProvider( - objMerge(chainMetadata, mailboxes), - ); - const warpCoreConfig = getWarpCoreConfig(warpRouteId); - const warpCore = WarpCore.FromConfig(multiProtocolProvider, warpCoreConfig); - const warpDeployConfig = await registry.getWarpDeployConfig(warpRouteId); - - await pollAndUpdateWarpRouteMetrics( - checkFrequency, - warpCore, - warpDeployConfig, - chainMetadata, - warpRouteId, - ); -} - -// Indefinitely loops, updating warp route metrics at the specified frequency. -async function pollAndUpdateWarpRouteMetrics( - checkFrequency: number, - warpCore: WarpCore, - warpDeployConfig: WarpRouteDeployConfig | null, - chainMetadata: ChainMap, - warpRouteId: string, -) { - const tokenPriceGetter = new CoinGeckoTokenPriceGetter({ - chainMetadata, - apiKey: await getCoinGeckoApiKey(logger), - }); - - while (true) { - await tryFn(async () => { - await Promise.all( - warpCore.tokens.map((token) => - updateTokenMetrics( - warpCore, - warpDeployConfig, - token, - tokenPriceGetter, - warpRouteId, - ), - ), - ); - }, 'Updating warp route metrics'); - await sleep(checkFrequency); - } -} - -// Updates the metrics for a single token in a warp route. -async function updateTokenMetrics( - warpCore: WarpCore, - warpDeployConfig: WarpRouteDeployConfig | null, - token: Token, - tokenPriceGetter: CoinGeckoTokenPriceGetter, - warpRouteId: string, -) { - const promises = [ - tryFn(async () => { - const balanceInfo = await getTokenBridgedBalance( - warpCore, - token, - tokenPriceGetter, - ); - if (!balanceInfo) { - return; - } - updateTokenBalanceMetrics(warpCore, token, balanceInfo, warpRouteId); - }, 'Getting bridged balance and value'), - ]; - - // For Sealevel collateral and synthetic tokens, there is an - // "Associated Token Account" (ATA) rent payer that has a balance - // that's used to pay for rent for the accounts that store user balances. - // This is necessary if the recipient has never received any tokens before. - if (token.protocol === ProtocolType.Sealevel && !token.isNative()) { - promises.push( - tryFn(async () => { - const balance = await getSealevelAtaPayerBalance( - warpCore, - token, - warpRouteId, - ); - updateNativeWalletBalanceMetrics(balance); - }, 'Getting ATA payer balance'), - ); - } - - if (token.isXerc20()) { - promises.push( - tryFn(async () => { - const { limits, xERC20Address } = await getXERC20Info(warpCore, token); - const routerAddress = token.addressOrDenom; - updateXERC20LimitsMetrics( - token, - limits, - routerAddress, - token.standard, - xERC20Address, - ); - }, 'Getting xERC20 limits'), - ); - - if (!warpDeployConfig) { - logger.warn( - { token: token.symbol, chain: token.chainName }, - 'Failed to read warp deploy config, skipping extra lockboxes', - ); - return; - } - - // If the current token is an xERC20, we need to check if there are any extra lockboxes - const currentTokenDeployConfig = warpDeployConfig[token.chainName]; - if ( - currentTokenDeployConfig.type !== TokenType.XERC20 && - currentTokenDeployConfig.type !== TokenType.XERC20Lockbox - ) { - logger.error( - { - expected: 'XERC20|XERC20Lockbox', - actual: currentTokenDeployConfig.type, - token: token.symbol, - chain: token.chainName, - }, - 'Invalid deploy config type for xERC20 token', - ); - return; - } - - const extraLockboxes = currentTokenDeployConfig.xERC20?.extraBridges ?? []; - - for (const lockbox of extraLockboxes) { - promises.push( - tryFn(async () => { - const { limits, xERC20Address } = await getExtraLockboxInfo( - token, - warpCore.multiProvider, - lockbox.lockbox, - ); - - updateXERC20LimitsMetrics( - token, - limits, - lockbox.lockbox, - 'EvmManagedLockbox', - xERC20Address, - ); - }, 'Getting extra lockbox limits'), - tryFn(async () => { - const balance = await getExtraLockboxBalance( - token, - warpCore.multiProvider, - tokenPriceGetter, - lockbox.lockbox, - ); - - if (balance) { - const { tokenName, tokenAddress } = - await getManagedLockBoxCollateralInfo( - token, - warpCore.multiProvider, - lockbox.lockbox, - ); - - updateManagedLockboxBalanceMetrics( - warpCore, - token.chainName, - tokenName, - tokenAddress, - lockbox.lockbox, - balance, - warpRouteId, - ); - } - }, `Updating extra lockbox balance for contract at "${lockbox.lockbox}" on chain ${token.chainName}`), - ); - } - } - - await Promise.all(promises); -} - -// Gets the bridged balance and value of a token in a warp route. -async function getTokenBridgedBalance( - warpCore: WarpCore, - token: Token, - tokenPriceGetter: CoinGeckoTokenPriceGetter, -): Promise { - if (!token.isHypToken()) { - logger.warn( - { token: token.symbol, chain: token.chainName }, - 'No support for bridged balance on non-Hyperlane token', - ); - return undefined; - } - - const adapter = token.getHypAdapter(warpCore.multiProvider); - let tokenAddress = token.collateralAddressOrDenom ?? token.addressOrDenom; - const bridgedSupply = await adapter.getBridgedSupply(); - if (bridgedSupply === undefined) { - logger.warn( - { token: token.symbol, chain: token.chainName }, - 'Failed to get bridged supply', - ); - return undefined; - } - const balance = token.amount(bridgedSupply).getDecimalFormattedAmount(); - - let tokenPrice; - // Only record value for collateralized and xERC20 lockbox tokens. - if ( - token.isCollateralized() || - token.standard === TokenStandard.EvmHypXERC20Lockbox || - token.standard === TokenStandard.EvmHypVSXERC20Lockbox - ) { - tokenPrice = await tryGetTokenPrice(token, tokenPriceGetter); - } - - if ( - token.standard === TokenStandard.EvmHypXERC20Lockbox || - token.standard === TokenStandard.EvmHypVSXERC20Lockbox - ) { - tokenAddress = (await (adapter as EvmHypXERC20LockboxAdapter).getXERC20()) - .address; - } - - return { - balance, - valueUSD: tokenPrice ? balance * tokenPrice : undefined, - tokenAddress, - }; -} - -async function getManagedLockBoxCollateralInfo( - warpToken: Token, - multiProtocolProvider: MultiProtocolProvider, - lockBoxAddress: Address, -): Promise<{ tokenName: string; tokenAddress: Address }> { - const lockBoxInstance = await getManagedLockBox( - warpToken, - multiProtocolProvider, - lockBoxAddress, - ); - - const collateralTokenAddress = await lockBoxInstance.ERC20(); - const collateralTokenAdapter = new EvmTokenAdapter( - warpToken.chainName, - multiProtocolProvider, - { - token: collateralTokenAddress, - }, - ); - - const { name } = await collateralTokenAdapter.getMetadata(); - - return { - tokenName: name, - tokenAddress: collateralTokenAddress, - }; -} - -function formatBigInt(warpToken: Token, num: bigint): number { - return warpToken.amount(num).getDecimalFormattedAmount(); -} - -// Gets the native balance of the ATA payer, which is used to pay for -// rent when delivering tokens to an account that previously didn't -// have a balance. -// Only intended for Collateral or Synthetic Sealevel tokens. -async function getSealevelAtaPayerBalance( - warpCore: WarpCore, - token: Token, - warpRouteId: string, -): Promise { - if (token.protocol !== ProtocolType.Sealevel || token.isNative()) { - throw new Error( - `Unsupported ATA payer protocol type ${token.protocol} or standard ${token.standard}`, - ); - } - const adapter = token.getHypAdapter( - warpCore.multiProvider, - ) as SealevelHypTokenAdapter; - - const ataPayer = adapter.deriveAtaPayerAccount().toString(); - const nativeToken = Token.FromChainMetadataNativeToken( - warpCore.multiProvider.getChainMetadata(token.chainName), - ); - const ataPayerBalance = await nativeToken.getBalance( - warpCore.multiProvider, - ataPayer, - ); - return { - chain: token.chainName, - walletAddress: ataPayer.toString(), - walletName: `${warpRouteId}/ata-payer`, - balance: ataPayerBalance.getDecimalFormattedAmount(), - }; -} - -async function getXERC20Info( - warpCore: WarpCore, - token: Token, -): Promise { - if (token.protocol !== ProtocolType.Ethereum) { - throw new Error(`Unsupported XERC20 protocol type ${token.protocol}`); - } - - if ( - token.standard === TokenStandard.EvmHypXERC20 || - token.standard === TokenStandard.EvmHypVSXERC20 - ) { - const adapter = token.getAdapter( - warpCore.multiProvider, - ) as EvmHypXERC20Adapter; - return { - limits: await getXERC20Limit(token, adapter), - xERC20Address: (await adapter.getXERC20()).address, - }; - } else if ( - token.standard === TokenStandard.EvmHypXERC20Lockbox || - token.standard === TokenStandard.EvmHypVSXERC20Lockbox - ) { - const adapter = token.getAdapter( - warpCore.multiProvider, - ) as EvmHypXERC20LockboxAdapter; - return { - limits: await getXERC20Limit(token, adapter), - xERC20Address: (await adapter.getXERC20()).address, - }; - } - throw new Error(`Unsupported XERC20 token standard ${token.standard}`); -} - -async function getXERC20Limit( - token: Token, - xerc20: IHypXERC20Adapter, -): Promise { - const [mintCurrent, mintMax, burnCurrent, burnMax] = await Promise.all([ - xerc20.getMintLimit(), - xerc20.getMintMaxLimit(), - xerc20.getBurnLimit(), - xerc20.getBurnMaxLimit(), - ]); - - return { - mint: formatBigInt(token, mintCurrent), - mintMax: formatBigInt(token, mintMax), - burn: formatBigInt(token, burnCurrent), - burnMax: formatBigInt(token, burnMax), - }; -} - -const managedLockBoxMinimalABI = [ - 'function XERC20() view returns (address)', - 'function ERC20() view returns (address)', -] as const; - -async function getExtraLockboxInfo( - warpToken: Token, - multiProtocolProvider: MultiProtocolProvider, - lockboxAddress: Address, -): Promise { - const currentChainProvider = multiProtocolProvider.getEthersV5Provider( - warpToken.chainName, - ); - const lockboxInstance = await getManagedLockBox( - warpToken, - multiProtocolProvider, - lockboxAddress, - ); - - const xERC20Address = await lockboxInstance.XERC20(); - const vsXERC20Address = IXERC20VS__factory.connect( - xERC20Address, - currentChainProvider, - ); // todo use adapter - - const [mintMax, burnMax, mint, burn] = await Promise.all([ - vsXERC20Address.mintingMaxLimitOf(lockboxAddress), - vsXERC20Address.burningMaxLimitOf(lockboxAddress), - vsXERC20Address.mintingCurrentLimitOf(lockboxAddress), - vsXERC20Address.burningCurrentLimitOf(lockboxAddress), - ]); - - return { - limits: { - burn: formatBigInt(warpToken, burn.toBigInt()), - burnMax: formatBigInt(warpToken, burnMax.toBigInt()), - mint: formatBigInt(warpToken, mint.toBigInt()), - mintMax: formatBigInt(warpToken, mintMax.toBigInt()), - }, - xERC20Address, - }; -} - -async function getManagedLockBox( - warpToken: Token, - multiProtocolProvider: MultiProtocolProvider, - lockboxAddress: Address, -): Promise { - const chainName = warpToken.chainName; - const provider = multiProtocolProvider.getEthersV5Provider(chainName); - return new Contract(lockboxAddress, managedLockBoxMinimalABI, provider); -} - -async function getExtraLockboxBalance( - warpToken: Token, - multiProtocolProvider: MultiProtocolProvider, - tokenPriceGetter: CoinGeckoTokenPriceGetter, - lockboxAddress: Address, -): Promise { - if (!warpToken.isXerc20()) { - return; - } - - const lockboxInstance = await getManagedLockBox( - warpToken, - multiProtocolProvider, - lockboxAddress, - ); - - const erc20TokenAddress = await lockboxInstance.ERC20(); - const erc20tokenAdapter = new EvmTokenAdapter( - warpToken.chainName, - multiProtocolProvider, - { - token: erc20TokenAddress, - }, - ); - - let balance; - try { - balance = await erc20tokenAdapter.getBalance(lockboxAddress); - } catch (err) { - logger.error( - { - err, - chain: warpToken.chainName, - token: warpToken.symbol, - lockboxAddress, - erc20TokenAddress, - }, - 'Failed to get balance for contract at lockbox address', - ); - return; - } - - const tokenPrice = await tryGetTokenPrice(warpToken, tokenPriceGetter); - - const balanceNumber = formatBigInt(warpToken, balance); - - return { - balance: balanceNumber, - valueUSD: tokenPrice ? balanceNumber * tokenPrice : undefined, - tokenAddress: erc20TokenAddress, - }; -} - -// Tries to get the price of a token from CoinGecko. Returns undefined if there's no -// CoinGecko ID for the token. -async function tryGetTokenPrice( - token: Token, - tokenPriceGetter: CoinGeckoTokenPriceGetter, -): Promise { - // We only get a price if the token defines a CoinGecko ID. - // This way we can ignore values of certain types of collateralized warp routes, - // e.g. Native warp routes on rollups that have been pre-funded. - const coinGeckoId = token.coinGeckoId; - - if (!coinGeckoId) { - logger.warn( - { token: token.symbol, chain: token.chainName }, - 'Missing CoinGecko ID for token', - ); - return undefined; - } - - return getCoingeckoPrice(tokenPriceGetter, coinGeckoId); -} - -async function getCoingeckoPrice( - tokenPriceGetter: CoinGeckoTokenPriceGetter, - coingeckoId: string, -): Promise { - const prices = await tokenPriceGetter.getTokenPriceByIds([coingeckoId]); - if (!prices) return undefined; - return prices[0]; -} - -function getWarpRouteCollateralTokenSymbol(warpCore: WarpCore): string { - // We need to have a deterministic way to determine the symbol of the warp route - // as its used to identify the warp route in metrics. This method should support routes where: - // - All tokens have the same symbol, token standards can be all collateral, all synthetic or a mix - // - All tokens have different symbol, but there is a collateral token to break the tie, where there are multiple collateral tokens, alphabetically first is chosen - // - All tokens have different symbol, but there is no collateral token to break the tie, pick the alphabetically first symbol - - // Get all unique symbols from the tokens array - const uniqueSymbols = new Set(warpCore.tokens.map((token) => token.symbol)); - - // If all tokens have the same symbol, return that symbol - if (uniqueSymbols.size === 1) { - return warpCore.tokens[0].symbol; - } - - // Find all collateralized tokens - const collateralTokens = warpCore.tokens.filter( - (token) => - token.isCollateralized() || - token.standard === TokenStandard.EvmHypXERC20Lockbox || - token.standard === TokenStandard.EvmHypVSXERC20Lockbox, - ); - - if (collateralTokens.length === 0) { - // If there are no collateralized tokens, return the alphabetically first symbol - return [...uniqueSymbols].sort()[0]; - } - - // if there is a single unique collateral symbol return it or - // ifthere are multiple, return the alphabetically first symbol - const collateralSymbols = collateralTokens.map((token) => token.symbol); - const uniqueCollateralSymbols = [...new Set(collateralSymbols)]; - - return uniqueCollateralSymbols.sort()[0]; -} - -main().catch((err) => { - logger.error(err); - process.exit(1); -}); diff --git a/typescript/infra/scripts/warp-routes/monitor/status.ts b/typescript/infra/scripts/warp-routes/monitor/status.ts deleted file mode 100644 index 279117daf18..00000000000 --- a/typescript/infra/scripts/warp-routes/monitor/status.ts +++ /dev/null @@ -1,130 +0,0 @@ -import chalk from 'chalk'; - -import { - LogFormat, - LogLevel, - configureRootLogger, - rootLogger, -} from '@hyperlane-xyz/utils'; - -import { HelmManager, getHelmReleaseName } from '../../../src/utils/helm.js'; -import { WarpRouteMonitorHelmManager } from '../../../src/warp/helm.js'; -import { - assertCorrectKubeContext, - getArgs, - withWarpRouteIdRequired, -} from '../../agent-utils.js'; -import { getEnvironmentConfig } from '../../core-utils.js'; - -const orange = chalk.hex('#FFA500'); -const GRAFANA_LINK = - 'https://abacusworks.grafana.net/d/ddz6ma94rnzswc/warp-routes?orgId=1&var-warp_route_id='; -const LOG_AMOUNT = 5; - -async function main() { - configureRootLogger(LogFormat.Pretty, LogLevel.Info); - const { environment, warpRouteId } = - await withWarpRouteIdRequired(getArgs()).parse(); - - const config = getEnvironmentConfig(environment); - await assertCorrectKubeContext(config); - - try { - const podWarpRouteId = `${getHelmReleaseName(warpRouteId, WarpRouteMonitorHelmManager.helmReleasePrefix)}-0`; - - rootLogger.info(chalk.grey.italic(`Fetching pod status...`)); - const pod = HelmManager.runK8sCommand( - 'get pod', - podWarpRouteId, - environment, - ); - rootLogger.info(chalk.green(pod)); - - rootLogger.info(chalk.gray.italic(`Fetching latest logs...`)); - const latestLogs = HelmManager.runK8sCommand( - 'logs', - podWarpRouteId, - environment, - [`--tail=${LOG_AMOUNT}`], - ); - formatAndPrintLogs(latestLogs); - - rootLogger.info( - orange.bold(`Grafana Dashboard Link: ${GRAFANA_LINK}${warpRouteId}`), - ); - } catch (error) { - rootLogger.error(error); - process.exit(1); - } -} - -function formatAndPrintLogs(rawLogs: string) { - try { - const logs = rawLogs - .trim() - .split('\n') - .map((line) => JSON.parse(line)); - logs.forEach((log) => { - const { time, msg, labels, balance, valueUSD } = log; - - // Handle both standard timestamps and GCP timestamp format - let timestamp: string; - if (typeof time === 'string') { - // Standard timestamp format - timestamp = new Date(time).toISOString(); - } else if ( - time && - typeof time === 'object' && - 'seconds' in time && - 'nanos' in time - ) { - // GCP timestamp format: { seconds: number, nanos: number } - const seconds = (time as { seconds: number; nanos: number }).seconds; - const nanos = (time as { seconds: number; nanos: number }).nanos; - const milliseconds = seconds * 1000 + nanos / 1000000; - timestamp = new Date(milliseconds).toISOString(); - } else { - // Fallback to current time if timestamp is invalid - timestamp = new Date().toISOString(); - } - - // Try our default fields first, then fall back to GCP fields - const module = - labels?.module ?? log?.serviceContext?.service ?? 'Unknown Module'; - const chain = labels?.chain_name || 'Unknown Chain'; - const token = labels?.token_name || 'Unknown Token'; - const warpRoute = labels?.warp_route_id || 'Unknown Warp Route'; - const tokenStandard = - labels?.token_standard ?? - labels?.collateral_token_standard ?? - 'Unknown Standard'; - const tokenAddress = labels?.token_address || 'Unknown Token Address'; - const walletAddress = labels?.wallet_address || 'Unknown Wallet'; - - let logMessage = - chalk.gray(`[${timestamp}] `) + chalk.white(`[${module}] `); - logMessage += chalk.blue(`${warpRoute} `); - logMessage += chalk.green(`${chain} `); - logMessage += chalk.blue.italic(`Token: ${token} (${tokenAddress}) `); - logMessage += chalk.green.italic(`${tokenStandard} `); - logMessage += chalk.blue.italic(`Wallet: ${walletAddress} `); - - if (balance) { - logMessage += chalk.yellow.italic(`Balance: ${balance} `); - } - if (valueUSD) { - logMessage += chalk.green.italic(`Value (USD): ${valueUSD} `); - } - logMessage += chalk.white(`→ ${msg ?? log.message}\n`); - - rootLogger.info(logMessage); - }); - } catch (err) { - rootLogger.error(err, 'Failed to parse logs'); - } -} - -main().catch((err) => { - rootLogger.error(err); - process.exit(1); -}); diff --git a/typescript/infra/scripts/warp-routes/monitor/utils.ts b/typescript/infra/scripts/warp-routes/monitor/utils.ts deleted file mode 100644 index 6f1740ae381..00000000000 --- a/typescript/infra/scripts/warp-routes/monitor/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createServiceLogger, setRootLogger } from '@hyperlane-xyz/utils'; - -const logger = await createServiceLogger({ - service: 'warp-balance-monitor', - version: '1.0.0', -}); - -setRootLogger(logger); - -export function setLoggerBindings(bindings: Record) { - logger.setBindings(bindings); -} - -export { logger }; - -export async function tryFn(fn: () => Promise, context: string) { - try { - await fn(); - } catch (err) { - logger.error(err, `Error in ${context}`); - } -} diff --git a/typescript/infra/src/warp/helm.ts b/typescript/infra/src/warp/helm.ts index 987e2a862a5..334db8f50e9 100644 --- a/typescript/infra/src/warp/helm.ts +++ b/typescript/infra/src/warp/helm.ts @@ -1,6 +1,7 @@ import { confirm } from '@inquirer/prompts'; import path from 'path'; +import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry'; import { ChainMap, IToken, @@ -55,6 +56,11 @@ export class WarpRouteMonitorHelmManager extends HelmManager { super(); } + private get registryUri(): string { + // Build registry URI with commit embedded in /tree/{commit} format + return `${DEFAULT_GITHUB_REGISTRY}/tree/${this.registryCommit}`; + } + async runPreflightChecks(multiProtocolProvider: MultiProtocolProvider) { const rebalancerReleaseName = getHelmReleaseName( this.warpRouteId, @@ -95,15 +101,14 @@ export class WarpRouteMonitorHelmManager extends HelmManager { async helmValues() { return { image: { - repository: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', - tag: '9611a3e-20251218-204337', + repository: 'gcr.io/abacus-labs-dev/hyperlane-warp-monitor', + tag: 'eda7b03-20251230-135200', }, warpRouteId: this.warpRouteId, fullnameOverride: this.helmReleaseName, - environment: this.runEnv, hyperlane: { chains: this.environmentChainNames, - registryCommit: this.registryCommit, + registryUri: this.registryUri, }, }; } diff --git a/typescript/warp-monitor/.gitignore b/typescript/warp-monitor/.gitignore new file mode 100644 index 00000000000..574bce1b9ab --- /dev/null +++ b/typescript/warp-monitor/.gitignore @@ -0,0 +1,4 @@ +.env +dist +cache +bundle diff --git a/typescript/warp-monitor/.mocharc.json b/typescript/warp-monitor/.mocharc.json new file mode 100644 index 00000000000..2a414eb587f --- /dev/null +++ b/typescript/warp-monitor/.mocharc.json @@ -0,0 +1,3 @@ +{ + "import": ["tsx"] +} diff --git a/typescript/warp-monitor/Dockerfile b/typescript/warp-monitor/Dockerfile new file mode 100644 index 00000000000..c1cbb17a1e3 --- /dev/null +++ b/typescript/warp-monitor/Dockerfile @@ -0,0 +1,88 @@ +FROM node:20-slim AS builder + +WORKDIR /hyperlane-monorepo + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git g++ make python3 python3-pip jq bash curl ca-certificates unzip \ + && rm -rf /var/lib/apt/lists/* + +# Install Foundry (Linux binaries) - pinned version for reproducibility +ARG FOUNDRY_VERSION +ARG SERVICE_VERSION=dev +ARG TARGETARCH +SHELL ["/bin/bash", "-c"] +RUN set -o pipefail && \ + ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") && \ + curl --fail -L "https://github.com/foundry-rs/foundry/releases/download/${FOUNDRY_VERSION}/foundry_${FOUNDRY_VERSION}_linux_${ARCH}.tar.gz" | tar -xzC /usr/local/bin forge cast +SHELL ["/bin/sh", "-c"] + +# Copy package.json first for corepack to read packageManager field +COPY package.json ./ +RUN corepack enable && corepack install + +# Copy pnpm config files +COPY pnpm-lock.yaml pnpm-workspace.yaml ./ + +# Copy patches directory (required for pnpm install) +COPY patches ./patches + +# Copy only the packages needed for warp-monitor +COPY typescript/warp-monitor/package.json ./typescript/warp-monitor/ +COPY typescript/deploy-sdk/package.json ./typescript/deploy-sdk/ +COPY typescript/sdk/package.json ./typescript/sdk/ +COPY typescript/provider-sdk/package.json ./typescript/provider-sdk/ +COPY typescript/utils/package.json ./typescript/utils/ +COPY typescript/cosmos-sdk/package.json ./typescript/cosmos-sdk/ +COPY typescript/cosmos-types/package.json ./typescript/cosmos-types/ +COPY typescript/radix-sdk/package.json ./typescript/radix-sdk/ +COPY typescript/tsconfig/package.json ./typescript/tsconfig/ +COPY typescript/eslint-config/package.json ./typescript/eslint-config/ +COPY solidity/package.json ./solidity/ +COPY solhint-plugin/package.json ./solhint-plugin/ +COPY starknet/package.json ./starknet/ + +RUN pnpm install --frozen-lockfile + +# Copy source files +COPY turbo.json ./ +COPY typescript/warp-monitor ./typescript/warp-monitor +COPY typescript/deploy-sdk ./typescript/deploy-sdk +COPY typescript/sdk ./typescript/sdk +COPY typescript/provider-sdk ./typescript/provider-sdk +COPY typescript/utils ./typescript/utils +COPY typescript/cosmos-sdk ./typescript/cosmos-sdk +COPY typescript/cosmos-types ./typescript/cosmos-types +COPY typescript/radix-sdk ./typescript/radix-sdk +COPY typescript/tsconfig ./typescript/tsconfig +COPY typescript/eslint-config ./typescript/eslint-config +COPY solidity ./solidity +COPY solhint-plugin ./solhint-plugin +COPY starknet ./starknet + +# Build and bundle the warp-monitor (ncc creates a single-file bundle with all deps) +RUN pnpm turbo run bundle --filter=@hyperlane-xyz/warp-monitor + +# Production stage - minimal Alpine image with just the bundled code +FROM node:20-alpine AS runner + +WORKDIR /app + +RUN apk add --no-cache ca-certificates + +# Copy only the bundled output (includes all dependencies) +COPY --from=builder /hyperlane-monorepo/typescript/warp-monitor/bundle ./bundle + +# Install external dependencies that ncc doesn't bundle (dynamic imports) +RUN npm install @google-cloud/pino-logging-gcp-config + +# Environment variables +ARG SERVICE_VERSION=dev +ENV NODE_ENV=production +ENV LOG_LEVEL=info +ENV SERVICE_VERSION=${SERVICE_VERSION} + +# Expose metrics port +EXPOSE 9090 + +# Run the warp-monitor service from the bundle +CMD ["node", "bundle/index.js"] diff --git a/typescript/warp-monitor/eslint.config.mjs b/typescript/warp-monitor/eslint.config.mjs new file mode 100644 index 00000000000..f677b56736f --- /dev/null +++ b/typescript/warp-monitor/eslint.config.mjs @@ -0,0 +1,3 @@ +import { jsRules, typescriptRules } from '@hyperlane-xyz/eslint-config'; + +export default [...jsRules, ...typescriptRules]; diff --git a/typescript/warp-monitor/package.json b/typescript/warp-monitor/package.json new file mode 100644 index 00000000000..52c86dcf4c7 --- /dev/null +++ b/typescript/warp-monitor/package.json @@ -0,0 +1,59 @@ +{ + "name": "@hyperlane-xyz/warp-monitor", + "version": "0.0.0", + "private": true, + "description": "Hyperlane Warp Route Balance Monitor Service", + "type": "module", + "scripts": { + "build": "tsc", + "bundle": "rm -rf ./bundle && ncc build ./dist/service.js -o bundle -e @google-cloud/pino-logging-gcp-config && node ./scripts/ncc.post-bundle.mjs", + "clean": "rm -rf dist cache bundle", + "dev": "tsc --watch", + "lint": "eslint -c ./eslint.config.mjs ./src", + "prettier": "prettier --write ./src", + "test": "mocha --config .mocharc.json './src/**/*.test.ts' --exit", + "test:ci": "pnpm test", + "start": "node dist/service.js", + "start:dev": "tsx src/service.ts" + }, + "dependencies": { + "@google-cloud/pino-logging-gcp-config": "catalog:", + "@hyperlane-xyz/core": "workspace:*", + "@hyperlane-xyz/registry": "catalog:", + "@hyperlane-xyz/sdk": "workspace:*", + "@hyperlane-xyz/utils": "workspace:*", + "ethers": "catalog:", + "pino": "catalog:", + "prom-client": "catalog:" + }, + "devDependencies": { + "@hyperlane-xyz/eslint-config": "workspace:^", + "@hyperlane-xyz/tsconfig": "workspace:^", + "@types/chai": "catalog:", + "@types/mocha": "catalog:", + "@types/node": "catalog:", + "@types/sinon": "catalog:", + "@vercel/ncc": "catalog:", + "chai": "catalog:", + "eslint": "catalog:", + "mocha": "catalog:", + "prettier": "catalog:", + "sinon": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=18" + }, + "repository": "https://github.com/hyperlane-xyz/hyperlane-monorepo", + "keywords": [ + "hyperlane", + "warp-monitor", + "warp-route", + "balance", + "blockchain", + "interchain" + ], + "author": "Abacus Works, Inc.", + "license": "Apache-2.0" +} diff --git a/typescript/warp-monitor/scripts/ncc.post-bundle.mjs b/typescript/warp-monitor/scripts/ncc.post-bundle.mjs new file mode 100644 index 00000000000..2eefba7fd6e --- /dev/null +++ b/typescript/warp-monitor/scripts/ncc.post-bundle.mjs @@ -0,0 +1,38 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { readFile, writeFile } from 'fs/promises'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const outputFile = path.join(__dirname, '..', 'bundle', 'index.js'); + +const shebang = '#!/usr/bin/env node'; +const dirnameDef = `import { fileURLToPath } from 'url'; +import path from 'path'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename);`; + +async function prepend() { + try { + const content = await readFile(outputFile, 'utf8'); + + // Assume the 'service.ts' file entry point already has the shebang + if (!content.startsWith(shebang)) { + throw new Error('Missing shebang from service entry point'); + } + + if (!content.includes(dirnameDef)) { + const [, executable] = content.split(shebang); + const newContent = `${shebang}\n${dirnameDef}\n${executable}`; + await writeFile(outputFile, newContent, 'utf8'); + console.log('Adding missing __dirname definition to service executable'); + } + } catch (err) { + console.error('Error processing output file:', err); + process.exit(1); + } +} + +await prepend(); diff --git a/typescript/warp-monitor/src/metrics.test.ts b/typescript/warp-monitor/src/metrics.test.ts new file mode 100644 index 00000000000..789bdda2e8f --- /dev/null +++ b/typescript/warp-monitor/src/metrics.test.ts @@ -0,0 +1,304 @@ +import { expect } from 'chai'; +import { Registry } from 'prom-client'; + +import { TokenStandard } from '@hyperlane-xyz/sdk'; + +import { + metricsRegister, + updateNativeWalletBalanceMetrics, + updateTokenBalanceMetrics, + updateXERC20LimitsMetrics, +} from './metrics.js'; +import type { + NativeWalletBalance, + WarpRouteBalance, + XERC20Limit, +} from './types.js'; + +describe('Warp Monitor Metrics', () => { + // Note: We don't clear the registry between tests because the gauges are + // registered at module load time. Clearing would remove the gauge registrations. + + describe('metricsRegister', () => { + it('should be a valid Prometheus registry', () => { + expect(metricsRegister).to.be.instanceOf(Registry); + }); + + it('should be able to generate metrics output', async () => { + const metrics = await metricsRegister.metrics(); + expect(metrics).to.be.a('string'); + }); + }); + + describe('updateTokenBalanceMetrics', () => { + const createMockWarpCore = (chains: string[]) => ({ + getTokenChains: () => chains, + }); + + const createMockToken = ( + chainName: string, + name: string, + standard: TokenStandard, + ) => ({ + chainName, + name, + addressOrDenom: '0x1234567890123456789012345678901234567890', + standard, + }); + + it('should record token balance with correct value', async () => { + const mockWarpCore = createMockWarpCore(['ethereum', 'polygon']); + const mockToken = createMockToken( + 'ethereum', + 'Test Token', + TokenStandard.EvmHypCollateral, + ); + + const balanceInfo: WarpRouteBalance = { + balance: 1000.5, + tokenAddress: '0x1234567890123456789012345678901234567890', + }; + + updateTokenBalanceMetrics( + mockWarpCore as any, + mockToken as any, + balanceInfo, + 'ETH/ethereum-polygon', + ); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_warp_route_token_balance'); + expect(metrics).to.include('chain_name="ethereum"'); + expect(metrics).to.include('token_name="Test Token"'); + expect(metrics).to.include('warp_route_id="ETH/ethereum-polygon"'); + expect(metrics).to.include('1000.5'); + }); + + it('should record collateral value when valueUSD is provided', async () => { + const mockWarpCore = createMockWarpCore(['ethereum', 'polygon']); + const mockToken = createMockToken( + 'ethereum', + 'Collateral Token', + TokenStandard.EvmHypCollateral, + ); + + const balanceInfo: WarpRouteBalance = { + balance: 1000, + valueUSD: 5000.25, + tokenAddress: '0x1234567890123456789012345678901234567890', + }; + + updateTokenBalanceMetrics( + mockWarpCore as any, + mockToken as any, + balanceInfo, + 'ETH/collateral-test', + ); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_warp_route_collateral_value'); + expect(metrics).to.include('5000.25'); + }); + + it('should record value at risk for all chains in warp route', async () => { + const mockWarpCore = createMockWarpCore([ + 'ethereum', + 'polygon', + 'arbitrum', + ]); + const mockToken = createMockToken( + 'ethereum', + 'MultiChain Token', + TokenStandard.EvmHypCollateral, + ); + + const balanceInfo: WarpRouteBalance = { + balance: 1000, + valueUSD: 5000, + tokenAddress: '0x1234567890123456789012345678901234567890', + }; + + updateTokenBalanceMetrics( + mockWarpCore as any, + mockToken as any, + balanceInfo, + 'ETH/multichain-test', + ); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_warp_route_value_at_risk'); + }); + + it('should set related_chain_names excluding current chain', async () => { + const mockWarpCore = createMockWarpCore([ + 'ethereum', + 'polygon', + 'arbitrum', + ]); + const mockToken = createMockToken( + 'ethereum', + 'Related Chains Token', + TokenStandard.EvmHypCollateral, + ); + + const balanceInfo: WarpRouteBalance = { + balance: 1000, + tokenAddress: '0x1234567890123456789012345678901234567890', + }; + + updateTokenBalanceMetrics( + mockWarpCore as any, + mockToken as any, + balanceInfo, + 'ETH/related-test', + ); + + const metrics = await metricsRegister.metrics(); + // Related chains should exclude 'ethereum' (the current chain) and be sorted + expect(metrics).to.include('related_chain_names="arbitrum,polygon"'); + }); + + it('should handle xERC20 tokens with correct standard label', async () => { + const mockWarpCore = createMockWarpCore(['ethereum', 'polygon']); + const mockToken = createMockToken( + 'ethereum', + 'xERC20 Token', + TokenStandard.EvmHypXERC20, + ); + + const balanceInfo: WarpRouteBalance = { + balance: 1000, + tokenAddress: '0xabcdef1234567890123456789012345678901234', + }; + + updateTokenBalanceMetrics( + mockWarpCore as any, + mockToken as any, + balanceInfo, + 'xERC20/test', + ); + + const metrics = await metricsRegister.metrics(); + // xERC20 tokens should be labeled as 'xERC20' not 'EvmHypXERC20' + expect(metrics).to.include('token_standard="xERC20"'); + }); + }); + + describe('updateNativeWalletBalanceMetrics', () => { + it('should record native wallet balance with correct labels', async () => { + const balance: NativeWalletBalance = { + chain: 'solanamainnet', + walletAddress: 'SoLaNaAdDrEsS123456789012345678901234567890', + walletName: 'ETH/ethereum-solanamainnet/ata-payer', + balance: 0.5, + }; + + updateNativeWalletBalanceMetrics(balance); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_wallet_balance'); + expect(metrics).to.include('chain="solanamainnet"'); + expect(metrics).to.include( + 'wallet_address="SoLaNaAdDrEsS123456789012345678901234567890"', + ); + expect(metrics).to.include( + 'wallet_name="ETH/ethereum-solanamainnet/ata-payer"', + ); + expect(metrics).to.include('token_symbol="Native"'); + }); + + it('should handle small balance values', async () => { + const smallBalance: NativeWalletBalance = { + chain: 'ethereum', + walletAddress: '0xSmallBalance12345678901234567890123456', + walletName: 'small-wallet', + balance: 0.0001, + }; + + updateNativeWalletBalanceMetrics(smallBalance); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include( + 'wallet_address="0xSmallBalance12345678901234567890123456"', + ); + }); + }); + + describe('updateXERC20LimitsMetrics', () => { + const mockToken = { + chainName: 'ethereum', + name: 'Test xERC20 Limits', + }; + + it('should record all four limit types', async () => { + const limits: XERC20Limit = { + mint: 1000, + burn: 500, + mintMax: 10000, + burnMax: 5000, + }; + + updateXERC20LimitsMetrics( + mockToken as any, + limits, + '0xLimitsTest123456789012345678901234567890', + 'EvmHypXERC20', + '0xabcdef1234567890123456789012345678901234', + ); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_xerc20_limits'); + expect(metrics).to.include('limit_type="mint"'); + expect(metrics).to.include('limit_type="burn"'); + expect(metrics).to.include('limit_type="mintMax"'); + expect(metrics).to.include('limit_type="burnMax"'); + }); + + it('should record bridge address and label', async () => { + const limits: XERC20Limit = { + mint: 2000, + burn: 1500, + mintMax: 20000, + burnMax: 15000, + }; + + updateXERC20LimitsMetrics( + mockToken as any, + limits, + '0xBridgeAddress1234567890123456789012345678', + 'EvmManagedLockbox', + '0xabcdef1234567890123456789012345678901234', + ); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include( + 'bridge_address="0xBridgeAddress1234567890123456789012345678"', + ); + expect(metrics).to.include('bridge_label="EvmManagedLockbox"'); + }); + + it('should handle zero limits gracefully', async () => { + const limits: XERC20Limit = { + mint: 0, + burn: 0, + mintMax: 10000, + burnMax: 5000, + }; + + // Should not throw + updateXERC20LimitsMetrics( + mockToken as any, + limits, + '0xZeroLimits123456789012345678901234567890', + 'EvmHypXERC20', + '0xabcdef1234567890123456789012345678901234', + ); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_xerc20_limits'); + expect(metrics).to.include( + 'bridge_address="0xZeroLimits123456789012345678901234567890"', + ); + }); + }); +}); diff --git a/typescript/infra/scripts/warp-routes/monitor/metrics.ts b/typescript/warp-monitor/src/metrics.ts similarity index 72% rename from typescript/infra/scripts/warp-routes/monitor/metrics.ts rename to typescript/warp-monitor/src/metrics.ts index 38645957de2..86cee033ee9 100644 --- a/typescript/infra/scripts/warp-routes/monitor/metrics.ts +++ b/typescript/warp-monitor/src/metrics.ts @@ -1,16 +1,24 @@ +import http from 'http'; import { Gauge, Registry } from 'prom-client'; -import { ChainName, Token, TokenStandard, WarpCore } from '@hyperlane-xyz/sdk'; -import { Address } from '@hyperlane-xyz/utils'; +import { + type ChainName, + type Token, + TokenStandard, + type WarpCore, +} from '@hyperlane-xyz/sdk'; +import type { Address } from '@hyperlane-xyz/utils'; -import { getWalletBalanceGauge } from '../../../src/utils/metrics.js'; - -import { NativeWalletBalance, WarpRouteBalance, XERC20Limit } from './types.js'; -import { logger } from './utils.js'; +import type { + NativeWalletBalance, + WarpRouteBalance, + XERC20Limit, +} from './types.js'; +import { getLogger } from './utils.js'; export const metricsRegister = new Registry(); -type supportedTokenStandards = TokenStandard | 'EvmManagedLockbox' | 'xERC20'; +type SupportedTokenStandards = TokenStandard | 'EvmManagedLockbox' | 'xERC20'; interface BaseWarpRouteMetrics { chain_name: ChainName; @@ -21,7 +29,7 @@ interface BaseWarpRouteMetrics { interface WarpRouteMetrics extends BaseWarpRouteMetrics { wallet_address: string; - token_standard: supportedTokenStandards; + token_standard: SupportedTokenStandards; related_chain_names: string; } @@ -53,7 +61,7 @@ const warpRouteCollateralValue = new Gauge({ interface WarpRouteValueAtRiskMetrics extends BaseWarpRouteMetrics { collateral_chain_name: ChainName; - collateral_token_standard: supportedTokenStandards; + collateral_token_standard: SupportedTokenStandards; } type WarpRouteValueAtRiskMetricLabels = keyof WarpRouteValueAtRiskMetrics; @@ -74,7 +82,28 @@ const warpRouteValueAtRisk = new Gauge({ labelNames: warpRouteValueAtRiskLabels, }); -const walletBalanceGauge = getWalletBalanceGauge(metricsRegister); +function createWalletBalanceGauge( + register: Registry, + additionalLabels: string[] = [], +): Gauge { + return new Gauge({ + // Mirror the rust/main/ethers-prometheus `wallet_balance` gauge metric. + name: 'hyperlane_wallet_balance', + help: 'Current balance of a wallet for a token', + registers: [register], + labelNames: [ + 'chain', + 'wallet_address', + 'wallet_name', + 'token_address', + 'token_symbol', + 'token_name', + ...additionalLabels, + ], + }); +} + +const walletBalanceGauge = createWalletBalanceGauge(metricsRegister); const xERC20LimitsGauge = new Gauge({ name: 'hyperlane_xerc20_limits', @@ -95,7 +124,8 @@ export function updateTokenBalanceMetrics( token: Token, balanceInfo: WarpRouteBalance, warpRouteId: string, -) { +): void { + const logger = getLogger(); const allChains = warpCore.getTokenChains().sort(); const relatedChains = allChains.filter( (chainName) => chainName !== token.chainName, @@ -161,8 +191,7 @@ export function updateTokenBalanceMetrics( } } } -// TODO: This does not need to be a separate function, we can redefine updateTokenBalanceMetrics to be generic -// TODO: Consider adding some identifier for the managedLockbox contract, could be adding collateralName label for lockboxes, this would help different manages lockboxes that has a different collateral token + export function updateManagedLockboxBalanceMetrics( warpCore: WarpCore, chainName: ChainName, @@ -171,7 +200,8 @@ export function updateManagedLockboxBalanceMetrics( lockBoxAddress: string, balanceInfo: WarpRouteBalance, warpRouteId: string, -) { +): void { + const logger = getLogger(); const metrics: WarpRouteMetrics = { chain_name: chainName, token_address: tokenAddress, @@ -207,12 +237,16 @@ export function updateManagedLockboxBalanceMetrics( } } -export function updateNativeWalletBalanceMetrics(balance: NativeWalletBalance) { +export function updateNativeWalletBalanceMetrics( + balance: NativeWalletBalance, +): void { + const logger = getLogger(); walletBalanceGauge .labels({ chain: balance.chain, wallet_address: balance.walletAddress, wallet_name: balance.walletName, + token_address: 'native', token_symbol: 'Native', token_name: 'Native', }) @@ -228,7 +262,8 @@ export function updateXERC20LimitsMetrics( bridgeAddress: Address, bridgeLabel: string, xERC20Address: Address, -) { +): void { + const logger = getLogger(); const labels = { chain_name: token.chainName, token_name: token.name, @@ -254,3 +289,37 @@ export function updateXERC20LimitsMetrics( 'xERC20 limits updated for bridge on token', ); } + +/** + * Start a simple HTTP server to host metrics. This just takes the registry and dumps the text + * string to people who request `GET /metrics`. + * + * PROMETHEUS_PORT env var is used to determine what port to host on, defaults to 9090. + */ +export function startMetricsServer(register: Registry): http.Server { + const logger = getLogger(); + return http + .createServer((req, res) => { + if (req.url !== '/metrics') { + res.writeHead(404, 'Invalid url').end(); + return; + } + if (req.method !== 'GET') { + res.writeHead(405, 'Invalid method').end(); + return; + } + + register + .metrics() + .then((metricsStr) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }).end(metricsStr); + }) + .catch((err) => { + logger.error(err, 'Failed to collect metrics'); + res + .writeHead(500, { 'Content-Type': 'text/plain' }) + .end('Internal Server Error'); + }); + }) + .listen(parseInt(process.env['PROMETHEUS_PORT'] || '9090')); +} diff --git a/typescript/warp-monitor/src/monitor.ts b/typescript/warp-monitor/src/monitor.ts new file mode 100644 index 00000000000..34ca79bfe12 --- /dev/null +++ b/typescript/warp-monitor/src/monitor.ts @@ -0,0 +1,610 @@ +import { Contract, type PopulatedTransaction } from 'ethers'; + +import { IXERC20VS__factory } from '@hyperlane-xyz/core'; +import type { IRegistry } from '@hyperlane-xyz/registry'; +import { + type ChainMap, + type ChainMetadata, + CoinGeckoTokenPriceGetter, + EvmHypXERC20Adapter, + EvmHypXERC20LockboxAdapter, + EvmTokenAdapter, + type IHypXERC20Adapter, + MultiProtocolProvider, + SealevelHypTokenAdapter, + Token, + TokenStandard, + TokenType, + WarpCore, + type WarpRouteDeployConfig, +} from '@hyperlane-xyz/sdk'; +import { + type Address, + ProtocolType, + objMap, + objMerge, + sleep, +} from '@hyperlane-xyz/utils'; + +import { + metricsRegister, + startMetricsServer, + updateManagedLockboxBalanceMetrics, + updateNativeWalletBalanceMetrics, + updateTokenBalanceMetrics, + updateXERC20LimitsMetrics, +} from './metrics.js'; +import type { + NativeWalletBalance, + WarpMonitorConfig, + WarpRouteBalance, + XERC20Limit, +} from './types.js'; +import { getLogger, setLoggerBindings, tryFn } from './utils.js'; + +interface XERC20Info { + limits: XERC20Limit; + xERC20Address: Address; +} + +export class WarpMonitor { + private readonly config: WarpMonitorConfig; + private readonly registry: IRegistry; + + constructor(config: WarpMonitorConfig, registry: IRegistry) { + this.config = config; + this.registry = registry; + } + + async start(): Promise { + const logger = getLogger(); + const { warpRouteId, checkFrequency, coingeckoApiKey } = this.config; + + setLoggerBindings({ + warp_route: warpRouteId, + }); + + startMetricsServer(metricsRegister); + logger.info( + { port: process.env['PROMETHEUS_PORT'] || '9090' }, + 'Metrics server started', + ); + + // Get chain metadata and addresses from registry + const chainMetadata = await this.registry.getMetadata(); + const chainAddresses = await this.registry.getAddresses(); + + // The Sealevel warp adapters require the Mailbox address, so we + // get mailboxes for all chains and merge them with the chain metadata. + const mailboxes = objMap(chainAddresses, (_, { mailbox }) => ({ + mailbox, + })); + const multiProtocolProvider = new MultiProtocolProvider( + objMerge(chainMetadata, mailboxes), + ); + + // Get warp route config from registry + const warpCoreConfig = await this.registry.getWarpRoute(warpRouteId); + if (!warpCoreConfig) { + throw new Error( + `Warp route config for ${warpRouteId} not found in registry`, + ); + } + + const warpCore = WarpCore.FromConfig(multiProtocolProvider, warpCoreConfig); + const warpDeployConfig = + await this.registry.getWarpDeployConfig(warpRouteId); + + logger.info( + { + warpRouteId, + checkFrequency, + tokenCount: warpCore.tokens.length, + chains: warpCore.getTokenChains(), + }, + 'Starting warp route monitor', + ); + + await this.pollAndUpdateWarpRouteMetrics( + checkFrequency, + warpCore, + warpDeployConfig, + chainMetadata, + warpRouteId, + coingeckoApiKey, + ); + } + + // Indefinitely loops, updating warp route metrics at the specified frequency. + private async pollAndUpdateWarpRouteMetrics( + checkFrequency: number, + warpCore: WarpCore, + warpDeployConfig: WarpRouteDeployConfig | null, + chainMetadata: ChainMap, + warpRouteId: string, + coingeckoApiKey?: string, + ): Promise { + const logger = getLogger(); + const tokenPriceGetter = new CoinGeckoTokenPriceGetter({ + chainMetadata, + apiKey: coingeckoApiKey, + }); + + if (!coingeckoApiKey) { + logger.warn( + 'No CoinGecko API key provided, using public tier (rate limited)', + ); + } + + while (true) { + await tryFn(async () => { + await Promise.all( + warpCore.tokens.map((token) => + this.updateTokenMetrics( + warpCore, + warpDeployConfig, + token, + tokenPriceGetter, + warpRouteId, + ), + ), + ); + }, 'Updating warp route metrics'); + await sleep(checkFrequency); + } + } + + // Updates the metrics for a single token in a warp route. + private async updateTokenMetrics( + warpCore: WarpCore, + warpDeployConfig: WarpRouteDeployConfig | null, + token: Token, + tokenPriceGetter: CoinGeckoTokenPriceGetter, + warpRouteId: string, + ): Promise { + const logger = getLogger(); + const promises = [ + tryFn(async () => { + const balanceInfo = await this.getTokenBridgedBalance( + warpCore, + token, + tokenPriceGetter, + ); + if (!balanceInfo) { + return; + } + updateTokenBalanceMetrics(warpCore, token, balanceInfo, warpRouteId); + }, 'Getting bridged balance and value'), + ]; + + // For Sealevel collateral and synthetic tokens, there is an + // "Associated Token Account" (ATA) rent payer that has a balance + // that's used to pay for rent for the accounts that store user balances. + // This is necessary if the recipient has never received any tokens before. + if (token.protocol === ProtocolType.Sealevel && !token.isNative()) { + promises.push( + tryFn(async () => { + const balance = await this.getSealevelAtaPayerBalance( + warpCore, + token, + warpRouteId, + ); + updateNativeWalletBalanceMetrics(balance); + }, 'Getting ATA payer balance'), + ); + } + + if (token.isXerc20()) { + promises.push( + tryFn(async () => { + const { limits, xERC20Address } = await this.getXERC20Info( + warpCore, + token, + ); + const routerAddress = token.addressOrDenom; + updateXERC20LimitsMetrics( + token, + limits, + routerAddress, + token.standard, + xERC20Address, + ); + }, 'Getting xERC20 limits'), + ); + + if (!warpDeployConfig) { + logger.warn( + { token: token.symbol, chain: token.chainName }, + 'Failed to read warp deploy config, skipping extra lockboxes', + ); + await Promise.all(promises); + return; + } + + // If the current token is an xERC20, we need to check if there are any extra lockboxes + const currentTokenDeployConfig = warpDeployConfig[token.chainName]; + if ( + currentTokenDeployConfig.type !== TokenType.XERC20 && + currentTokenDeployConfig.type !== TokenType.XERC20Lockbox + ) { + logger.error( + { + expected: 'XERC20|XERC20Lockbox', + actual: currentTokenDeployConfig.type, + token: token.symbol, + chain: token.chainName, + }, + 'Invalid deploy config type for xERC20 token', + ); + await Promise.all(promises); + return; + } + + const extraLockboxes = + currentTokenDeployConfig.xERC20?.extraBridges ?? []; + + for (const lockbox of extraLockboxes) { + promises.push( + tryFn(async () => { + const { limits, xERC20Address } = await this.getExtraLockboxInfo( + token, + warpCore.multiProvider, + lockbox.lockbox, + ); + + updateXERC20LimitsMetrics( + token, + limits, + lockbox.lockbox, + 'EvmManagedLockbox', + xERC20Address, + ); + }, 'Getting extra lockbox limits'), + tryFn(async () => { + const balance = await this.getExtraLockboxBalance( + token, + warpCore.multiProvider, + tokenPriceGetter, + lockbox.lockbox, + ); + + if (balance) { + const { tokenName, tokenAddress } = + await this.getManagedLockBoxCollateralInfo( + token, + warpCore.multiProvider, + lockbox.lockbox, + ); + + updateManagedLockboxBalanceMetrics( + warpCore, + token.chainName, + tokenName, + tokenAddress, + lockbox.lockbox, + balance, + warpRouteId, + ); + } + }, `Updating extra lockbox balance for contract at "${lockbox.lockbox}" on chain ${token.chainName}`), + ); + } + } + + await Promise.all(promises); + } + + // Gets the bridged balance and value of a token in a warp route. + private async getTokenBridgedBalance( + warpCore: WarpCore, + token: Token, + tokenPriceGetter: CoinGeckoTokenPriceGetter, + ): Promise { + const logger = getLogger(); + if (!token.isHypToken()) { + logger.warn( + { token: token.symbol, chain: token.chainName }, + 'No support for bridged balance on non-Hyperlane token', + ); + return undefined; + } + + const adapter = token.getHypAdapter(warpCore.multiProvider); + let tokenAddress = token.collateralAddressOrDenom ?? token.addressOrDenom; + const bridgedSupply = await adapter.getBridgedSupply(); + if (bridgedSupply === undefined) { + logger.warn( + { token: token.symbol, chain: token.chainName }, + 'Failed to get bridged supply', + ); + return undefined; + } + const balance = token.amount(bridgedSupply).getDecimalFormattedAmount(); + + let tokenPrice; + // Only record value for collateralized and xERC20 lockbox tokens. + if ( + token.isCollateralized() || + token.standard === TokenStandard.EvmHypXERC20Lockbox || + token.standard === TokenStandard.EvmHypVSXERC20Lockbox + ) { + tokenPrice = await this.tryGetTokenPrice(token, tokenPriceGetter); + } + + if ( + token.standard === TokenStandard.EvmHypXERC20Lockbox || + token.standard === TokenStandard.EvmHypVSXERC20Lockbox + ) { + tokenAddress = (await (adapter as EvmHypXERC20LockboxAdapter).getXERC20()) + .address; + } + + return { + balance, + valueUSD: tokenPrice ? balance * tokenPrice : undefined, + tokenAddress, + }; + } + + private async getManagedLockBoxCollateralInfo( + warpToken: Token, + multiProtocolProvider: MultiProtocolProvider, + lockBoxAddress: Address, + ): Promise<{ tokenName: string; tokenAddress: Address }> { + const lockBoxInstance = await this.getManagedLockBox( + warpToken, + multiProtocolProvider, + lockBoxAddress, + ); + + const collateralTokenAddress = await lockBoxInstance.ERC20(); + const collateralTokenAdapter = new EvmTokenAdapter( + warpToken.chainName, + multiProtocolProvider, + { + token: collateralTokenAddress, + }, + ); + + const { name } = await collateralTokenAdapter.getMetadata(); + + return { + tokenName: name, + tokenAddress: collateralTokenAddress, + }; + } + + private formatBigInt(warpToken: Token, num: bigint): number { + return warpToken.amount(num).getDecimalFormattedAmount(); + } + + // Gets the native balance of the ATA payer, which is used to pay for + // rent when delivering tokens to an account that previously didn't + // have a balance. + // Only intended for Collateral or Synthetic Sealevel tokens. + private async getSealevelAtaPayerBalance( + warpCore: WarpCore, + token: Token, + warpRouteId: string, + ): Promise { + if (token.protocol !== ProtocolType.Sealevel || token.isNative()) { + throw new Error( + `Unsupported ATA payer protocol type ${token.protocol} or standard ${token.standard}`, + ); + } + const adapter = token.getHypAdapter( + warpCore.multiProvider, + ) as SealevelHypTokenAdapter; + + const ataPayer = adapter.deriveAtaPayerAccount().toString(); + const nativeToken = Token.FromChainMetadataNativeToken( + warpCore.multiProvider.getChainMetadata(token.chainName), + ); + const ataPayerBalance = await nativeToken.getBalance( + warpCore.multiProvider, + ataPayer, + ); + return { + chain: token.chainName, + walletAddress: ataPayer.toString(), + walletName: `${warpRouteId}/ata-payer`, + balance: ataPayerBalance.getDecimalFormattedAmount(), + }; + } + + private async getXERC20Info( + warpCore: WarpCore, + token: Token, + ): Promise { + if (token.protocol !== ProtocolType.Ethereum) { + throw new Error(`Unsupported XERC20 protocol type ${token.protocol}`); + } + + if ( + token.standard === TokenStandard.EvmHypXERC20 || + token.standard === TokenStandard.EvmHypVSXERC20 + ) { + const adapter = token.getAdapter( + warpCore.multiProvider, + ) as EvmHypXERC20Adapter; + return { + limits: await this.getXERC20Limit(token, adapter), + xERC20Address: (await adapter.getXERC20()).address, + }; + } else if ( + token.standard === TokenStandard.EvmHypXERC20Lockbox || + token.standard === TokenStandard.EvmHypVSXERC20Lockbox + ) { + const adapter = token.getAdapter( + warpCore.multiProvider, + ) as EvmHypXERC20LockboxAdapter; + return { + limits: await this.getXERC20Limit(token, adapter), + xERC20Address: (await adapter.getXERC20()).address, + }; + } + throw new Error(`Unsupported XERC20 token standard ${token.standard}`); + } + + private async getXERC20Limit( + token: Token, + xerc20: IHypXERC20Adapter, + ): Promise { + const [mintCurrent, mintMax, burnCurrent, burnMax] = await Promise.all([ + xerc20.getMintLimit(), + xerc20.getMintMaxLimit(), + xerc20.getBurnLimit(), + xerc20.getBurnMaxLimit(), + ]); + + return { + mint: this.formatBigInt(token, mintCurrent), + mintMax: this.formatBigInt(token, mintMax), + burn: this.formatBigInt(token, burnCurrent), + burnMax: this.formatBigInt(token, burnMax), + }; + } + + private readonly managedLockBoxMinimalABI = [ + 'function XERC20() view returns (address)', + 'function ERC20() view returns (address)', + ] as const; + + private async getExtraLockboxInfo( + warpToken: Token, + multiProtocolProvider: MultiProtocolProvider, + lockboxAddress: Address, + ): Promise { + const currentChainProvider = multiProtocolProvider.getEthersV5Provider( + warpToken.chainName, + ); + const lockboxInstance = await this.getManagedLockBox( + warpToken, + multiProtocolProvider, + lockboxAddress, + ); + + const xERC20Address = await lockboxInstance.XERC20(); + const vsXERC20Address = IXERC20VS__factory.connect( + xERC20Address, + currentChainProvider, + ); // todo use adapter + + const [mintMax, burnMax, mint, burn] = await Promise.all([ + vsXERC20Address.mintingMaxLimitOf(lockboxAddress), + vsXERC20Address.burningMaxLimitOf(lockboxAddress), + vsXERC20Address.mintingCurrentLimitOf(lockboxAddress), + vsXERC20Address.burningCurrentLimitOf(lockboxAddress), + ]); + + return { + limits: { + burn: this.formatBigInt(warpToken, burn.toBigInt()), + burnMax: this.formatBigInt(warpToken, burnMax.toBigInt()), + mint: this.formatBigInt(warpToken, mint.toBigInt()), + mintMax: this.formatBigInt(warpToken, mintMax.toBigInt()), + }, + xERC20Address, + }; + } + + private async getManagedLockBox( + warpToken: Token, + multiProtocolProvider: MultiProtocolProvider, + lockboxAddress: Address, + ): Promise { + const chainName = warpToken.chainName; + const provider = multiProtocolProvider.getEthersV5Provider(chainName); + return new Contract( + lockboxAddress, + this.managedLockBoxMinimalABI, + provider, + ); + } + + private async getExtraLockboxBalance( + warpToken: Token, + multiProtocolProvider: MultiProtocolProvider, + tokenPriceGetter: CoinGeckoTokenPriceGetter, + lockboxAddress: Address, + ): Promise { + const logger = getLogger(); + if (!warpToken.isXerc20()) { + return undefined; + } + + const lockboxInstance = await this.getManagedLockBox( + warpToken, + multiProtocolProvider, + lockboxAddress, + ); + + const erc20TokenAddress = await lockboxInstance.ERC20(); + const erc20tokenAdapter = new EvmTokenAdapter( + warpToken.chainName, + multiProtocolProvider, + { + token: erc20TokenAddress, + }, + ); + + let balance; + try { + balance = await erc20tokenAdapter.getBalance(lockboxAddress); + } catch (err) { + logger.error( + { + err, + chain: warpToken.chainName, + token: warpToken.symbol, + lockboxAddress, + erc20TokenAddress, + }, + 'Failed to get balance for contract at lockbox address', + ); + return undefined; + } + + const tokenPrice = await this.tryGetTokenPrice(warpToken, tokenPriceGetter); + + const balanceNumber = this.formatBigInt(warpToken, balance); + + return { + balance: balanceNumber, + valueUSD: tokenPrice ? balanceNumber * tokenPrice : undefined, + tokenAddress: erc20TokenAddress, + }; + } + + // Tries to get the price of a token from CoinGecko. Returns undefined if there's no + // CoinGecko ID for the token. + private async tryGetTokenPrice( + token: Token, + tokenPriceGetter: CoinGeckoTokenPriceGetter, + ): Promise { + const logger = getLogger(); + // We only get a price if the token defines a CoinGecko ID. + // This way we can ignore values of certain types of collateralized warp routes, + // e.g. Native warp routes on rollups that have been pre-funded. + const coinGeckoId = token.coinGeckoId; + + if (!coinGeckoId) { + logger.warn( + { token: token.symbol, chain: token.chainName }, + 'Missing CoinGecko ID for token', + ); + return undefined; + } + + return this.getCoingeckoPrice(tokenPriceGetter, coinGeckoId); + } + + private async getCoingeckoPrice( + tokenPriceGetter: CoinGeckoTokenPriceGetter, + coingeckoId: string, + ): Promise { + const prices = await tokenPriceGetter.getTokenPriceByIds([coingeckoId]); + if (!prices) return undefined; + return prices[0]; + } +} diff --git a/typescript/warp-monitor/src/service.ts b/typescript/warp-monitor/src/service.ts new file mode 100644 index 00000000000..fa35c8446f0 --- /dev/null +++ b/typescript/warp-monitor/src/service.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Hyperlane Warp Monitor Service Entry Point + * + * This is the main entry point for running the warp balance monitor as a standalone service + * in Kubernetes or other container environments. It reads configuration from + * environment variables, then starts the monitor in daemon mode. + * + * Environment Variables: + * - WARP_ROUTE_ID: The warp route ID to monitor (required) + * - CHECK_FREQUENCY: Balance check frequency in ms (default: 30000) + * - COINGECKO_API_KEY: API key for CoinGecko price fetching (optional) + * - LOG_LEVEL: Logging level (default: "info") - supported by pino + * - REGISTRY_URI: Registry URI for chain metadata. Can include /tree/{commit} to pin version (default: GitHub registry) + * + * Usage: + * node dist/service.js + * WARP_ROUTE_ID=ETH/ethereum-base COINGECKO_API_KEY=... node dist/service.js + */ +import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry'; +import { getRegistry } from '@hyperlane-xyz/registry/fs'; +import { rootLogger } from '@hyperlane-xyz/utils'; + +import { WarpMonitor } from './monitor.js'; +import { initializeLogger } from './utils.js'; + +async function main(): Promise { + const VERSION = process.env.SERVICE_VERSION || 'dev'; + + // Validate required environment variables + const warpRouteId = process.env.WARP_ROUTE_ID; + if (!warpRouteId) { + rootLogger.error('WARP_ROUTE_ID environment variable is required'); + process.exit(1); + } + + // Parse optional environment variables + let checkFrequency = 30_000; + if (process.env.CHECK_FREQUENCY) { + const parsed = parseInt(process.env.CHECK_FREQUENCY, 10); + if (isNaN(parsed) || parsed <= 0) { + rootLogger.error( + 'CHECK_FREQUENCY must be a positive number (milliseconds)', + ); + process.exit(1); + } + checkFrequency = parsed; + } + + const coingeckoApiKey = process.env.COINGECKO_API_KEY; + + // Create logger (uses LOG_LEVEL environment variable for level configuration) + const logger = await initializeLogger('warp-balance-monitor', VERSION); + + logger.info( + { + version: VERSION, + warpRouteId, + checkFrequency, + }, + 'Starting Hyperlane Warp Balance Monitor Service', + ); + + try { + // Initialize registry (uses env var or defaults to GitHub registry) + // For GitHub registries, REGISTRY_URI can include /tree/{commit} to pin to a specific version + const registryUri = process.env.REGISTRY_URI || DEFAULT_GITHUB_REGISTRY; + const registry = getRegistry({ + registryUris: [registryUri], + enableProxy: true, + logger: rootLogger, + }); + logger.info({ registryUri }, 'Initialized registry'); + + // Create and start the monitor + const monitor = new WarpMonitor( + { + warpRouteId, + checkFrequency, + coingeckoApiKey, + registryUri, + }, + registry, + ); + + await monitor.start(); + } catch (error) { + logger.error({ error }, 'Failed to start warp monitor service'); + process.exit(1); + } +} + +// Run the service +main().catch((error) => { + rootLogger.error({ error }, 'Fatal error'); + process.exit(1); +}); diff --git a/typescript/warp-monitor/src/types.test.ts b/typescript/warp-monitor/src/types.test.ts new file mode 100644 index 00000000000..ea3d9899664 --- /dev/null +++ b/typescript/warp-monitor/src/types.test.ts @@ -0,0 +1,97 @@ +import { expect } from 'chai'; + +import type { + NativeWalletBalance, + WarpMonitorConfig, + WarpRouteBalance, + XERC20Limit, +} from './types.js'; + +describe('Warp Monitor Types', () => { + describe('WarpRouteBalance', () => { + it('should create a valid WarpRouteBalance object', () => { + const balance: WarpRouteBalance = { + balance: 1000, + valueUSD: 5000, + tokenAddress: '0x1234567890123456789012345678901234567890', + }; + + expect(balance.balance).to.equal(1000); + expect(balance.valueUSD).to.equal(5000); + expect(balance.tokenAddress).to.equal( + '0x1234567890123456789012345678901234567890', + ); + }); + + it('should allow optional valueUSD', () => { + const balance: WarpRouteBalance = { + balance: 1000, + tokenAddress: '0x1234567890123456789012345678901234567890', + }; + + expect(balance.valueUSD).to.be.undefined; + }); + }); + + describe('NativeWalletBalance', () => { + it('should create a valid NativeWalletBalance object', () => { + const balance: NativeWalletBalance = { + chain: 'ethereum', + walletAddress: '0x1234567890123456789012345678901234567890', + walletName: 'ata-payer', + balance: 10.5, + }; + + expect(balance.chain).to.equal('ethereum'); + expect(balance.walletAddress).to.equal( + '0x1234567890123456789012345678901234567890', + ); + expect(balance.walletName).to.equal('ata-payer'); + expect(balance.balance).to.equal(10.5); + }); + }); + + describe('XERC20Limit', () => { + it('should create a valid XERC20Limit object', () => { + const limit: XERC20Limit = { + mint: 1000, + burn: 500, + mintMax: 10000, + burnMax: 5000, + }; + + expect(limit.mint).to.equal(1000); + expect(limit.burn).to.equal(500); + expect(limit.mintMax).to.equal(10000); + expect(limit.burnMax).to.equal(5000); + }); + }); + + describe('WarpMonitorConfig', () => { + it('should create a valid WarpMonitorConfig object', () => { + const config: WarpMonitorConfig = { + warpRouteId: 'ETH/ethereum-polygon', + checkFrequency: 30000, + coingeckoApiKey: 'test-api-key', + registryUri: 'https://github.com/hyperlane-xyz/hyperlane-registry', + }; + + expect(config.warpRouteId).to.equal('ETH/ethereum-polygon'); + expect(config.checkFrequency).to.equal(30000); + expect(config.coingeckoApiKey).to.equal('test-api-key'); + expect(config.registryUri).to.equal( + 'https://github.com/hyperlane-xyz/hyperlane-registry', + ); + }); + + it('should allow optional fields', () => { + const config: WarpMonitorConfig = { + warpRouteId: 'ETH/ethereum-polygon', + checkFrequency: 30000, + }; + + expect(config.coingeckoApiKey).to.be.undefined; + expect(config.registryUri).to.be.undefined; + }); + }); +}); diff --git a/typescript/infra/scripts/warp-routes/monitor/types.ts b/typescript/warp-monitor/src/types.ts similarity index 57% rename from typescript/infra/scripts/warp-routes/monitor/types.ts rename to typescript/warp-monitor/src/types.ts index a1d964e918d..68b67435320 100644 --- a/typescript/infra/scripts/warp-routes/monitor/types.ts +++ b/typescript/warp-monitor/src/types.ts @@ -1,5 +1,5 @@ -import { ChainName } from '@hyperlane-xyz/sdk'; -import { Address } from '@hyperlane-xyz/utils'; +import type { ChainName } from '@hyperlane-xyz/sdk'; +import type { Address } from '@hyperlane-xyz/utils'; export interface XERC20Limit { mint: number; @@ -20,3 +20,10 @@ export interface NativeWalletBalance { walletName: string; balance: number; } + +export interface WarpMonitorConfig { + warpRouteId: string; + checkFrequency: number; + coingeckoApiKey?: string; + registryUri?: string; +} diff --git a/typescript/warp-monitor/src/utils.test.ts b/typescript/warp-monitor/src/utils.test.ts new file mode 100644 index 00000000000..ef5e8e7447b --- /dev/null +++ b/typescript/warp-monitor/src/utils.test.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; + +import { getLogger, setLoggerBindings, tryFn } from './utils.js'; + +describe('Warp Monitor Utils', () => { + describe('getLogger', () => { + it('should return a logger instance', () => { + const logger = getLogger(); + expect(logger).to.have.property('info'); + expect(logger).to.have.property('warn'); + expect(logger).to.have.property('error'); + }); + }); + + describe('setLoggerBindings', () => { + it('should not throw when setting bindings', () => { + expect(() => + setLoggerBindings({ warp_route: 'test-route' }), + ).to.not.throw(); + }); + }); + + describe('tryFn', () => { + it('should execute the function successfully', async () => { + let executed = false; + await tryFn(async () => { + executed = true; + }, 'test context'); + expect(executed).to.be.true; + }); + + it('should catch and log errors without throwing', async () => { + const errorFn = async () => { + throw new Error('Test error'); + }; + + // Should not throw + await tryFn(errorFn, 'error test context'); + }); + }); +}); diff --git a/typescript/warp-monitor/src/utils.ts b/typescript/warp-monitor/src/utils.ts new file mode 100644 index 00000000000..9b133ac8d24 --- /dev/null +++ b/typescript/warp-monitor/src/utils.ts @@ -0,0 +1,37 @@ +import type { Logger } from 'pino'; + +import { rootLogger, setRootLogger } from '@hyperlane-xyz/utils'; + +let logger: Logger = rootLogger; + +export async function initializeLogger( + service: string, + version: string, +): Promise { + const { createServiceLogger } = await import('@hyperlane-xyz/utils'); + logger = await createServiceLogger({ + service, + version, + }); + setRootLogger(logger); + return logger; +} + +export function getLogger(): Logger { + return logger; +} + +export function setLoggerBindings(bindings: Record): void { + logger.setBindings(bindings); +} + +export async function tryFn( + fn: () => Promise, + context: string, +): Promise { + try { + await fn(); + } catch (err) { + logger.error(err, `Error in ${context}`); + } +} diff --git a/typescript/warp-monitor/tsconfig.json b/typescript/warp-monitor/tsconfig.json new file mode 100644 index 00000000000..9005365a9b7 --- /dev/null +++ b/typescript/warp-monitor/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@hyperlane-xyz/tsconfig/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src/**/*"] +} diff --git a/typescript/warp-monitor/turbo.json b/typescript/warp-monitor/turbo.json new file mode 100644 index 00000000000..eda75172045 --- /dev/null +++ b/typescript/warp-monitor/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "bundle": { + "dependsOn": ["build"], + "outputs": ["bundle/**"] + } + } +}