diff --git a/.github/actions/docker-image-comment/action.yml b/.github/actions/docker-image-comment/action.yml index 8de62a8f2df..7703de000ea 100644 --- a/.github/actions/docker-image-comment/action.yml +++ b/.github/actions/docker-image-comment/action.yml @@ -50,6 +50,7 @@ runs: declare -A SERVICE_EMOJI=( ["rebalancer"]="♻️" ["warp-monitor"]="🕵️" + ["key-funder"]="🔑" ["offchain-lookup-server"]="🔍" ["monorepo"]="📦" ) diff --git a/.github/workflows/node-services-docker.yml b/.github/workflows/node-services-docker.yml index 2077c3be4d8..d4e2ad72235 100644 --- a/.github/workflows/node-services-docker.yml +++ b/.github/workflows/node-services-docker.yml @@ -10,6 +10,7 @@ on: - 'typescript/rebalancer/**' - 'typescript/warp-monitor/**' - 'typescript/ccip-server/**' + - 'typescript/keyfunder/**' - 'typescript/Dockerfile.node-service' - 'pnpm-lock.yaml' - '.github/workflows/node-services-docker.yml' @@ -125,6 +126,7 @@ jobs: TAGS=$(cat << EOF ${REGISTRY}/hyperlane-rebalancer:${TAG_SHA_DATE} ${REGISTRY}/hyperlane-warp-monitor:${TAG_SHA_DATE} + ${REGISTRY}/hyperlane-key-funder:${TAG_SHA_DATE} ${REGISTRY}/hyperlane-offchain-lookup-server:${TAG_SHA_DATE} EOF ) @@ -140,6 +142,7 @@ jobs: |---------|-----| | ♻️ rebalancer | \`${TAG_SHA_DATE}\` | | 🕵️ warp-monitor | \`${TAG_SHA_DATE}\` | + | 🔑 key-funder | \`${TAG_SHA_DATE}\` | | 🔍 offchain-lookup-server | \`${TAG_SHA_DATE}\` | **Full image paths:** diff --git a/Dockerfile b/Dockerfile index 777013ef56e..907f9a370d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,7 @@ COPY typescript/github-proxy/package.json ./typescript/github-proxy/ COPY typescript/helloworld/package.json ./typescript/helloworld/ COPY typescript/http-registry-server/package.json ./typescript/http-registry-server/ COPY typescript/infra/package.json ./typescript/infra/ +COPY typescript/keyfunder/package.json ./typescript/keyfunder/ COPY typescript/provider-sdk/package.json ./typescript/provider-sdk/ COPY typescript/radix-sdk/package.json ./typescript/radix-sdk/ COPY typescript/rebalancer/package.json ./typescript/rebalancer/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac75b7a5b6..02ca810c6aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1648,6 +1648,94 @@ importers: specifier: 'catalog:' version: 5.8.3 + typescript/keyfunder: + dependencies: + '@google-cloud/pino-logging-gcp-config': + specifier: 'catalog:' + version: 1.3.0 + '@hyperlane-xyz/core': + specifier: workspace:* + version: link:../../solidity + '@hyperlane-xyz/metrics': + specifier: workspace:* + version: link:../metrics + '@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 + pino-pretty: + specifier: 'catalog:' + version: 13.1.2 + prom-client: + specifier: 'catalog:' + version: 14.2.0 + yaml: + specifier: 'catalog:' + version: 2.4.5 + zod: + specifier: 'catalog:' + version: 3.25.76 + zod-validation-error: + specifier: 'catalog:' + version: 3.5.4(zod@3.25.76) + devDependencies: + '@hyperlane-xyz/eslint-config': + specifier: workspace:^ + version: link:../eslint-config + '@hyperlane-xyz/tsconfig': + specifier: workspace:^ + version: link:../tsconfig + '@types/chai-as-promised': + specifier: 'catalog:' + version: 8.0.2 + '@types/mocha': + specifier: 'catalog:' + version: 10.0.10 + '@types/node': + specifier: 'catalog:' + version: 24.10.9 + '@types/sinon': + specifier: 'catalog:' + version: 17.0.4 + '@vercel/ncc': + specifier: 'catalog:' + version: 0.38.4 + chai: + specifier: 'catalog:' + version: 4.5.0 + chai-as-promised: + specifier: 'catalog:' + version: 8.0.2(chai@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/metrics: dependencies: '@hyperlane-xyz/core': diff --git a/typescript/docker-bake.hcl b/typescript/docker-bake.hcl index f788ae64e11..b6ba4c401ca 100644 --- a/typescript/docker-bake.hcl +++ b/typescript/docker-bake.hcl @@ -43,6 +43,7 @@ target "ncc-services" { { name = "rebalancer", dir = "rebalancer", package = "@hyperlane-xyz/rebalancer", image = "hyperlane-rebalancer", port = "" }, { name = "warp-monitor", dir = "warp-monitor", package = "@hyperlane-xyz/warp-monitor", image = "hyperlane-warp-monitor", port = "" }, { name = "ccip-server", dir = "ccip-server", package = "@hyperlane-xyz/ccip-server", image = "hyperlane-offchain-lookup-server", port = "3000" }, + { name = "keyfunder", dir = "keyfunder", package = "@hyperlane-xyz/keyfunder", image = "hyperlane-key-funder", port=""}, ] } diff --git a/typescript/keyfunder/.gitignore b/typescript/keyfunder/.gitignore new file mode 100644 index 00000000000..6e2fc577008 --- /dev/null +++ b/typescript/keyfunder/.gitignore @@ -0,0 +1,7 @@ +.env* +/dist +/bundle +/cache + +# allow check-in of .env.example +!.env.example diff --git a/typescript/keyfunder/.mocharc.json b/typescript/keyfunder/.mocharc.json new file mode 100644 index 00000000000..2a414eb587f --- /dev/null +++ b/typescript/keyfunder/.mocharc.json @@ -0,0 +1,3 @@ +{ + "import": ["tsx"] +} diff --git a/typescript/keyfunder/README.md b/typescript/keyfunder/README.md new file mode 100644 index 00000000000..ab081f8f0d3 --- /dev/null +++ b/typescript/keyfunder/README.md @@ -0,0 +1,178 @@ +# @hyperlane-xyz/keyfunder + +Standalone service for funding Hyperlane agent keys with native tokens across multiple chains. + +## Overview + +The KeyFunder service: + +- Funds agent keys (relayers, kathy, rebalancer) to maintain desired balances +- Claims accumulated fees from InterchainGasPaymaster (IGP) contracts +- Sweeps excess funds from the funder wallet to a safe address + +## Configuration + +The service reads configuration from a YAML file. The file path is specified via the `KEYFUNDER_CONFIG_FILE` environment variable. + +### Example Configuration + +```yaml +version: '1' + +# Roles define WHO gets funded (address defined once, reused across chains) +roles: + hyperlane-relayer: + address: '0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5' + hyperlane-kathy: + address: '0x5fb02f40f56d15f0442a39d11a23f73747095b20' + hyperlane-rebalancer: + address: '0xdef456...' + +# Chains define HOW MUCH each role gets (balances reference role names) +chains: + ethereum: + balances: + hyperlane-relayer: '0.5' + hyperlane-kathy: '0.4' + igp: + address: '0x6cA0B6D43F8e45C82e57eC5a5F2Bce4bF2b6F1f7' + claimThreshold: '0.2' + sweep: + enabled: true + address: '0x478be6076f31E9666123B9721D0B6631baD944AF' + threshold: '0.3' + targetMultiplier: 1.5 + triggerMultiplier: 2.0 + arbitrum: + balances: + hyperlane-relayer: '0.1' + igp: + address: '0x3b6044acd6767f017e99318AA6Ef93b7B06A5a22' + claimThreshold: '0.1' + +metrics: + jobName: 'keyfunder-mainnet3' + labels: + environment: 'mainnet3' +chainsToSkip: [] +``` + +### Configuration Options + +| Field | Description | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `version` | Config version, must be "1" | +| `roles` | Role definitions (address per role) | +| `roles..address` | Ethereum address for this role | +| `chains` | Per-chain configuration | +| `chains..balances` | Map of role name to desired balance | +| `chains..balances.` | Target balance decimal string (e.g., "0.5" ETH; up to 18 decimals) | +| `chains..igp` | IGP claim configuration | +| `chains..igp.address` | IGP contract address (required when `igp` is specified) | +| `chains..igp.claimThreshold` | Minimum IGP balance before claiming (decimal string; up to 18 decimals) | +| `chains..sweep` | Sweep excess funds configuration | +| `chains..sweep.enabled` | Enable sweep functionality | +| `chains..sweep.address` | Address to sweep funds to (required when enabled) | +| `chains..sweep.threshold` | Base threshold for sweep calculations (required when enabled; decimal string; up to 18 decimals) | +| `chains..sweep.targetMultiplier` | Multiplier for target balance (default: 1.5; 2 decimal precision, floored) | +| `chains..sweep.triggerMultiplier` | Multiplier for trigger threshold (default: 2.0; 2 decimal precision, floored) | +| `metrics.jobName` | Job name for metrics | +| `metrics.labels` | Additional labels for metrics | +| `chainsToSkip` | Array of chain names to skip | + +### Precision Notes + +- **Balance strings**: Support up to 18 decimal places (standard ETH precision). Must include leading digit (e.g., `"0.5"` not `".5"`). +- **Multipliers**: Calculated with 2 decimal precision using floor (e.g., `1.555` is treated as `1.55`, not `1.56`). + +## Environment Variables + +| Variable | Description | Required | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `KEYFUNDER_CONFIG_FILE` | Path to config YAML file | Yes | +| `HYP_KEY` | Private key for funding wallet | Yes | +| `RPC_URL_` | RPC URL per chain (e.g., `RPC_URL_ETHEREUM`, `RPC_URL_ARBITRUM`). Falls back to registry defaults if not set. | No | +| `REGISTRY_URI` | Hyperlane registry URI (default: GitHub registry). Supports commit pinning (e.g., `github://hyperlane-xyz/hyperlane-registry/commit/abc123`) | No | +| `SKIP_IGP_CLAIM` | Set to "true" to skip IGP claims | No | +| `PROMETHEUS_PUSH_GATEWAY` | Prometheus push gateway URL (e.g., `http://prometheus-pushgateway:9091`) | No | +| `SERVICE_VERSION` | Version identifier for logging (default: "dev") | No | +| `LOG_LEVEL` | Log level: DEBUG, INFO, WARN, ERROR | No | +| `LOG_FORMAT` | Log format: JSON, PRETTY | No | + +In Kubernetes deployments, `HYP_KEY` and `RPC_URL_*` are injected via ExternalSecrets from GCP Secret Manager. + +## Usage + +### Docker + +```bash +docker run -v /path/to/config.yaml:/config/keyfunder.yaml \ + -e KEYFUNDER_CONFIG_FILE=/config/keyfunder.yaml \ + -e HYP_KEY=0x... \ + -e RPC_URL_ETHEREUM=https://... \ + gcr.io/abacus-labs-dev/hyperlane-key-funder:latest +``` + +### Local Development + +```bash +# Build +pnpm build + +# Run locally +KEYFUNDER_CONFIG_FILE=./config.yaml HYP_KEY=0x... RPC_URL_ETHEREUM=https://... pnpm start:dev +``` + +### Bundle + +The service can be bundled into a single file using ncc: + +```bash +pnpm bundle +# Output: ./bundle/index.js +``` + +## Funding Logic + +### Key Funding + +Keys are funded when their balance drops below 40% of the desired balance. The funding amount brings the balance up to the full desired balance. + +**Example**: If `desiredBalance` is `1.0 ETH` and current balance is `0.39 ETH` (39%), funding is triggered. The key receives `0.61 ETH` to reach the full `1.0 ETH`. + +### IGP Claims + +When the IGP contract balance exceeds the claim threshold, accumulated fees are claimed to the funder wallet. + +### Sweep + +When the funder wallet balance exceeds `threshold * triggerMultiplier`, excess funds are swept to the safe address, leaving `threshold * targetMultiplier` in the wallet. + +**Example**: With `threshold: '1.0'`, `triggerMultiplier: 2.0`, `targetMultiplier: 1.5`: + +- If funder balance > 2.0 ETH, sweep is triggered +- After sweep, funder balance = 1.5 ETH + +### Timeouts + +Each chain is processed with a 60-second timeout. If funding operations for a chain exceed this limit, the chain is marked as failed and processing continues with remaining chains. + +## Metrics + +The service exposes Prometheus metrics: + +| Metric | Description | +| ------------------------------------------------ | ---------------------------- | +| `hyperlane_keyfunder_wallet_balance` | Current wallet balance | +| `hyperlane_keyfunder_funding_amount` | Amount funded to a key | +| `hyperlane_keyfunder_igp_balance` | IGP contract balance | +| `hyperlane_keyfunder_sweep_amount` | Amount swept to safe address | +| `hyperlane_keyfunder_operation_duration_seconds` | Duration of operations | + +## Deployment + +The service is typically deployed as a Kubernetes CronJob. See `typescript/infra/helm/key-funder/` for the Helm chart. + +## License + +Apache-2.0 diff --git a/typescript/keyfunder/eslint.config.mjs b/typescript/keyfunder/eslint.config.mjs new file mode 100644 index 00000000000..f677b56736f --- /dev/null +++ b/typescript/keyfunder/eslint.config.mjs @@ -0,0 +1,3 @@ +import { jsRules, typescriptRules } from '@hyperlane-xyz/eslint-config'; + +export default [...jsRules, ...typescriptRules]; diff --git a/typescript/keyfunder/package.json b/typescript/keyfunder/package.json new file mode 100644 index 00000000000..4d8fec94152 --- /dev/null +++ b/typescript/keyfunder/package.json @@ -0,0 +1,65 @@ +{ + "name": "@hyperlane-xyz/keyfunder", + "version": "0.0.0", + "private": true, + "description": "Hyperlane Key Funder Service - funds agent keys with native tokens", + "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/metrics": "workspace:*", + "@hyperlane-xyz/registry": "catalog:", + "@hyperlane-xyz/sdk": "workspace:*", + "@hyperlane-xyz/utils": "workspace:*", + "ethers": "catalog:", + "pino": "catalog:", + "pino-pretty": "catalog:", + "prom-client": "catalog:", + "yaml": "catalog:", + "zod": "catalog:", + "zod-validation-error": "catalog:" + }, + "devDependencies": { + "@hyperlane-xyz/eslint-config": "workspace:^", + "@hyperlane-xyz/tsconfig": "workspace:^", + "@types/chai-as-promised": "catalog:", + "@types/mocha": "catalog:", + "@types/node": "catalog:", + "@types/sinon": "catalog:", + "@vercel/ncc": "catalog:", + "chai": "catalog:", + "chai-as-promised": "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", + "keyfunder", + "funding", + "agent", + "blockchain", + "interchain" + ], + "author": "Abacus Works, Inc.", + "license": "Apache-2.0" +} diff --git a/typescript/keyfunder/src/config/KeyFunderConfig.test.ts b/typescript/keyfunder/src/config/KeyFunderConfig.test.ts new file mode 100644 index 00000000000..c1cb0dd3f39 --- /dev/null +++ b/typescript/keyfunder/src/config/KeyFunderConfig.test.ts @@ -0,0 +1,161 @@ +import { expect } from 'chai'; +import fs from 'fs'; +import sinon from 'sinon'; + +import { KeyFunderConfigLoader } from './KeyFunderConfig.js'; + +describe('KeyFunderConfigLoader', () => { + let fsExistsStub: sinon.SinonStub; + let fsReadFileStub: sinon.SinonStub; + + beforeEach(() => { + fsExistsStub = sinon.stub(fs, 'existsSync'); + fsReadFileStub = sinon.stub(fs, 'readFileSync'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('load', () => { + it('should load valid config from file', () => { + const configYaml = ` +version: "1" +roles: + hyperlane-relayer: + address: "0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5" +chains: + ethereum: + balances: + hyperlane-relayer: "0.5" +`; + fsExistsStub.returns(true); + fsReadFileStub.returns(configYaml); + + const loader = KeyFunderConfigLoader.load('/path/to/config.yaml'); + + expect(loader.config.version).to.equal('1'); + expect(loader.config.roles['hyperlane-relayer'].address).to.equal( + '0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5', + ); + expect(loader.config.chains.ethereum.balances).to.deep.equal({ + 'hyperlane-relayer': '0.5', + }); + }); + + it('should throw if file does not exist', () => { + fsExistsStub.returns(false); + + expect(() => KeyFunderConfigLoader.load('/nonexistent.yaml')).to.throw( + 'Config file not found', + ); + }); + + it('should throw on invalid config', () => { + const invalidYaml = ` +version: "2" +roles: {} +chains: {} +`; + fsExistsStub.returns(true); + fsReadFileStub.returns(invalidYaml); + + expect(() => KeyFunderConfigLoader.load('/path/to/config.yaml')).to.throw( + 'Invalid keyfunder config', + ); + }); + }); + + describe('fromObject', () => { + it('should create loader from valid object', () => { + const config = { + version: '1' as const, + roles: { + 'hyperlane-relayer': { + address: '0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5', + }, + }, + chains: { + ethereum: { + balances: { + 'hyperlane-relayer': '0.5', + }, + }, + }, + }; + + const loader = KeyFunderConfigLoader.fromObject(config); + expect(loader.config.chains.ethereum.balances).to.deep.equal({ + 'hyperlane-relayer': '0.5', + }); + }); + + it('should throw on invalid object', () => { + const config = { + version: '2', + roles: {}, + chains: {}, + }; + + expect(() => KeyFunderConfigLoader.fromObject(config as never)).to.throw( + 'Invalid keyfunder config', + ); + }); + }); + + describe('getConfiguredChains', () => { + it('should return all chain names', () => { + const config = { + version: '1' as const, + roles: {}, + chains: { + ethereum: {}, + arbitrum: {}, + polygon: {}, + }, + }; + + const loader = KeyFunderConfigLoader.fromObject(config); + const chains = loader.getConfiguredChains(); + + expect(chains).to.have.members(['ethereum', 'arbitrum', 'polygon']); + }); + }); + + describe('getChainsToProcess', () => { + it('should exclude skipped chains', () => { + const config = { + version: '1' as const, + roles: {}, + chains: { + ethereum: {}, + arbitrum: {}, + polygon: {}, + }, + chainsToSkip: ['polygon'], + }; + + const loader = KeyFunderConfigLoader.fromObject(config); + const chains = loader.getChainsToProcess(); + + expect(chains).to.have.members(['ethereum', 'arbitrum']); + expect(chains).to.not.include('polygon'); + }); + + it('should return all chains when none skipped', () => { + const config = { + version: '1' as const, + roles: {}, + chains: { + ethereum: {}, + arbitrum: {}, + }, + }; + + const loader = KeyFunderConfigLoader.fromObject(config); + const chains = loader.getChainsToProcess(); + + expect(chains).to.have.members(['ethereum', 'arbitrum']); + }); + }); +}); diff --git a/typescript/keyfunder/src/config/KeyFunderConfig.ts b/typescript/keyfunder/src/config/KeyFunderConfig.ts new file mode 100644 index 00000000000..235e39c2e62 --- /dev/null +++ b/typescript/keyfunder/src/config/KeyFunderConfig.ts @@ -0,0 +1,44 @@ +import fs from 'fs'; +import * as YAML from 'yaml'; +import { fromZodError } from 'zod-validation-error'; + +import { + KeyFunderConfig, + KeyFunderConfigInput, + KeyFunderConfigSchema, +} from './types.js'; + +export class KeyFunderConfigLoader { + private constructor(public readonly config: KeyFunderConfig) {} + + static load(filePath: string): KeyFunderConfigLoader { + if (!fs.existsSync(filePath)) { + throw new Error(`Config file not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, 'utf8'); + const rawConfig: KeyFunderConfigInput = YAML.parse(content); + + return KeyFunderConfigLoader.fromObject(rawConfig); + } + + static fromObject(config: KeyFunderConfigInput): KeyFunderConfigLoader { + const validationResult = KeyFunderConfigSchema.safeParse(config); + if (!validationResult.success) { + throw new Error( + `Invalid keyfunder config: ${fromZodError(validationResult.error).message}`, + ); + } + return new KeyFunderConfigLoader(validationResult.data); + } + + getConfiguredChains(): string[] { + return Object.keys(this.config.chains); + } + + getChainsToProcess(): string[] { + const allChains = this.getConfiguredChains(); + const chainsToSkip = new Set(this.config.chainsToSkip ?? []); + return allChains.filter((chain) => !chainsToSkip.has(chain)); + } +} diff --git a/typescript/keyfunder/src/config/types.test.ts b/typescript/keyfunder/src/config/types.test.ts new file mode 100644 index 00000000000..4a288fe3bc0 --- /dev/null +++ b/typescript/keyfunder/src/config/types.test.ts @@ -0,0 +1,368 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; + +import { calculateMultipliedBalance } from '../core/KeyFunder.js'; + +import { + ChainConfigSchema, + KeyFunderConfigSchema, + RoleConfigSchema, + SweepConfigSchema, +} from './types.js'; + +describe('KeyFunderConfig Schemas', () => { + describe('RoleConfigSchema', () => { + it('should validate a valid role config', () => { + const config = { + address: '0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5', + }; + const result = RoleConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should reject invalid address', () => { + const config = { + address: 'invalid-address', + }; + const result = RoleConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject missing address', () => { + const config = {}; + const result = RoleConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + }); + + describe('SweepConfigSchema', () => { + it('should validate valid sweep config', () => { + const config = { + enabled: true, + address: '0x478be6076f31E9666123B9721D0B6631baD944AF', + threshold: '0.5', + targetMultiplier: 1.5, + triggerMultiplier: 2.0, + }; + const result = SweepConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should reject enabled sweep config without address', () => { + const config = { + enabled: true, + threshold: '0.5', + }; + const result = SweepConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject enabled sweep config without threshold', () => { + const config = { + enabled: true, + address: '0x478be6076f31E9666123B9721D0B6631baD944AF', + }; + const result = SweepConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject trigger multiplier less than target + 0.05', () => { + const config = { + enabled: true, + address: '0x478be6076f31E9666123B9721D0B6631baD944AF', + threshold: '0.5', + targetMultiplier: 1.5, + triggerMultiplier: 1.52, + }; + const result = SweepConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should use default multipliers', () => { + const config = { + enabled: true, + address: '0x478be6076f31E9666123B9721D0B6631baD944AF', + threshold: '0.5', + }; + const result = SweepConfigSchema.safeParse(config); + expect(result.success).to.be.true; + if (result.success) { + expect(result.data.targetMultiplier).to.equal(1.5); + expect(result.data.triggerMultiplier).to.equal(2.0); + } + }); + + it('should skip validation when disabled', () => { + const config = { + enabled: false, + targetMultiplier: 1.5, + triggerMultiplier: 1.5, + }; + const result = SweepConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + }); + + describe('ChainConfigSchema', () => { + it('should validate chain config with balances only', () => { + const config = { + balances: { + 'hyperlane-relayer': '0.5', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should validate chain config with igp', () => { + const config = { + igp: { + address: '0x6cA0B6D43F8e45C82e57eC5a5F2Bce4bF2b6F1f7', + claimThreshold: '0.2', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should validate complete chain config', () => { + const config = { + balances: { + 'hyperlane-relayer': '0.5', + 'hyperlane-kathy': '0.3', + }, + igp: { + address: '0x6cA0B6D43F8e45C82e57eC5a5F2Bce4bF2b6F1f7', + claimThreshold: '0.2', + }, + sweep: { + enabled: true, + address: '0x478be6076f31E9666123B9721D0B6631baD944AF', + threshold: '0.3', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should reject invalid balance value', () => { + const config = { + balances: { + 'hyperlane-relayer': 'not-a-number', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject scientific notation in balances', () => { + const config = { + balances: { + 'hyperlane-relayer': '1e3', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject balances with too many decimals', () => { + const config = { + balances: { + 'hyperlane-relayer': '1.1234567890123456789', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject negative balance', () => { + const config = { + balances: { + 'hyperlane-relayer': '-1', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject balance without leading digit (.5)', () => { + const config = { + balances: { + 'hyperlane-relayer': '.5', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should accept balance with leading zero (0.5)', () => { + const config = { + balances: { + 'hyperlane-relayer': '0.5', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should accept high precision balances (up to 18 decimals)', () => { + const config = { + balances: { + 'hyperlane-relayer': '0.000000000000000001', + }, + }; + const result = ChainConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + }); + + describe('Multiplier precision (calculateMultipliedBalance)', () => { + const oneEther = BigNumber.from('1000000000000000000'); + + it('should calculate 1.5x correctly (1 ETH * 1.5 = 1.5 ETH)', () => { + const result = calculateMultipliedBalance(oneEther, 1.5); + expect(result.toString()).to.equal('1500000000000000000'); + }); + + it('should calculate 2.0x correctly (1 ETH * 2.0 = 2 ETH)', () => { + const result = calculateMultipliedBalance(oneEther, 2.0); + expect(result.toString()).to.equal('2000000000000000000'); + }); + + it('should floor third decimal (1 ETH * 1.555 = 1.55 ETH, not 1.56 ETH)', () => { + const result = calculateMultipliedBalance(oneEther, 1.555); + expect(result.toString()).to.equal('1550000000000000000'); + }); + + it('should floor (1 ETH * 1.999 = 1.99 ETH, not 2 ETH)', () => { + const result = calculateMultipliedBalance(oneEther, 1.999); + expect(result.toString()).to.equal('1990000000000000000'); + }); + }); + + describe('KeyFunderConfigSchema', () => { + it('should validate minimal config', () => { + const config = { + version: '1', + roles: {}, + chains: {}, + }; + const result = KeyFunderConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should reject invalid version', () => { + const config = { + version: '2', + roles: {}, + chains: {}, + }; + const result = KeyFunderConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should reject missing roles', () => { + const config = { + version: '1', + chains: {}, + }; + const result = KeyFunderConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should validate complete config', () => { + const config = { + version: '1', + roles: { + 'hyperlane-relayer': { + address: '0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5', + }, + 'hyperlane-kathy': { + address: '0x5fb02f40f56d15f0442a39d11a23f73747095b20', + }, + }, + chains: { + ethereum: { + balances: { + 'hyperlane-relayer': '0.5', + 'hyperlane-kathy': '0.4', + }, + igp: { + address: '0x6cA0B6D43F8e45C82e57eC5a5F2Bce4bF2b6F1f7', + claimThreshold: '0.2', + }, + sweep: { + enabled: true, + address: '0x478be6076f31E9666123B9721D0B6631baD944AF', + threshold: '0.3', + }, + }, + arbitrum: { + balances: { + 'hyperlane-relayer': '0.1', + }, + }, + }, + metrics: { + jobName: 'keyfunder', + labels: { + environment: 'mainnet3', + }, + }, + chainsToSkip: ['polygon'], + }; + const result = KeyFunderConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + + it('should reject undefined role reference in chain balances', () => { + const config = { + version: '1', + roles: { + 'hyperlane-relayer': { + address: '0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5', + }, + }, + chains: { + ethereum: { + balances: { + 'hyperlane-relayer': '0.5', + 'undefined-role': '0.3', + }, + }, + }, + }; + const result = KeyFunderConfigSchema.safeParse(config); + expect(result.success).to.be.false; + }); + + it('should allow chain balances that reference defined roles', () => { + const config = { + version: '1', + roles: { + 'hyperlane-relayer': { + address: '0x74cae0ecc47b02ed9b9d32e000fd70b9417970c5', + }, + 'hyperlane-kathy': { + address: '0x5fb02f40f56d15f0442a39d11a23f73747095b20', + }, + }, + chains: { + ethereum: { + balances: { + 'hyperlane-relayer': '0.5', + }, + }, + arbitrum: { + balances: { + 'hyperlane-relayer': '0.1', + 'hyperlane-kathy': '0.05', + }, + }, + }, + }; + const result = KeyFunderConfigSchema.safeParse(config); + expect(result.success).to.be.true; + }); + }); +}); diff --git a/typescript/keyfunder/src/config/types.ts b/typescript/keyfunder/src/config/types.ts new file mode 100644 index 00000000000..661cd9333da --- /dev/null +++ b/typescript/keyfunder/src/config/types.ts @@ -0,0 +1,120 @@ +import { z } from 'zod'; + +const AddressSchema = z + .string() + .regex( + /^0x[a-fA-F0-9]{40}$/, + 'Must be a valid Ethereum address (0x-prefixed, 40 hex characters)', + ); + +// Requires leading digit (e.g., "0.5" not ".5") for YAML readability +const BalanceStringSchema = z + .string() + .regex( + /^(?:\d+)(?:\.\d{1,18})?$/, + 'Must be a valid non-negative decimal string with leading digit (e.g., "0.5" not ".5", up to 18 decimals)', + ); + +export const RoleConfigSchema = z.object({ + address: AddressSchema, +}); + +export const IgpConfigSchema = z.object({ + address: AddressSchema, + claimThreshold: BalanceStringSchema, +}); + +const MIN_TRIGGER_DIFFERENCE = 0.05; +const MIN_TARGET = 1.05; +const MIN_TRIGGER = 1.1; +const MAX_TARGET = 10.0; +const MAX_TRIGGER = 200.0; + +export const SweepConfigSchema = z + .object({ + enabled: z.boolean().default(false), + address: AddressSchema.optional(), + targetMultiplier: z + .number() + .min(MIN_TARGET, `Target multiplier must be at least ${MIN_TARGET}`) + .max(MAX_TARGET, `Target multiplier must be at most ${MAX_TARGET}`) + .default(1.5), + triggerMultiplier: z + .number() + .min(MIN_TRIGGER, `Trigger multiplier must be at least ${MIN_TRIGGER}`) + .max(MAX_TRIGGER, `Trigger multiplier must be at most ${MAX_TRIGGER}`) + .default(2.0), + threshold: BalanceStringSchema.optional(), + }) + .refine((data) => !data.enabled || !!data.address, { + message: 'Sweep address is required when sweep is enabled', + path: ['address'], + }) + .refine((data) => !data.enabled || !!data.threshold, { + message: 'Sweep threshold is required when sweep is enabled', + path: ['threshold'], + }) + .refine( + (data) => { + if (!data.enabled) return true; + return ( + data.triggerMultiplier >= data.targetMultiplier + MIN_TRIGGER_DIFFERENCE + ); + }, + { + message: `Trigger multiplier must be at least ${MIN_TRIGGER_DIFFERENCE} greater than target multiplier`, + path: ['triggerMultiplier'], + }, + ); + +export const ChainConfigSchema = z.object({ + balances: z.record(z.string(), BalanceStringSchema).optional(), + igp: IgpConfigSchema.optional(), + sweep: SweepConfigSchema.optional(), +}); + +export const MetricsConfigSchema = z.object({ + jobName: z.string().default('keyfunder'), + labels: z.record(z.string(), z.string()).optional(), +}); + +export const KeyFunderConfigSchema = z + .object({ + version: z.literal('1'), + roles: z.record(z.string(), RoleConfigSchema), + chains: z.record(z.string(), ChainConfigSchema), + metrics: MetricsConfigSchema.optional(), + chainsToSkip: z.array(z.string()).optional(), + }) + .refine( + (data) => { + const definedRoles = new Set(Object.keys(data.roles)); + for (const chainConfig of Object.values(data.chains)) { + if (!chainConfig.balances) continue; + for (const roleName of Object.keys(chainConfig.balances)) { + if (!definedRoles.has(roleName)) { + return false; + } + } + } + return true; + }, + { + message: + 'Chain balances reference undefined roles. All roles must be defined in the roles section.', + }, + ); + +export type RoleConfig = z.infer; +export type IgpConfig = z.infer; +export type SweepConfig = z.infer; +export type ChainConfig = z.infer; +export type MetricsConfig = z.infer; +export type KeyFunderConfig = z.infer; +export type KeyFunderConfigInput = z.input; + +export interface ResolvedKeyConfig { + address: string; + role: string; + desiredBalance: string; +} diff --git a/typescript/keyfunder/src/core/KeyFunder.ts b/typescript/keyfunder/src/core/KeyFunder.ts new file mode 100644 index 00000000000..7803071d858 --- /dev/null +++ b/typescript/keyfunder/src/core/KeyFunder.ts @@ -0,0 +1,372 @@ +import { BigNumber, ethers } from 'ethers'; +import type { Logger } from 'pino'; + +import { HyperlaneIgp, MultiProvider } from '@hyperlane-xyz/sdk'; + +import type { + ChainConfig, + KeyFunderConfig, + ResolvedKeyConfig, +} from '../config/types.js'; +import type { KeyFunderMetrics } from '../metrics/Metrics.js'; + +const MIN_DELTA_NUMERATOR = BigNumber.from(6); +const MIN_DELTA_DENOMINATOR = BigNumber.from(10); + +const CHAIN_FUNDING_TIMEOUT_MS = 60_000; + +export interface KeyFunderOptions { + logger: Logger; + metrics?: KeyFunderMetrics; + skipIgpClaim?: boolean; + igp?: HyperlaneIgp; +} + +export class KeyFunder { + constructor( + private readonly multiProvider: MultiProvider, + private readonly config: KeyFunderConfig, + private readonly options: KeyFunderOptions, + ) {} + + async fundAllChains(): Promise { + const chainsToSkip = new Set(this.config.chainsToSkip ?? []); + const chains = Object.keys(this.config.chains).filter( + (chain) => !chainsToSkip.has(chain), + ); + + const results = await Promise.allSettled( + chains.map((chain) => this.fundChainWithTimeout(chain)), + ); + + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + this.options.logger.error( + { failureCount: failures.length, totalChains: chains.length }, + 'Some chains failed to fund', + ); + throw new Error( + `${failures.length}/${chains.length} chains failed to fund`, + ); + } + } + + private async fundChainWithTimeout(chain: string): Promise { + const { promise: timeoutPromise, cleanup } = createTimeoutPromise( + CHAIN_FUNDING_TIMEOUT_MS, + `Funding timed out for chain ${chain}`, + ); + + try { + await Promise.race([this.fundChain(chain), timeoutPromise]); + } finally { + cleanup(); + } + } + + async fundChain(chain: string): Promise { + const chainConfig = this.config.chains[chain]; + if (!chainConfig) { + this.options.logger.warn({ chain }, 'No config for chain, skipping'); + return; + } + + const startTime = Date.now(); + const logger = this.options.logger.child({ chain }); + + try { + if (!this.options.skipIgpClaim && chainConfig.igp) { + await this.claimFromIgp(chain, chainConfig); + } + + const resolvedKeys = this.resolveKeysForChain(chain, chainConfig); + if (resolvedKeys.length > 0) { + await this.fundKeys(chain, resolvedKeys); + } + + if (chainConfig.sweep?.enabled) { + await this.sweepExcessFunds(chain, chainConfig); + } + + const durationSeconds = (Date.now() - startTime) / 1000; + this.options.metrics?.recordOperationDuration( + chain, + 'fund', + durationSeconds, + ); + logger.info({ durationSeconds }, 'Chain funding completed'); + } catch (error) { + logger.error({ error }, 'Chain funding failed'); + throw error; + } + } + + private resolveKeysForChain( + chain: string, + chainConfig: ChainConfig, + ): ResolvedKeyConfig[] { + if (!chainConfig.balances) { + return []; + } + + const resolvedKeys: ResolvedKeyConfig[] = []; + for (const [roleName, desiredBalance] of Object.entries( + chainConfig.balances, + )) { + const roleConfig = this.config.roles[roleName]; + if (!roleConfig) { + this.options.logger.warn( + { chain, role: roleName }, + 'Role not found in config, skipping', + ); + continue; + } + + resolvedKeys.push({ + address: roleConfig.address, + role: roleName, + desiredBalance, + }); + } + + return resolvedKeys; + } + + private async claimFromIgp( + chain: string, + chainConfig: ChainConfig, + ): Promise { + const igpConfig = chainConfig.igp; + if (!igpConfig || !this.options.igp) { + return; + } + + const logger = this.options.logger.child({ chain, operation: 'igp-claim' }); + const provider = this.multiProvider.getProvider(chain); + const igpContract = + this.options.igp.getContracts(chain).interchainGasPaymaster; + const igpBalance = await provider.getBalance(igpContract.address); + const claimThreshold = ethers.utils.parseEther(igpConfig.claimThreshold); + + this.options.metrics?.recordIgpBalance( + chain, + parseFloat(ethers.utils.formatEther(igpBalance)), + ); + + logger.info( + { + igpBalance: ethers.utils.formatEther(igpBalance), + claimThreshold: ethers.utils.formatEther(claimThreshold), + }, + 'Checking IGP balance', + ); + + if (igpBalance.gt(claimThreshold)) { + logger.info('IGP balance exceeds threshold, claiming'); + await this.multiProvider.sendTransaction( + chain, + await igpContract.populateTransaction.claim(), + ); + logger.info('IGP claim completed'); + } + } + + private async fundKeys( + chain: string, + keys: ResolvedKeyConfig[], + ): Promise { + for (const key of keys) { + await this.fundKey(chain, key); + } + } + + private async fundKey(chain: string, key: ResolvedKeyConfig): Promise { + const logger = this.options.logger.child({ + chain, + address: key.address, + role: key.role, + }); + + const desiredBalance = ethers.utils.parseEther(key.desiredBalance); + const fundingAmount = await this.calculateFundingAmount( + chain, + key.address, + desiredBalance, + ); + + const currentBalance = await this.multiProvider + .getProvider(chain) + .getBalance(key.address); + + this.options.metrics?.recordWalletBalance( + chain, + key.address, + key.role, + parseFloat(ethers.utils.formatEther(currentBalance)), + ); + + if (fundingAmount.eq(0)) { + logger.debug( + { currentBalance: ethers.utils.formatEther(currentBalance) }, + 'Key balance sufficient, skipping', + ); + return; + } + + const funderAddress = await this.multiProvider.getSignerAddress(chain); + const funderBalance = await this.multiProvider + .getSigner(chain) + .getBalance(); + + logger.info( + { + amount: ethers.utils.formatEther(fundingAmount), + currentBalance: ethers.utils.formatEther(currentBalance), + desiredBalance: ethers.utils.formatEther(desiredBalance), + funderAddress, + funderBalance: ethers.utils.formatEther(funderBalance), + }, + 'Funding key', + ); + + const tx = await this.multiProvider.sendTransaction(chain, { + to: key.address, + value: fundingAmount, + }); + + this.options.metrics?.recordFundingAmount( + chain, + key.address, + key.role, + parseFloat(ethers.utils.formatEther(fundingAmount)), + ); + + logger.info( + { + txHash: tx.transactionHash, + txUrl: this.multiProvider.tryGetExplorerTxUrl(chain, { + hash: tx.transactionHash, + }), + }, + 'Funding transaction completed', + ); + } + + private async calculateFundingAmount( + chain: string, + address: string, + desiredBalance: BigNumber, + ): Promise { + const currentBalance = await this.multiProvider + .getProvider(chain) + .getBalance(address); + if (currentBalance.gte(desiredBalance)) { + return BigNumber.from(0); + } + const delta = desiredBalance.sub(currentBalance); + const minDelta = desiredBalance + .mul(MIN_DELTA_NUMERATOR) + .div(MIN_DELTA_DENOMINATOR); + return delta.gt(minDelta) ? delta : BigNumber.from(0); + } + + private async sweepExcessFunds( + chain: string, + chainConfig: ChainConfig, + ): Promise { + const sweepConfig = chainConfig.sweep; + if (!sweepConfig?.enabled) { + return; + } + + const logger = this.options.logger.child({ chain, operation: 'sweep' }); + + if (!sweepConfig.address || !sweepConfig.threshold) { + throw new Error( + `Sweep config is invalid for chain ${chain}: address and threshold are required when sweep is enabled`, + ); + } + + const threshold = ethers.utils.parseEther(sweepConfig.threshold); + const targetBalance = calculateMultipliedBalance( + threshold, + sweepConfig.targetMultiplier, + ); + const triggerThreshold = calculateMultipliedBalance( + threshold, + sweepConfig.triggerMultiplier, + ); + + const funderBalance = await this.multiProvider + .getSigner(chain) + .getBalance(); + + logger.info( + { + funderBalance: ethers.utils.formatEther(funderBalance), + triggerThreshold: ethers.utils.formatEther(triggerThreshold), + targetBalance: ethers.utils.formatEther(targetBalance), + }, + 'Checking sweep conditions', + ); + + if (funderBalance.gt(triggerThreshold)) { + const sweepAmount = funderBalance.sub(targetBalance); + + logger.info( + { + sweepAmount: ethers.utils.formatEther(sweepAmount), + sweepAddress: sweepConfig.address, + }, + 'Sweeping excess funds', + ); + + const tx = await this.multiProvider.sendTransaction(chain, { + to: sweepConfig.address, + value: sweepAmount, + }); + + this.options.metrics?.recordSweepAmount( + chain, + parseFloat(ethers.utils.formatEther(sweepAmount)), + ); + + logger.info( + { + txHash: tx.transactionHash, + txUrl: this.multiProvider.tryGetExplorerTxUrl(chain, { + hash: tx.transactionHash, + }), + }, + 'Sweep completed', + ); + } else { + logger.debug('Funder balance below trigger threshold, no sweep needed'); + } + } +} + +/** + * Multiplies a BigNumber by a decimal multiplier with 2 decimal precision (floored). + * e.g., 1 ETH * 1.555 = 1.55 ETH (not 1.56 ETH) + */ +export function calculateMultipliedBalance( + base: BigNumber, + multiplier: number, +): BigNumber { + return base.mul(Math.floor(multiplier * 100)).div(100); +} + +function createTimeoutPromise( + timeoutMs: number, + errorMessage: string, +): { promise: Promise; cleanup: () => void } { + let timeoutId: NodeJS.Timeout; + const promise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(errorMessage)), timeoutMs); + }); + return { + promise, + cleanup: () => clearTimeout(timeoutId), + }; +} diff --git a/typescript/keyfunder/src/index.ts b/typescript/keyfunder/src/index.ts new file mode 100644 index 00000000000..6e194fb0176 --- /dev/null +++ b/typescript/keyfunder/src/index.ts @@ -0,0 +1,26 @@ +export { KeyFunderConfigLoader } from './config/KeyFunderConfig.js'; +export { + KeyFunderConfigSchema, + RoleConfigSchema, + IgpConfigSchema, + SweepConfigSchema, + ChainConfigSchema, + MetricsConfigSchema, +} from './config/types.js'; +export type { + KeyFunderConfig, + KeyFunderConfigInput, + RoleConfig, + IgpConfig, + SweepConfig, + ChainConfig, + MetricsConfig, + ResolvedKeyConfig, +} from './config/types.js'; + +export { + KeyFunder, + calculateMultipliedBalance, + type KeyFunderOptions, +} from './core/KeyFunder.js'; +export { KeyFunderMetrics } from './metrics/Metrics.js'; diff --git a/typescript/keyfunder/src/metrics/Metrics.test.ts b/typescript/keyfunder/src/metrics/Metrics.test.ts new file mode 100644 index 00000000000..6fcb0d37256 --- /dev/null +++ b/typescript/keyfunder/src/metrics/Metrics.test.ts @@ -0,0 +1,124 @@ +import { expect } from 'chai'; + +import { KeyFunderMetrics } from './Metrics.js'; + +describe('KeyFunderMetrics', () => { + describe('constructor', () => { + it('should create metrics without push gateway', () => { + const metrics = new KeyFunderMetrics(undefined); + expect(metrics.getRegistry()).to.not.be.undefined; + }); + + it('should create metrics with config', () => { + const metrics = new KeyFunderMetrics({ + jobName: 'test', + }); + expect(metrics.getRegistry()).to.not.be.undefined; + }); + + it('should include base labels in gauge configurations', () => { + const metrics = new KeyFunderMetrics( + { jobName: 'test' }, + { environment: 'testnet' }, + ); + expect(metrics.getRegistry()).to.not.be.undefined; + }); + }); + + describe('recordWalletBalance', () => { + it('should record wallet balance metric', async () => { + const metrics = new KeyFunderMetrics(undefined); + metrics.recordWalletBalance( + 'ethereum', + '0x1234567890123456789012345678901234567890', + 'relayer', + 1.5, + ); + + const metricsOutput = await metrics.getRegistry().metrics(); + expect(metricsOutput).to.include('hyperlane_keyfunder_wallet_balance'); + expect(metricsOutput).to.include('ethereum'); + expect(metricsOutput).to.include('relayer'); + }); + }); + + describe('recordFundingAmount', () => { + it('should record funding amount metric', async () => { + const metrics = new KeyFunderMetrics(undefined); + metrics.recordFundingAmount( + 'arbitrum', + '0x1234567890123456789012345678901234567890', + 'kathy', + 0.25, + ); + + const metricsOutput = await metrics.getRegistry().metrics(); + expect(metricsOutput).to.include('hyperlane_keyfunder_funding_amount'); + expect(metricsOutput).to.include('arbitrum'); + expect(metricsOutput).to.include('kathy'); + }); + }); + + describe('recordIgpBalance', () => { + it('should record IGP balance metric', async () => { + const metrics = new KeyFunderMetrics(undefined); + metrics.recordIgpBalance('polygon', 2.5); + + const metricsOutput = await metrics.getRegistry().metrics(); + expect(metricsOutput).to.include('hyperlane_keyfunder_igp_balance'); + expect(metricsOutput).to.include('polygon'); + }); + }); + + describe('recordSweepAmount', () => { + it('should record sweep amount metric', async () => { + const metrics = new KeyFunderMetrics(undefined); + metrics.recordSweepAmount('optimism', 5.0); + + const metricsOutput = await metrics.getRegistry().metrics(); + expect(metricsOutput).to.include('hyperlane_keyfunder_sweep_amount'); + expect(metricsOutput).to.include('optimism'); + }); + }); + + describe('recordOperationDuration', () => { + it('should record operation duration metric', async () => { + const metrics = new KeyFunderMetrics(undefined); + metrics.recordOperationDuration('base', 'fund', 3.14); + + const metricsOutput = await metrics.getRegistry().metrics(); + expect(metricsOutput).to.include( + 'hyperlane_keyfunder_operation_duration_seconds', + ); + expect(metricsOutput).to.include('base'); + expect(metricsOutput).to.include('fund'); + }); + }); + + describe('push', () => { + it('should not throw when no push gateway configured', async () => { + const metrics = new KeyFunderMetrics(undefined); + await metrics.push(); + }); + }); + + describe('with base labels', () => { + it('should include base labels in all metrics', async () => { + const metrics = new KeyFunderMetrics( + { jobName: 'keyfunder-test' }, + { environment: 'mainnet3', region: 'us-east' }, + ); + + metrics.recordWalletBalance( + 'ethereum', + '0x1234567890123456789012345678901234567890', + 'relayer', + 1.0, + ); + + const metricsOutput = await metrics.getRegistry().metrics(); + expect(metricsOutput).to.include('environment="mainnet3"'); + expect(metricsOutput).to.include('region="us-east"'); + }); + }); +}); diff --git a/typescript/keyfunder/src/metrics/Metrics.ts b/typescript/keyfunder/src/metrics/Metrics.ts new file mode 100644 index 00000000000..53470fa95e8 --- /dev/null +++ b/typescript/keyfunder/src/metrics/Metrics.ts @@ -0,0 +1,114 @@ +import { Gauge, Registry } from 'prom-client'; + +import { submitMetrics } from '@hyperlane-xyz/metrics'; + +import type { MetricsConfig } from '../config/types.js'; + +export class KeyFunderMetrics { + private registry: Registry; + private jobName: string; + + readonly walletBalanceGauge: Gauge; + readonly fundingAmountGauge: Gauge; + readonly igpBalanceGauge: Gauge; + readonly sweepAmountGauge: Gauge; + readonly operationDurationGauge: Gauge; + + constructor( + config: MetricsConfig | undefined, + private readonly baseLabels: Record = {}, + ) { + this.registry = new Registry(); + this.jobName = config?.jobName ?? 'keyfunder'; + + const labelNames = ['chain', 'address', 'role', ...Object.keys(baseLabels)]; + + this.walletBalanceGauge = new Gauge({ + name: 'hyperlane_keyfunder_wallet_balance', + help: 'Current wallet balance in native token', + labelNames, + registers: [this.registry], + }); + + this.fundingAmountGauge = new Gauge({ + name: 'hyperlane_keyfunder_funding_amount', + help: 'Amount funded to a key', + labelNames, + registers: [this.registry], + }); + + this.igpBalanceGauge = new Gauge({ + name: 'hyperlane_keyfunder_igp_balance', + help: 'IGP contract balance', + labelNames: ['chain', ...Object.keys(baseLabels)], + registers: [this.registry], + }); + + this.sweepAmountGauge = new Gauge({ + name: 'hyperlane_keyfunder_sweep_amount', + help: 'Amount swept to safe address', + labelNames: ['chain', ...Object.keys(baseLabels)], + registers: [this.registry], + }); + + this.operationDurationGauge = new Gauge({ + name: 'hyperlane_keyfunder_operation_duration_seconds', + help: 'Duration of funding operations', + labelNames: ['chain', 'operation', ...Object.keys(baseLabels)], + registers: [this.registry], + }); + } + + recordWalletBalance( + chain: string, + address: string, + role: string, + balance: number, + ): void { + this.walletBalanceGauge.set( + { chain, address, role, ...this.baseLabels }, + balance, + ); + } + + recordFundingAmount( + chain: string, + address: string, + role: string, + amount: number, + ): void { + this.fundingAmountGauge.set( + { chain, address, role, ...this.baseLabels }, + amount, + ); + } + + recordIgpBalance(chain: string, balance: number): void { + this.igpBalanceGauge.set({ chain, ...this.baseLabels }, balance); + } + + recordSweepAmount(chain: string, amount: number): void { + this.sweepAmountGauge.set({ chain, ...this.baseLabels }, amount); + } + + recordOperationDuration( + chain: string, + operation: string, + durationSeconds: number, + ): void { + this.operationDurationGauge.set( + { chain, operation, ...this.baseLabels }, + durationSeconds, + ); + } + + async push(): Promise { + await submitMetrics(this.registry, this.jobName, { + overwriteAllMetrics: true, + }); + } + + getRegistry(): Registry { + return this.registry; + } +} diff --git a/typescript/keyfunder/src/service.ts b/typescript/keyfunder/src/service.ts new file mode 100644 index 00000000000..7f39dfaaddb --- /dev/null +++ b/typescript/keyfunder/src/service.ts @@ -0,0 +1,139 @@ +#!/usr/bin/env node +import { Wallet } from 'ethers'; + +import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry'; +import { getRegistry } from '@hyperlane-xyz/registry/fs'; +import { ChainMetadata, HyperlaneIgp, MultiProvider } from '@hyperlane-xyz/sdk'; +import { createServiceLogger, rootLogger } from '@hyperlane-xyz/utils'; + +import { KeyFunderConfigLoader } from './config/KeyFunderConfig.js'; +import { KeyFunder } from './core/KeyFunder.js'; +import { KeyFunderMetrics } from './metrics/Metrics.js'; + +async function main(): Promise { + const VERSION = process.env.SERVICE_VERSION || 'dev'; + + const configFile = process.env.KEYFUNDER_CONFIG_FILE; + if (!configFile) { + rootLogger.error('KEYFUNDER_CONFIG_FILE environment variable is required'); + process.exit(1); + } + + const privateKey = process.env.HYP_KEY; + if (!privateKey) { + rootLogger.error('HYP_KEY environment variable is required'); + process.exit(1); + } + + const logger = await createServiceLogger({ + service: 'keyfunder', + version: VERSION, + }); + + logger.info( + { version: VERSION, configFile }, + 'Starting Hyperlane KeyFunder Service', + ); + + try { + const configLoader = KeyFunderConfigLoader.load(configFile); + const config = configLoader.config; + const configuredChains = configLoader.getConfiguredChains(); + logger.info({ chains: configuredChains }, 'Loaded keyfunder configuration'); + + const registryUri = process.env.REGISTRY_URI || DEFAULT_GITHUB_REGISTRY; + const registry = getRegistry({ + registryUris: [registryUri], + enableProxy: true, + logger: rootLogger, + }); + logger.info({ registryUri }, 'Initialized registry'); + + const chainMetadata = await registry.getMetadata(); + applyRpcOverrides(chainMetadata, configuredChains); + logger.info( + `Loaded metadata for ${Object.keys(chainMetadata).length} chains`, + ); + + const multiProvider = new MultiProvider(chainMetadata); + const signer = new Wallet(privateKey); + multiProvider.setSharedSigner(signer); + logger.info('Initialized MultiProvider with signer'); + + let igp: HyperlaneIgp | undefined; + const igpEntries = Object.entries(config.chains) + .filter(([, cfg]) => cfg.igp) + .map(([chain, cfg]) => [ + chain, + { interchainGasPaymaster: cfg.igp!.address }, + ]); + + if (igpEntries.length > 0) { + const igpAddresses = Object.fromEntries(igpEntries); + igp = HyperlaneIgp.fromAddressesMap(igpAddresses, multiProvider); + logger.info( + { chains: Object.keys(igpAddresses) }, + 'Initialized IGP contracts', + ); + } + + const metrics = new KeyFunderMetrics( + config.metrics, + config.metrics?.labels, + ); + + const funder = new KeyFunder(multiProvider, config, { + logger, + metrics, + skipIgpClaim: process.env.SKIP_IGP_CLAIM === 'true', + igp, + }); + + let fundingError: Error | undefined; + try { + await funder.fundAllChains(); + } catch (error) { + fundingError = error as Error; + } + + // Always push metrics, even on failure (matches original fund-keys-from-deployer.ts behavior) + await metrics.push(); + logger.info('Metrics pushed to gateway'); + + if (fundingError) { + throw fundingError; + } + + logger.info('KeyFunder completed successfully'); + process.exit(0); + } catch (error) { + const err = error as Error; + logger.error({ error: err.message, stack: err.stack }, 'KeyFunder failed'); + process.exit(1); + } +} + +function applyRpcOverrides( + chainMetadata: Record>, + configuredChains: string[], +): void { + for (const chain of configuredChains) { + const envVarName = `RPC_URL_${chain.toUpperCase().replace(/-/g, '_')}`; + const rpcUrl = process.env[envVarName]; + if (rpcUrl && chainMetadata[chain]) { + rootLogger.debug( + { chain, envVarName }, + 'Using RPC from environment variable', + ); + chainMetadata[chain].rpcUrls = [ + { http: rpcUrl }, + ] as ChainMetadata['rpcUrls']; + } + } +} + +main().catch((error) => { + const err = error as Error; + rootLogger.error({ error: err.message, stack: err.stack }, 'Fatal error'); + process.exit(1); +}); diff --git a/typescript/keyfunder/tsconfig.json b/typescript/keyfunder/tsconfig.json new file mode 100644 index 00000000000..9005365a9b7 --- /dev/null +++ b/typescript/keyfunder/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@hyperlane-xyz/tsconfig/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src/**/*"] +} diff --git a/typescript/keyfunder/turbo.json b/typescript/keyfunder/turbo.json new file mode 100644 index 00000000000..eda75172045 --- /dev/null +++ b/typescript/keyfunder/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "bundle": { + "dependsOn": ["build"], + "outputs": ["bundle/**"] + } + } +}