diff --git a/typescript/infra/config/environments/mainnet/funding.ts b/typescript/infra/config/environments/mainnet/funding.ts index e2425efed..9608375d5 100644 --- a/typescript/infra/config/environments/mainnet/funding.ts +++ b/typescript/infra/config/environments/mainnet/funding.ts @@ -1,9 +1,10 @@ -import { RelayerFunderConfig } from '../../../src/config/funding'; +import { KEY_ROLE_ENUM } from '../../../src/agents/roles'; +import { KeyFunderConfig } from '../../../src/config/funding'; import { Contexts } from '../../contexts'; import { environment } from './chains'; -export const relayerFunderConfig: RelayerFunderConfig = { +export const keyFunderConfig: KeyFunderConfig = { docker: { repo: 'gcr.io/abacus-labs-dev/abacus-monorepo', tag: 'sha-d24eaa4', @@ -13,5 +14,7 @@ export const relayerFunderConfig: RelayerFunderConfig = { prometheusPushGateway: 'http://prometheus-pushgateway.monitoring.svc.cluster.local:9091', contextFundingFrom: Contexts.Abacus, - contextsToFund: [Contexts.Abacus], + contextsAndRolesToFund: { + [Contexts.Abacus]: [KEY_ROLE_ENUM.Relayer], + }, }; diff --git a/typescript/infra/config/environments/mainnet/index.ts b/typescript/infra/config/environments/mainnet/index.ts index 7773c991d..8eff4d2fe 100644 --- a/typescript/infra/config/environments/mainnet/index.ts +++ b/typescript/infra/config/environments/mainnet/index.ts @@ -9,7 +9,7 @@ import { mainnetConfigs, } from './chains'; import { core } from './core'; -import { relayerFunderConfig } from './funding'; +import { keyFunderConfig } from './funding'; import { helloWorld } from './helloworld'; import { infrastructure } from './infrastructure'; @@ -22,5 +22,5 @@ export const environment: CoreEnvironmentConfig = { core, infra: infrastructure, helloWorld, - relayerFunderConfig, + keyFunderConfig, }; diff --git a/typescript/infra/config/environments/testnet2/funding.ts b/typescript/infra/config/environments/testnet2/funding.ts index e2425efed..9608375d5 100644 --- a/typescript/infra/config/environments/testnet2/funding.ts +++ b/typescript/infra/config/environments/testnet2/funding.ts @@ -1,9 +1,10 @@ -import { RelayerFunderConfig } from '../../../src/config/funding'; +import { KEY_ROLE_ENUM } from '../../../src/agents/roles'; +import { KeyFunderConfig } from '../../../src/config/funding'; import { Contexts } from '../../contexts'; import { environment } from './chains'; -export const relayerFunderConfig: RelayerFunderConfig = { +export const keyFunderConfig: KeyFunderConfig = { docker: { repo: 'gcr.io/abacus-labs-dev/abacus-monorepo', tag: 'sha-d24eaa4', @@ -13,5 +14,7 @@ export const relayerFunderConfig: RelayerFunderConfig = { prometheusPushGateway: 'http://prometheus-pushgateway.monitoring.svc.cluster.local:9091', contextFundingFrom: Contexts.Abacus, - contextsToFund: [Contexts.Abacus], + contextsAndRolesToFund: { + [Contexts.Abacus]: [KEY_ROLE_ENUM.Relayer], + }, }; diff --git a/typescript/infra/config/environments/testnet2/index.ts b/typescript/infra/config/environments/testnet2/index.ts index abb8144ee..1b5bc633f 100644 --- a/typescript/infra/config/environments/testnet2/index.ts +++ b/typescript/infra/config/environments/testnet2/index.ts @@ -9,7 +9,7 @@ import { testnetConfigs, } from './chains'; import { core } from './core'; -import { relayerFunderConfig } from './funding'; +import { keyFunderConfig } from './funding'; import { helloWorld } from './helloworld'; import { infrastructure } from './infrastructure'; @@ -22,5 +22,5 @@ export const environment: CoreEnvironmentConfig = { core, infra: infrastructure, helloWorld, - relayerFunderConfig, + keyFunderConfig, }; diff --git a/typescript/infra/helm/relayer-funder/Chart.yaml b/typescript/infra/helm/key-funder/Chart.yaml similarity index 98% rename from typescript/infra/helm/relayer-funder/Chart.yaml rename to typescript/infra/helm/key-funder/Chart.yaml index 0c7ddb156..f86da7739 100644 --- a/typescript/infra/helm/relayer-funder/Chart.yaml +++ b/typescript/infra/helm/key-funder/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -name: relayer-funder +name: key-funder description: Relayer funder # A chart can be either an 'application' or a 'library' chart. diff --git a/typescript/infra/helm/relayer-funder/templates/_helpers.tpl b/typescript/infra/helm/key-funder/templates/_helpers.tpl similarity index 100% rename from typescript/infra/helm/relayer-funder/templates/_helpers.tpl rename to typescript/infra/helm/key-funder/templates/_helpers.tpl diff --git a/typescript/infra/helm/relayer-funder/templates/addresses-external-secret.yaml b/typescript/infra/helm/key-funder/templates/addresses-external-secret.yaml similarity index 56% rename from typescript/infra/helm/relayer-funder/templates/addresses-external-secret.yaml rename to typescript/infra/helm/key-funder/templates/addresses-external-secret.yaml index 61c207e14..22eef58ac 100644 --- a/typescript/infra/helm/relayer-funder/templates/addresses-external-secret.yaml +++ b/typescript/infra/helm/key-funder/templates/addresses-external-secret.yaml @@ -1,7 +1,7 @@ apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: - name: relayer-funder-addresses-external-secret + name: key-funder-addresses-external-secret labels: {{- include "abacus.labels" . | nindent 4 }} spec: @@ -11,7 +11,7 @@ spec: refreshInterval: "1h" # The secret that will be created target: - name: relayer-funder-addresses-secret + name: key-funder-addresses-secret template: type: Opaque metadata: @@ -20,12 +20,12 @@ spec: annotations: update-on-redeploy: "{{ now }}" data: -{{- range .Values.abacus.contextsToFund }} - {{ . }}-addresses.json: {{ printf "'{{ .%s_addresses | toString }}'" . }} +{{- range $context, $roles := .Values.abacus.contextsAndRoles }} + {{ $context }}-addresses.json: {{ printf "'{{ .%s_addresses | toString }}'" $context }} {{- end }} data: -{{- range .Values.abacus.contextsToFund }} - - secretKey: {{ . }}_addresses +{{- range $context, $roles := .Values.abacus.contextsAndRoles }} + - secretKey: {{ $context }}_addresses remoteRef: - key: {{ printf "%s-%s-key-addresses" . $.Values.abacus.runEnv }} + key: {{ printf "%s-%s-key-addresses" $context $.Values.abacus.runEnv }} {{- end }} diff --git a/typescript/infra/helm/relayer-funder/templates/cron-job.yaml b/typescript/infra/helm/key-funder/templates/cron-job.yaml similarity index 71% rename from typescript/infra/helm/relayer-funder/templates/cron-job.yaml rename to typescript/infra/helm/key-funder/templates/cron-job.yaml index 2439d32fc..85e16e0fe 100644 --- a/typescript/infra/helm/relayer-funder/templates/cron-job.yaml +++ b/typescript/infra/helm/key-funder/templates/cron-job.yaml @@ -1,7 +1,7 @@ apiVersion: batch/v1 kind: CronJob metadata: - name: relayer-funder + name: key-funder spec: schedule: "{{ .Values.cronjob.schedule }}" successfulJobsHistoryLimit: {{ .Values.cronjob.successfulJobsHistoryLimit }} @@ -14,7 +14,7 @@ spec: spec: restartPolicy: Never containers: - - name: relayer-funder + - name: key-funder image: {{ .Values.image.repository }}:{{ .Values.image.tag }} imagePullPolicy: IfNotPresent command: @@ -24,21 +24,23 @@ spec: - {{ .Values.abacus.runEnv }} - --context - {{ .Values.abacus.contextFundingFrom }} -{{- range .Values.abacus.contextsToFund }} +{{- range $context, $roles := .Values.abacus.contextsAndRoles }} + - --contexts-and-roles + - {{ $context }}={{ join "," $roles }} - -f - - /addresses-secret/{{ . }}-addresses.json + - /addresses-secret/{{ $context }}-addresses.json {{- end }} env: - name: PROMETHEUS_PUSH_GATEWAY value: {{ .Values.infra.prometheusPushGateway }} envFrom: - secretRef: - name: relayer-funder-env-var-secret + name: key-funder-env-var-secret volumeMounts: - - name: relayer-funder-addresses-secret + - name: key-funder-addresses-secret mountPath: /addresses-secret volumes: - - name: relayer-funder-addresses-secret + - name: key-funder-addresses-secret secret: - secretName: relayer-funder-addresses-secret + secretName: key-funder-addresses-secret defaultMode: 0400 diff --git a/typescript/infra/helm/relayer-funder/templates/env-var-external-secret.yaml b/typescript/infra/helm/key-funder/templates/env-var-external-secret.yaml similarity index 94% rename from typescript/infra/helm/relayer-funder/templates/env-var-external-secret.yaml rename to typescript/infra/helm/key-funder/templates/env-var-external-secret.yaml index 51e9259cf..6db4ff01e 100644 --- a/typescript/infra/helm/relayer-funder/templates/env-var-external-secret.yaml +++ b/typescript/infra/helm/key-funder/templates/env-var-external-secret.yaml @@ -1,7 +1,7 @@ apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: - name: relayer-funder-env-var-external-secret + name: key-funder-env-var-external-secret labels: {{- include "abacus.labels" . | nindent 4 }} spec: @@ -11,7 +11,7 @@ spec: refreshInterval: "1h" # The secret that will be created target: - name: relayer-funder-env-var-secret + name: key-funder-env-var-secret template: type: Opaque metadata: diff --git a/typescript/infra/helm/relayer-funder/values.yaml b/typescript/infra/helm/key-funder/values.yaml similarity index 76% rename from typescript/infra/helm/relayer-funder/values.yaml rename to typescript/infra/helm/key-funder/values.yaml index 5c6fa5251..78ae14f8b 100644 --- a/typescript/infra/helm/relayer-funder/values.yaml +++ b/typescript/infra/helm/key-funder/values.yaml @@ -6,8 +6,10 @@ abacus: # Used for fetching secrets chains: [] contextFundingFrom: abacus - contextsToFund: - - abacus + # key = context, value = array of roles to fund + contextsAndRoles: + abacus: + - relayer cronjob: schedule: "*/10 * * * *" # Every 10 minutes successfulJobsHistoryLimit: 1 diff --git a/typescript/infra/scripts/funding/deploy-relayer-funder.ts b/typescript/infra/scripts/funding/deploy-key-funder.ts similarity index 67% rename from typescript/infra/scripts/funding/deploy-relayer-funder.ts rename to typescript/infra/scripts/funding/deploy-key-funder.ts index 745ad6690..6bdba63ec 100644 --- a/typescript/infra/scripts/funding/deploy-relayer-funder.ts +++ b/typescript/infra/scripts/funding/deploy-key-funder.ts @@ -1,7 +1,7 @@ import { - getRelayerFunderConfig, - runRelayerFunderHelmCommand, -} from '../../src/funding/deploy-relayer-funder'; + getKeyFunderConfig, + runKeyFunderHelmCommand, +} from '../../src/funding/deploy-key-funder'; import { HelmCommand } from '../../src/utils/helm'; import { assertCorrectKubeContext, @@ -14,13 +14,13 @@ async function main() { await assertCorrectKubeContext(coreConfig); - const relayerFunderConfig = getRelayerFunderConfig(coreConfig); + const keyFunderConfig = getKeyFunderConfig(coreConfig); const agentConfig = await getContextAgentConfig(coreConfig); - await runRelayerFunderHelmCommand( + await runKeyFunderHelmCommand( HelmCommand.InstallOrUpgrade, agentConfig, - relayerFunderConfig, + keyFunderConfig, ); } diff --git a/typescript/infra/scripts/funding/fund-keys-from-deployer.ts b/typescript/infra/scripts/funding/fund-keys-from-deployer.ts new file mode 100644 index 000000000..7d2788371 --- /dev/null +++ b/typescript/infra/scripts/funding/fund-keys-from-deployer.ts @@ -0,0 +1,471 @@ +import { ethers } from 'ethers'; +import { Gauge, Registry } from 'prom-client'; +import { format } from 'util'; + +import { + ChainConnection, + ChainName, + CompleteChainMap, + MultiProvider, +} from '@abacus-network/sdk'; +import { error, log } from '@abacus-network/utils'; + +import { Contexts } from '../../config/contexts'; +import { AgentKey, ReadOnlyAgentKey } from '../../src/agents/agent'; +import { getAllKeys } from '../../src/agents/key-utils'; +import { KEY_ROLE_ENUM } from '../../src/agents/roles'; +import { ContextAndRoles, ContextAndRolesMap } from '../../src/config/funding'; +import { submitMetrics } from '../../src/utils/metrics'; +import { + assertContext, + assertRole, + readJSONAtPath, +} from '../../src/utils/utils'; +import { + assertEnvironment, + getAgentConfig, + getArgs, + getCoreEnvironmentConfig, +} from '../utils'; + +const constMetricLabels = { + // this needs to get set in main because of async reasons + abacus_deployment: '', + abacus_context: 'abacus', +}; + +const metricsRegister = new Registry(); + +const walletBalanceGauge = new Gauge({ + // Mirror the rust/ethers-prometheus `wallet_balance` gauge metric. + name: 'abacus_wallet_balance', + help: 'Current balance of eth and other tokens in the `tokens` map for the wallet addresses in the `wallets` set', + registers: [metricsRegister], + labelNames: [ + 'chain', + 'wallet_address', + 'wallet_name', + 'token_address', + 'token_symbol', + 'token_name', + ...(Object.keys(constMetricLabels) as (keyof typeof constMetricLabels)[]), + ], +}); +metricsRegister.registerMetric(walletBalanceGauge); + +// Min delta is 1/10 of the desired balance +const MIN_DELTA_NUMERATOR = ethers.BigNumber.from(1); +const MIN_DELTA_DENOMINATOR = ethers.BigNumber.from(10); + +const desiredBalancePerChain: CompleteChainMap = { + celo: '0.1', + alfajores: '1', + avalanche: '0.1', + fuji: '1', + ethereum: '0.2', + kovan: '0.1', + polygon: '1', + mumbai: '0.5', + optimism: '0.05', + optimismkovan: '0.1', + arbitrum: '0.01', + arbitrumrinkeby: '0.1', + bsc: '0.01', + bsctestnet: '1', + // unused + goerli: '0', + auroratestnet: '0', + test1: '0', + test2: '0', + test3: '0', +}; + +// Funds key addresses for multiple contexts from the deployer key of the context +// specified via the `--context` flag. +// The --contexts-and-roles flag is used to specify the contexts and the key roles +// for each context to fund. +// There are two ways to configure this script so that key addresses are known. +// You can pass in files using `-f`, which are expected to each be JSON arrays of objects +// of the form { identifier: '..', address: '..' }, where the keys described in one file +// are all for the same context. This will avoid requiring any sort of GCP/AWS credentials for +// fetching addresses from the keys themselves. A file for each context specified in --contexts-and-roles +// must be provided +// If the -f flag is not provided, addresses will be read directly from GCP/AWS for each +// context provided in --contexts-and-roles, which requires the appropriate credentials. +// +// Example usage: +// ts-node ./scripts/funding/fund-keys-from-deployer.ts -e testnet2 --context abacus --contexts-and-roles abacus=relayer +async function main() { + const argv = await getArgs() + .string('f') + .array('f') + .alias('f', 'address-files') + .describe( + 'f', + 'Files each containing JSON arrays of identifier and address objects for a single context. If not specified, key addresses are fetched from GCP/AWS and require sufficient credentials.', + ) + .string('contexts-and-roles') + .array('contexts-and-roles') + .describe( + 'contexts-and-roles', + 'Array indicating contexts and the roles to fund for each context. Each element is expected as =,,...', + ) + .coerce('contexts-and-roles', parseContextAndRolesMap) + .demandOption('contexts-and-roles').argv; + + const environment = assertEnvironment(argv.e as string); + constMetricLabels.abacus_deployment = environment; + const config = getCoreEnvironmentConfig(environment); + const multiProvider = await config.getMultiProvider(); + + let contextFunders: ContextFunder[]; + + if (argv.f) { + contextFunders = argv.f.map((path) => + ContextFunder.fromSerializedAddressFile( + multiProvider, + path, + argv.contextsAndRoles, + ), + ); + } else { + contextFunders = []; + const contexts = Object.keys(argv.contextsAndRoles) as Contexts[]; + contextFunders = await Promise.all( + contexts.map((context) => + ContextFunder.fromContext( + multiProvider, + context, + argv.contextsAndRoles[context]!, + ), + ), + ); + } + + let failureOccurred = false; + for (const funder of contextFunders) { + const failure = await funder.fund(); + if (failure) { + failureOccurred = true; + } + } + + await submitMetrics(metricsRegister, 'key-funder'); + + if (failureOccurred) { + error('At least one failure occurred when funding'); + process.exit(1); + } +} + +// Funds keys for a single context +class ContextFunder { + public readonly chains: ChainName[]; + + constructor( + public readonly multiProvider: MultiProvider, + public readonly keys: AgentKey[], + public readonly context: Contexts, + public readonly rolesToFund: KEY_ROLE_ENUM[], + ) { + const uniqueChains = new Set( + keys.map((key) => key.chainName!).filter((chain) => chain !== undefined), + ); + + this.chains = Array.from(uniqueChains); + } + + static fromSerializedAddressFile( + multiProvider: MultiProvider, + path: string, + contextsAndRolesToFund: ContextAndRolesMap, + ) { + log('Reading identifiers and addresses from file', { + path, + }); + const idsAndAddresses = readJSONAtPath(path); + const keys: AgentKey[] = idsAndAddresses.map((idAndAddress: any) => + ReadOnlyAgentKey.fromSerializedAddress( + idAndAddress.identifier, + idAndAddress.address, + ), + ); + + const context = keys[0].context; + // Ensure all keys have the same context, just to be safe + for (const key of keys) { + if (key.context !== context) { + throw Error( + `Expected all keys at path ${path} to have context ${context}, found ${key.context}`, + ); + } + } + + const rolesToFund = contextsAndRolesToFund[context]; + if (!rolesToFund) { + throw Error( + `Expected context ${context} to be defined in contextsAndRolesToFund`, + ); + } + + log('Read keys for context from file', { + path, + keyCount: keys.length, + context, + }); + + return new ContextFunder(multiProvider, keys, context, rolesToFund); + } + + // The keys here are not ReadOnlyAgentKeys, instead they are AgentGCPKey or AgentAWSKeys, + // which require credentials to fetch. If you want to avoid requiring credentials, use + // fromSerializedAddressFile instead. + static async fromContext( + multiProvider: MultiProvider, + context: Contexts, + rolesToFund: KEY_ROLE_ENUM[], + ) { + const agentConfig = await getAgentConfig(context); + return new ContextFunder( + multiProvider, + getAllKeys(agentConfig), + context, + rolesToFund, + ); + } + + // Funds all the roles in this.rolesToFund + // Returns whether a failure occurred. + async fund(): Promise { + let failureOccurred = false; + + for (const role of this.rolesToFund) { + const failure = + role === KEY_ROLE_ENUM.Relayer + ? await this.fundRelayersOnAllRequiredChains() + : await this.fundNonRelayerKeysOnAllChains(role); + if (failure) { + failureOccurred = true; + } + } + return failureOccurred; + } + + // Returns whether a failure occurred. + private async fundNonRelayerKeysOnAllChains( + roleToFund: KEY_ROLE_ENUM, + ): Promise { + let failureOccurred = false; + + const keys = this.getKeysWithRole(roleToFund); + + for (const chain of this.chains) { + for (const key of keys) { + const failure = await this.attemptToFundKey(key, chain); + if (failure) { + failureOccurred = true; + } + } + } + return failureOccurred; + } + + // Funds the relayers on all the chains found in `this.chains`. + // Does not fund a relayer key on its outbox chain. + // Returns whether a failure occurred. + private async fundRelayersOnAllRequiredChains(): Promise { + let failureOccurred = false; + + const keys = this.getKeysWithRole(KEY_ROLE_ENUM.Relayer); + + for (const chain of this.chains) { + for (const key of keys.filter((k) => k.chainName !== chain)) { + const failure = await this.attemptToFundKey(key, chain); + if (failure) { + failureOccurred = true; + } + } + } + return failureOccurred; + } + + private async attemptToFundKey( + key: AgentKey, + chain: ChainName, + ): Promise { + const chainConnection = this.multiProvider.getChainConnection(chain); + const desiredBalance = desiredBalancePerChain[chain]; + + let failureOccurred = false; + + // Some types of keys must be fetched + await key.fetch(); + + try { + await this.fundKeyIfRequired(chainConnection, chain, key, desiredBalance); + } catch (err) { + error('Error funding key', { + key: getKeyInfo(key), + context: this.context, + error: err, + }); + failureOccurred = true; + } + await this.updateWalletBalanceGauge(chainConnection, chain); + + return failureOccurred; + } + + // Tops up the key's balance to the desired balance if the current balance + // is lower than the desired balance by the min delta + private async fundKeyIfRequired( + chainConnection: ChainConnection, + chain: ChainName, + key: AgentKey, + desiredBalance: string, + ) { + const currentBalance = await chainConnection.provider.getBalance( + key.address, + ); + const desiredBalanceEther = ethers.utils.parseUnits( + desiredBalance, + 'ether', + ); + const delta = desiredBalanceEther.sub(currentBalance); + + const minDelta = desiredBalanceEther + .mul(MIN_DELTA_NUMERATOR) + .div(MIN_DELTA_DENOMINATOR); + + const keyInfo = getKeyInfo(key); + + log('Assessing key for funding', { + key: keyInfo, + keyBalanceDelta: ethers.utils.formatEther(delta), + minKeyBalanceDelta: ethers.utils.formatEther(minDelta), + currentKeyBalance: ethers.utils.formatEther(currentBalance), + desiredKeyBalance: desiredBalance, + funder: { + address: await chainConnection.getAddress(), + balance: ethers.utils.formatEther( + await chainConnection.signer!.getBalance(), + ), + }, + context: this.context, + chain, + }); + + if (delta.gt(minDelta)) { + log('Sending funds...', { + key: keyInfo, + amount: ethers.utils.formatEther(delta), + context: this.context, + chain, + }); + + const tx = await chainConnection.signer!.sendTransaction({ + to: key.address, + value: delta, + ...chainConnection.overrides, + }); + log('Sent transaction', { + key: keyInfo, + txUrl: chainConnection.getTxUrl(tx), + context: this.context, + chain, + }); + const receipt = await tx.wait(chainConnection.confirmations); + log('Got transaction receipt', { + key: keyInfo, + receipt, + context: this.context, + chain, + }); + } + } + + private async updateWalletBalanceGauge( + chainConnection: ChainConnection, + chain: ChainName, + ) { + const funderAddress = await chainConnection.getAddress(); + walletBalanceGauge + .labels({ + chain, + wallet_address: funderAddress ?? 'unknown', + wallet_name: 'key-funder', + token_symbol: 'Native', + token_name: 'Native', + ...constMetricLabels, + }) + .set( + parseFloat( + ethers.utils.formatEther(await chainConnection.signer!.getBalance()), + ), + ); + } + + private getKeysWithRole(role: KEY_ROLE_ENUM) { + return this.keys.filter((k) => k.role === role); + } +} + +function getKeyInfo(key: AgentKey) { + return { + context: key.context, + address: key.address, + identifier: key.identifier, + originChain: key.chainName, + role: key.role, + index: key.index, + }; +} + +function parseContextAndRolesMap(strs: string[]): ContextAndRolesMap { + const contextsAndRoles = strs.map(parseContextAndRoles); + return contextsAndRoles.reduce( + (prev, curr) => ({ + ...prev, + [curr.context]: curr.roles, + }), + {}, + ); +} + +// Parses strings of the form =,,... +// e.g.: +// abacus=relayer +// flowcarbon=relayer,kathy +function parseContextAndRoles(str: string): ContextAndRoles { + const [contextStr, rolesStr] = str.split('='); + const context = assertContext(contextStr); + + const roles = rolesStr.split(',').map(assertRole); + if (roles.length === 0) { + throw Error('Expected > 0 roles'); + } + + // For now, restrict the valid roles we think are reasonable to want to fund + const validRoles = new Set([KEY_ROLE_ENUM.Relayer, KEY_ROLE_ENUM.Kathy]); + for (const role of roles) { + if (!validRoles.has(role)) { + throw Error( + `Invalid role ${role}, must be one of ${Array.from(validRoles)}`, + ); + } + } + + return { + context, + roles, + }; +} + +main().catch((err) => { + error('Error occurred in main', { + // JSON.stringifying an Error returns '{}'. + // This is a workaround from https://stackoverflow.com/a/60370781 + error: format(err), + }); + process.exit(1); +}); diff --git a/typescript/infra/scripts/funding/fund-relayers-from-deployer.ts b/typescript/infra/scripts/funding/fund-relayers-from-deployer.ts deleted file mode 100644 index cffece150..000000000 --- a/typescript/infra/scripts/funding/fund-relayers-from-deployer.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { ethers } from 'ethers'; -import { Gauge, Registry } from 'prom-client'; -import { format } from 'util'; - -import { - ChainConnection, - ChainName, - CompleteChainMap, - MultiProvider, -} from '@abacus-network/sdk'; -import { error, log } from '@abacus-network/utils'; - -import { Contexts } from '../../config/contexts'; -import { AgentKey, ReadOnlyAgentKey } from '../../src/agents/agent'; -import { getRelayerKeys } from '../../src/agents/key-utils'; -import { KEY_ROLE_ENUM } from '../../src/agents/roles'; -import { submitMetrics } from '../../src/utils/metrics'; -import { assertContext, readJSONAtPath } from '../../src/utils/utils'; -import { - assertEnvironment, - getAgentConfig, - getArgs, - getCoreEnvironmentConfig, -} from '../utils'; - -const constMetricLabels = { - // this needs to get set in main because of async reasons - abacus_deployment: '', - abacus_context: 'abacus', -}; - -const metricsRegister = new Registry(); - -const walletBalanceGauge = new Gauge({ - // Mirror the rust/ethers-prometheus `wallet_balance` gauge metric. - name: 'abacus_wallet_balance', - help: 'Current balance of eth and other tokens in the `tokens` map for the wallet addresses in the `wallets` set', - registers: [metricsRegister], - labelNames: [ - 'chain', - 'wallet_address', - 'wallet_name', - 'token_address', - 'token_symbol', - 'token_name', - ...(Object.keys(constMetricLabels) as (keyof typeof constMetricLabels)[]), - ], -}); -metricsRegister.registerMetric(walletBalanceGauge); - -// Min delta is 1/10 of the desired balance -const MIN_DELTA_NUMERATOR = ethers.BigNumber.from(1); -const MIN_DELTA_DENOMINATOR = ethers.BigNumber.from(10); - -const desiredBalancePerChain: CompleteChainMap = { - celo: '0.1', - alfajores: '1', - avalanche: '0.1', - fuji: '1', - ethereum: '0.2', - kovan: '0.1', - polygon: '1', - mumbai: '0.5', - optimism: '0.05', - optimismkovan: '0.1', - arbitrum: '0.01', - arbitrumrinkeby: '0.1', - bsc: '0.01', - bsctestnet: '1', - // unused - goerli: '0', - auroratestnet: '0', - test1: '0', - test2: '0', - test3: '0', -}; - -// Funds relayer addresses for multiple contexts from the deployer key of the context -// specified via the `--context` flag. -// There are two ways to configure this script so that relayer addresses are known. -// You can pass in files using `-f`, which are expected to each be JSON arrays of objects -// of the form { identifier: '..', address: '..' }, where the keys described in one file -// are all for the same context. This will avoid requiring any sort of GCP/AWS credentials for -// fetching addresses from the keys themselves. -// Alternatively, using `--contexts-to-fund` will fetch relayer addresses from GCP/AWS, which -// requires credentials. -async function main() { - const argv = await getArgs() - .string('f') - .array('f') - .alias('f', 'address-files') - .describe( - 'f', - 'Files each containing JSON arrays of identifier and address objects for a context', - ) - .string('contexts-to-fund') - .array('contexts-to-fund') - .describe( - 'contexts-to-fund', - 'Contexts to fund relayers for. If specified, relayer addresses are fetched from GCP/AWS and require sufficient credentials.', - ) - .coerce('contexts-to-fund', (contexts: string[]) => { - return contexts.map(assertContext); - }) - // Only one of the two methods for getting relayer addresses - .conflicts('f', 'contexts-to-fund').argv; - - const environment = assertEnvironment(argv.e as string); - constMetricLabels.abacus_deployment = environment; - const config = getCoreEnvironmentConfig(environment); - const multiProvider = await config.getMultiProvider(); - - const contextRelayerFunders = argv.f - ? argv.f.map((path) => - ContextRelayerFunder.fromSerializedAddressFile(multiProvider, path), - ) - : argv.contextsToFund!.map((context) => - ContextRelayerFunder.fromSerializedAddressFile(multiProvider, context), - ); - - let failureOccurred = false; - for (const relayerFunder of contextRelayerFunders) { - const failure = await relayerFunder.fundRelayersOnAllChains(); - if (failure) { - failureOccurred = true; - } - } - - await submitMetrics(metricsRegister, 'relayer-funder'); - - if (failureOccurred) { - error('At least one failure occurred when funding relayers'); - process.exit(1); - } -} - -// Funds relayers for a single context -class ContextRelayerFunder { - public readonly chains: ChainName[]; - - constructor( - public readonly multiProvider: MultiProvider, - public readonly keys: AgentKey[], - public readonly context: Contexts, - ) { - this.chains = keys.map((key) => key.chainName!); - } - - static fromSerializedAddressFile( - multiProvider: MultiProvider, - path: string, - ) { - log('Reading identifiers and addresses from file', { - path, - }); - const idsAndAddresses = readJSONAtPath(path); - const keys: AgentKey[] = idsAndAddresses - .map((idAndAddress: any) => - ReadOnlyAgentKey.fromSerializedAddress( - idAndAddress.identifier, - idAndAddress.address, - ), - ) - .filter((key: AgentKey) => key.role === KEY_ROLE_ENUM.Relayer); - - const context = keys[0].context; - // Ensure all keys have the same context, just to be safe - keys.forEach((key) => { - if (key.context !== context) { - throw Error( - `Expected all keys at path ${path} to have context ${context}, found ${key.context}`, - ); - } - }); - - log('Read keys for context from file', { - path, - keyCount: keys.length, - context, - }); - - return new ContextRelayerFunder(multiProvider, keys, context); - } - - // The keys here are not ReadOnlyAgentKeys, instead they are AgentGCPKey or AgentAWSKeys, - // which require credentials to fetch. If you want to avoid requiring credentials, use - // fromSerializedAddressFile instead. - static async fromContext( - multiProvider: MultiProvider, - context: Contexts, - ) { - const agentConfig = await getAgentConfig(context); - return new ContextRelayerFunder( - multiProvider, - getRelayerKeys(agentConfig), - context, - ); - } - - // Funds the relayers on all the chains found in `this.chains` - async fundRelayersOnAllChains() { - let failureOccurred = false; - - for (const chain of this.chains) { - const chainConnection = this.multiProvider.getChainConnection(chain); - - const desiredBalance = desiredBalancePerChain[chain]; - const funderAddress = await chainConnection.getAddress(); - - log('Funding relayers on chain...', { - chain, - funder: { - address: funderAddress, - balance: ethers.utils.formatEther( - await chainConnection.signer!.getBalance(), - ), - desiredRelayerBalance: desiredBalance, - }, - context: this.context, - }); - - for (const key of this.keys.filter((k) => k.chainName !== chain)) { - await key.fetch(); - try { - await this.fundRelayerIfRequired( - chainConnection, - chain, - key, - desiredBalance, - ); - } catch (err) { - error('Error funding relayer', { - relayer: relayerKeyInfo(key), - context: this.context, - error: err, - }); - failureOccurred = true; - } - } - - walletBalanceGauge - .labels({ - chain, - wallet_address: funderAddress ?? 'unknown', - wallet_name: 'relayer-funder', - token_symbol: 'Native', - token_name: 'Native', - ...constMetricLabels, - }) - .set( - parseFloat( - ethers.utils.formatEther( - await chainConnection.signer!.getBalance(), - ), - ), - ); - } - return failureOccurred; - } - - private async fundRelayerIfRequired( - chainConnection: ChainConnection, - chain: ChainName, - key: AgentKey, - desiredBalance: string, - ) { - const currentBalance = await chainConnection.provider.getBalance( - key.address, - ); - const desiredBalanceEther = ethers.utils.parseUnits( - desiredBalance, - 'ether', - ); - const delta = desiredBalanceEther.sub(currentBalance); - - const minDelta = desiredBalanceEther - .mul(MIN_DELTA_NUMERATOR) - .div(MIN_DELTA_DENOMINATOR); - - const relayerInfo = relayerKeyInfo(key); - - if (delta.gt(minDelta)) { - log('Sending relayer funds...', { - relayer: relayerInfo, - amount: ethers.utils.formatEther(delta), - context: this.context, - chain, - }); - - const tx = await chainConnection.signer!.sendTransaction({ - to: key.address, - value: delta, - ...chainConnection.overrides, - }); - log('Sent transaction', { - relayer: relayerInfo, - txUrl: chainConnection.getTxUrl(tx), - context: this.context, - chain, - }); - const receipt = await tx.wait(chainConnection.confirmations); - log('Got transaction receipt', { - relayer: relayerInfo, - receipt, - context: this.context, - chain, - }); - } - } -} - -function relayerKeyInfo(relayerKey: AgentKey) { - return { - context: relayerKey.context, - address: relayerKey.address, - identifier: relayerKey.identifier, - originChain: relayerKey.chainName, - }; -} - -main().catch((err) => { - error('Error occurred in main', { - // JSON.stringifying an Error returns '{}'. - // This is a workaround from https://stackoverflow.com/a/60370781 - error: format(err), - }); - process.exit(1); -}); diff --git a/typescript/infra/src/config/environment.ts b/typescript/infra/src/config/environment.ts index 1087ba173..ec2b4a943 100644 --- a/typescript/infra/src/config/environment.ts +++ b/typescript/infra/src/config/environment.ts @@ -10,7 +10,7 @@ import { Contexts } from '../../config/contexts'; import { environments } from '../../config/environments'; import { AgentConfig } from './agent'; -import { RelayerFunderConfig } from './funding'; +import { KeyFunderConfig } from './funding'; import { HelloWorldConfig } from './helloworld'; import { InfrastructureConfig } from './infrastructure'; @@ -30,5 +30,5 @@ export type CoreEnvironmentConfig = { infra: InfrastructureConfig; getMultiProvider: (context?: Contexts) => Promise>; helloWorld?: HelloWorldConfig; - relayerFunderConfig?: RelayerFunderConfig; + keyFunderConfig?: KeyFunderConfig; }; diff --git a/typescript/infra/src/config/funding.ts b/typescript/infra/src/config/funding.ts index 8660b8287..a29d09853 100644 --- a/typescript/infra/src/config/funding.ts +++ b/typescript/infra/src/config/funding.ts @@ -1,12 +1,20 @@ import { Contexts } from '../../config/contexts'; +import { KEY_ROLE_ENUM } from '../agents/roles'; import { DockerConfig } from './agent'; -export interface RelayerFunderConfig { +export interface ContextAndRoles { + context: Contexts; + roles: KEY_ROLE_ENUM[]; +} + +export type ContextAndRolesMap = Partial>; + +export interface KeyFunderConfig { docker: DockerConfig; cronSchedule: string; namespace: string; contextFundingFrom: Contexts; - contextsToFund: Contexts[]; + contextsAndRolesToFund: ContextAndRolesMap; prometheusPushGateway: string; } diff --git a/typescript/infra/src/funding/deploy-key-funder.ts b/typescript/infra/src/funding/deploy-key-funder.ts new file mode 100644 index 000000000..513efcbf2 --- /dev/null +++ b/typescript/infra/src/funding/deploy-key-funder.ts @@ -0,0 +1,61 @@ +import { ChainName } from '@abacus-network/sdk'; + +import { AgentConfig, CoreEnvironmentConfig } from '../config'; +import { KeyFunderConfig } from '../config/funding'; +import { HelmCommand, helmifyValues } from '../utils/helm'; +import { execCmd } from '../utils/utils'; + +export function runKeyFunderHelmCommand( + helmCommand: HelmCommand, + agentConfig: AgentConfig, + keyFunderConfig: KeyFunderConfig, +) { + const values = getKeyFunderHelmValues(agentConfig, keyFunderConfig); + + return execCmd( + `helm ${helmCommand} key-funder ./helm/key-funder --namespace ${ + keyFunderConfig.namespace + } ${values.join(' ')}`, + {}, + false, + true, + ); +} + +function getKeyFunderHelmValues( + agentConfig: AgentConfig, + keyFunderConfig: KeyFunderConfig, +) { + const values = { + cronjob: { + schedule: keyFunderConfig.cronSchedule, + }, + abacus: { + runEnv: agentConfig.environment, + // Only used for fetching RPC urls as env vars + chains: agentConfig.contextChainNames, + contextFundingFrom: keyFunderConfig.contextFundingFrom, + contextsAndRolesToFund: keyFunderConfig.contextsAndRolesToFund, + }, + image: { + repository: keyFunderConfig.docker.repo, + tag: keyFunderConfig.docker.tag, + }, + infra: { + prometheusPushGateway: keyFunderConfig.prometheusPushGateway, + }, + }; + return helmifyValues(values); +} + +export function getKeyFunderConfig( + coreConfig: CoreEnvironmentConfig, +): KeyFunderConfig { + const keyFunderConfig = coreConfig.keyFunderConfig; + if (!keyFunderConfig) { + throw new Error( + `Environment ${coreConfig.environment} does not have a KeyFunderConfig config`, + ); + } + return keyFunderConfig; +} diff --git a/typescript/infra/src/funding/deploy-relayer-funder.ts b/typescript/infra/src/funding/deploy-relayer-funder.ts deleted file mode 100644 index e56d7f7b8..000000000 --- a/typescript/infra/src/funding/deploy-relayer-funder.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ChainName } from '@abacus-network/sdk'; - -import { AgentConfig, CoreEnvironmentConfig } from '../config'; -import { RelayerFunderConfig } from '../config/funding'; -import { HelmCommand, helmifyValues } from '../utils/helm'; -import { execCmd } from '../utils/utils'; - -export function runRelayerFunderHelmCommand( - helmCommand: HelmCommand, - agentConfig: AgentConfig, - relayerFunderConfig: RelayerFunderConfig, -) { - const values = getRelayerFunderHelmValues(agentConfig, relayerFunderConfig); - - return execCmd( - `helm ${helmCommand} relayer-funder ./helm/relayer-funder --namespace ${ - relayerFunderConfig.namespace - } ${values.join(' ')}`, - {}, - false, - true, - ); -} - -function getRelayerFunderHelmValues( - agentConfig: AgentConfig, - relayerFunderConfig: RelayerFunderConfig, -) { - const values = { - cronjob: { - schedule: relayerFunderConfig.cronSchedule, - }, - abacus: { - runEnv: agentConfig.environment, - // Only used for fetching RPC urls as env vars - chains: agentConfig.contextChainNames, - contextFundingFrom: relayerFunderConfig.contextFundingFrom, - contextsToFund: relayerFunderConfig.contextsToFund, - }, - image: { - repository: relayerFunderConfig.docker.repo, - tag: relayerFunderConfig.docker.tag, - }, - infra: { - prometheusPushGateway: relayerFunderConfig.prometheusPushGateway, - }, - }; - return helmifyValues(values); -} - -export function getRelayerFunderConfig( - coreConfig: CoreEnvironmentConfig, -): RelayerFunderConfig { - const relayerFunderConfig = coreConfig.relayerFunderConfig; - if (!relayerFunderConfig) { - throw new Error( - `Environment ${coreConfig.environment} does not have a RelayerFunderConfig config`, - ); - } - return relayerFunderConfig; -} diff --git a/typescript/sdk/src/gas/token-prices.ts b/typescript/sdk/src/gas/token-prices.ts index c7887353a..572aa5976 100644 --- a/typescript/sdk/src/gas/token-prices.ts +++ b/typescript/sdk/src/gas/token-prices.ts @@ -43,7 +43,7 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter { const allTestnets = isMainnet.every((v) => v === false); if (allTestnets) { // Testnet tokens are all artificially priced at 1.0 USD. - return chains.map((_) => 1); + return chains.map(() => 1); } if (!allMainnets) {