feat: ergonomic IGP configuration in CLI (#4635)

### Description

Re-adding the ability to generate IGP hook configs using the CLI, but
repurposing logic found in infra to make the configuration experience
more ergonomic. Logic still behind the `--advanced` flag.

> Enabling this allows IGP configuration in any place that supports hook
config e.g. `core`/`warp`/`hook` init with `--advanced`.

We will use metadata in registry to:
1. fetch price from Coingecko (prompt user if unable to find)
1. fetch current gas prices via the default RPCs
1. request user to enter an IGP margin in %
1. Calculate the `gasPrice` + `tokenExchangeRate` for you

Note that it still sets `overhead` to some preexisting default. 

```sh
? Select hook type interchainGasPaymaster
Creating interchainGasPaymaster...
? Detected owner address as 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 from signer, is this
correct? yes
? Use this same address for the beneficiary? yes
? Select network type Mainnet
? Select local chain for IGP hook bitlayer
? Select remote destination chains for IGP hook alephzero, ancient8
? Enter overhead for alephzero (e.g., 75000) for IGP hook 75000
? Enter overhead for ancient8 (e.g., 75000) for IGP hook 75000
Getting gas token prices for all chains from Coingecko...
Gas price for alephzero is 40.0
Gas token price for alephzero is $0.393347
Gas price for ancient8 is 0.001000252
Gas token price for ancient8 is $2356.71
Gas price for bitlayer is 0.050000007
Gas token price for bitlayer is $60576
? Enter IGP margin percentage (e.g. 10 for 10%) 100
Created interchainGasPaymaster!
```
```sh
Core config is valid, writing to file ./configs/core-config.yaml:

    owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
    defaultIsm:
      type: trustedRelayerIsm
      relayer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
    defaultHook:
      type: aggregationHook
      hooks:
        - type: merkleTreeHook
        - owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
          type: interchainGasPaymaster
          beneficiary: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
          oracleKey: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
          overhead:
            alephzero: 75000
            ancient8: 75000
          oracleConfig:
            alephzero:
              gasPrice: "40000000000"
              tokenExchangeRate: "129868"
            ancient8:
              gasPrice: "1000253"
              tokenExchangeRate: "778100236"
    requiredHook:
      owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
      type: protocolFee
      beneficiary: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
      maxProtocolFee: "1000000000000000000"
      protocolFee: "0"

 Successfully created new core deployment config.
```

### Drive-by changes

Moving reusable infra logic into the SDK, and refactoring CLI+Infra to
reuse the underlying logic. For example:
- fetching token prices from coingecko
- fetching gas prices using a chain's RPC

### Related issues

Most recently,
https://github.com/hyperlane-xyz/hyperlane-registry/pull/236#issuecomment-2383956859.
But there have been numerous occasions where it would be nice for users
to be self-sufficient in configuring and deploying an IGP hook for their
PI deployments/relayer.

### Backward compatibility

yes

### Testing

- creating igp config with `hyperlane core init --advanced`
- making sure infra print-token-prices.ts still works
- making sure infra print-gas-prices.ts still works
pull/4775/head
Paul Balaji 4 weeks ago committed by GitHub
parent a028e16243
commit 956ff752ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/sixty-eggs-smoke.md
  2. 5
      .changeset/thin-tips-explain.md
  3. 231
      typescript/cli/src/config/hooks.ts
  4. 12
      typescript/cli/src/utils/chains.ts
  5. 22
      typescript/infra/config/environments/mainnet3/igp.ts
  6. 2
      typescript/infra/config/environments/test/gas-oracle.ts
  7. 21
      typescript/infra/config/environments/testnet4/igp.ts
  8. 4
      typescript/infra/scripts/agents/update-agent-config.ts
  9. 14
      typescript/infra/scripts/print-gas-prices.ts
  10. 115
      typescript/infra/src/config/gas-oracle.ts
  11. 251
      typescript/sdk/src/gas/utils.ts
  12. 10
      typescript/sdk/src/index.ts

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/cli': minor
---
Enable configuration of IGP hooks in the CLI

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---
Introduce utils that can be reused by the CLI and Infra for fetching token prices from Coingecko and gas prices from EVM/Cosmos chains.

@ -4,22 +4,33 @@ import { ethers } from 'ethers';
import { z } from 'zod';
import {
ChainGasOracleParams,
ChainMap,
ChainMetadata,
ChainName,
HookConfig,
HookConfigSchema,
HookType,
IgpHookConfig,
MultiProtocolProvider,
getCoingeckoTokenPrices,
getGasPrice,
getLocalStorageGasOracleConfig,
} from '@hyperlane-xyz/sdk';
import {
Address,
normalizeAddressEvm,
objFilter,
objMap,
toWei,
} from '@hyperlane-xyz/utils';
import { CommandContext } from '../context/types.js';
import { errorRed, logBlue, logGreen, logRed } from '../logger.js';
import { runMultiChainSelectionStep } from '../utils/chains.js';
import {
runMultiChainSelectionStep,
runSingleChainSelectionStep,
} from '../utils/chains.js';
import { readYamlOrJson } from '../utils/files.js';
import { detectAndConfirmOrPrompt, inputWithInfo } from '../utils/input.js';
@ -96,6 +107,11 @@ export async function createHookConfig({
name: HookType.PROTOCOL_FEE,
description: 'Charge fees for each message dispatch from this chain',
},
{
value: HookType.INTERCHAIN_GAS_PAYMASTER,
name: HookType.INTERCHAIN_GAS_PAYMASTER,
description: 'Pay for gas on remote chains',
},
],
pageSize: 10,
});
@ -107,6 +123,8 @@ export async function createHookConfig({
return createMerkleTreeConfig();
case HookType.PROTOCOL_FEE:
return createProtocolFeeConfig(context, advanced);
case HookType.INTERCHAIN_GAS_PAYMASTER:
return createIGPConfig(context, advanced);
default:
throw new Error(`Invalid hook type: ${hookType}`);
}
@ -124,30 +142,13 @@ export const createProtocolFeeConfig = callWithConfigCreationLogs(
context: CommandContext,
advanced: boolean = false,
): Promise<HookConfig> => {
const unnormalizedOwner =
!advanced && context.signer
? await context.signer.getAddress()
: await detectAndConfirmOrPrompt(
async () => context.signer?.getAddress(),
'For protocol fee hook, enter',
'owner address',
'signer',
);
const owner = normalizeAddressEvm(unnormalizedOwner);
let beneficiary = owner;
const isBeneficiarySameAsOwner = advanced
? await confirm({
message: `Use this same address (${owner}) for the beneficiary?`,
})
: true;
if (!isBeneficiarySameAsOwner) {
const unnormalizedBeneficiary = await input({
message: 'Enter beneficiary address for protocol fee hook:',
});
beneficiary = normalizeAddressEvm(unnormalizedBeneficiary);
}
// Get owner and beneficiary
const { owner, beneficiary } = await getOwnerAndBeneficiary(
'Protocol Fee Hook',
context,
advanced,
);
// TODO: input in gwei, wei, etc
const maxProtocolFee = advanced
? toWei(
@ -182,51 +183,167 @@ export const createProtocolFeeConfig = callWithConfigCreationLogs(
HookType.PROTOCOL_FEE,
);
// TODO: make this usable
export const createIGPConfig = callWithConfigCreationLogs(
async (remotes: ChainName[]): Promise<HookConfig> => {
const unnormalizedOwner = await input({
message: 'Enter owner address for IGP hook',
});
const owner = normalizeAddressEvm(unnormalizedOwner);
let beneficiary = owner;
let oracleKey = owner;
async (
context: CommandContext,
advanced: boolean = false,
): Promise<IgpHookConfig> => {
// Get owner and beneficiary
const { owner, beneficiary } = await getOwnerAndBeneficiary(
'Interchain Gas Paymaster',
context,
advanced,
);
// Determine local and remote chains
const { localChain, remoteChains } = await selectIgpChains(context);
// Get overhead, defaulting to 75000
const overhead = await getIgpOverheads(remoteChains);
// Only get prices for local and remote chains
const filteredMetadata = objFilter(
context.chainMetadata,
(_, metadata): metadata is ChainMetadata =>
remoteChains.includes(metadata.name) || metadata.name === localChain,
);
const prices = await getIgpTokenPrices(context, filteredMetadata);
// Get exchange rate margin percentage, defaulting to 10
const exchangeRateMarginPct = parseInt(
await input({
message: `Enter IGP margin percentage (e.g. 10 for 10%)`,
default: '10',
}),
10,
);
const beneficiarySameAsOwner = await confirm({
message: 'Use this same address for the beneficiary and gasOracleKey?',
// Calculate storage gas oracle config
const oracleConfig = getLocalStorageGasOracleConfig({
local: localChain,
gasOracleParams: prices,
exchangeRateMarginPct,
});
if (!beneficiarySameAsOwner) {
const unnormalizedBeneficiary = await input({
message: 'Enter beneficiary address for IGP hook',
});
beneficiary = normalizeAddressEvm(unnormalizedBeneficiary);
const unnormalizedOracleKey = await input({
message: 'Enter gasOracleKey address for IGP hook',
});
oracleKey = normalizeAddressEvm(unnormalizedOracleKey);
}
const overheads: ChainMap<number> = {};
for (const chain of remotes) {
const overhead = parseInt(
await input({
message: `Enter overhead for ${chain} (eg 75000) for IGP hook`,
}),
);
overheads[chain] = overhead;
}
return {
type: HookType.INTERCHAIN_GAS_PAYMASTER,
beneficiary,
owner,
oracleKey,
overhead: overheads,
oracleConfig: {},
oracleKey: owner,
overhead,
oracleConfig,
};
},
HookType.INTERCHAIN_GAS_PAYMASTER,
);
async function getOwnerAndBeneficiary(
module: string,
context: CommandContext,
advanced: boolean,
) {
const unnormalizedOwner =
!advanced && context.signer
? await context.signer.getAddress()
: await detectAndConfirmOrPrompt(
async () => context.signer?.getAddress(),
`For ${module}, enter`,
'owner address',
'signer',
);
const owner = normalizeAddressEvm(unnormalizedOwner);
let beneficiary = owner;
const beneficiarySameAsOwner = await confirm({
message: `Use this same address (${owner}) for the beneficiary?`,
});
if (!beneficiarySameAsOwner) {
const unnormalizedBeneficiary = await input({
message: `Enter beneficiary address for ${module}`,
});
beneficiary = normalizeAddressEvm(unnormalizedBeneficiary);
}
return { owner, beneficiary };
}
async function selectIgpChains(context: CommandContext) {
const localChain = await runSingleChainSelectionStep(
context.chainMetadata,
'Select local chain for IGP hook',
);
const isTestnet = context.chainMetadata[localChain].isTestnet;
const remoteChains = await runMultiChainSelectionStep({
chainMetadata: objFilter(
context.chainMetadata,
(_, metadata): metadata is ChainMetadata => metadata.name !== localChain,
),
message: 'Select remote destination chains for IGP hook',
requireNumber: 1,
networkType: isTestnet ? 'testnet' : 'mainnet',
});
return { localChain, remoteChains };
}
async function getIgpOverheads(remoteChains: ChainName[]) {
const overhead: ChainMap<number> = {};
for (const chain of remoteChains) {
overhead[chain] = parseInt(
await input({
message: `Enter overhead for ${chain} (e.g., 75000) for IGP hook`,
default: '75000',
}),
);
}
return overhead;
}
async function getIgpTokenPrices(
context: CommandContext,
filteredMetadata: ChainMap<ChainMetadata>,
) {
const isTestnet =
context.chainMetadata[Object.keys(filteredMetadata)[0]].isTestnet;
const fetchedPrices = isTestnet
? objMap(filteredMetadata, () => '10')
: await getCoingeckoTokenPrices(filteredMetadata);
logBlue(
isTestnet
? `Hardcoding all gas token prices to 10 USD for testnet...`
: `Getting gas token prices for all chains from Coingecko...`,
);
const mpp = new MultiProtocolProvider(context.chainMetadata);
const prices: ChainMap<ChainGasOracleParams> = {};
for (const chain of Object.keys(filteredMetadata)) {
const gasPrice = await getGasPrice(mpp, chain);
logBlue(`Gas price for ${chain} is ${gasPrice.amount}`);
let tokenPrice = fetchedPrices[chain];
if (!tokenPrice) {
tokenPrice = await input({
message: `Enter the price of ${chain}'s token in USD`,
});
} else {
logBlue(`Gas token price for ${chain} is $${tokenPrice}`);
}
const decimals = context.chainMetadata[chain].nativeToken?.decimals;
if (!decimals) {
throw new Error(`No decimals found in metadata for ${chain}`);
}
prices[chain] = {
gasPrice,
nativeToken: { price: tokenPrice, decimals },
};
}
return prices;
}
export const createAggregationConfig = callWithConfigCreationLogs(
async (
context: CommandContext,

@ -58,6 +58,13 @@ type RunMultiChainSelectionStepOptions = {
* @default false
*/
requiresConfirmation?: boolean;
/**
* The network type to filter the chains by
*
* @default undefined
*/
networkType?: 'mainnet' | 'testnet';
};
export async function runMultiChainSelectionStep({
@ -65,11 +72,12 @@ export async function runMultiChainSelectionStep({
message = 'Select chains',
requireNumber = 0,
requiresConfirmation = false,
networkType = undefined,
}: RunMultiChainSelectionStepOptions) {
const networkType = await selectNetworkType();
const selectedNetworkType = networkType ?? (await selectNetworkType());
const { choices, networkTypeSeparator } = getChainChoices(
chainMetadata,
networkType,
selectedNetworkType,
);
let currentChoiceSelection = new Set();

@ -1,12 +1,19 @@
import { ChainMap, ChainName, HookType, IgpConfig } from '@hyperlane-xyz/sdk';
import {
ChainMap,
ChainName,
HookType,
IgpConfig,
getTokenExchangeRateFromValues,
} from '@hyperlane-xyz/sdk';
import { exclude, objMap } from '@hyperlane-xyz/utils';
import {
AllStorageGasOracleConfigs,
EXCHANGE_RATE_MARGIN_PCT,
getAllStorageGasOracleConfigs,
getOverhead,
getTokenExchangeRateFromValues,
} from '../../../src/config/gas-oracle.js';
import { mustGetChainNativeToken } from '../../../src/utils/utils.js';
import { ethereumChainNames } from './chains.js';
import gasPrices from './gasPrices.json';
@ -29,7 +36,16 @@ const storageGasOracleConfig: AllStorageGasOracleConfigs =
supportedChainNames,
gasPrices,
(local, remote) =>
getTokenExchangeRateFromValues(local, remote, tokenPrices),
getTokenExchangeRateFromValues({
local,
remote,
tokenPrices,
exchangeRateMarginPct: EXCHANGE_RATE_MARGIN_PCT,
decimals: {
local: mustGetChainNativeToken(local).decimals,
remote: mustGetChainNativeToken(remote).decimals,
},
}),
(local) => parseFloat(tokenPrices[local]),
(local, remote) => getOverheadWithOverrides(local, remote),
);

@ -3,12 +3,12 @@ import { BigNumber, ethers } from 'ethers';
import {
ChainMap,
ChainName,
GasPriceConfig,
TOKEN_EXCHANGE_RATE_DECIMALS,
} from '@hyperlane-xyz/sdk';
import {
AllStorageGasOracleConfigs,
GasPriceConfig,
getAllStorageGasOracleConfigs,
} from '../../../src/config/gas-oracle.js';

@ -1,12 +1,18 @@
import { ChainMap, HookType, IgpConfig } from '@hyperlane-xyz/sdk';
import {
ChainMap,
HookType,
IgpConfig,
getTokenExchangeRateFromValues,
} from '@hyperlane-xyz/sdk';
import { Address, exclude, objMap } from '@hyperlane-xyz/utils';
import {
AllStorageGasOracleConfigs,
EXCHANGE_RATE_MARGIN_PCT,
getAllStorageGasOracleConfigs,
getOverhead,
getTokenExchangeRateFromValues,
} from '../../../src/config/gas-oracle.js';
import { mustGetChainNativeToken } from '../../../src/utils/utils.js';
import { ethereumChainNames } from './chains.js';
import gasPrices from './gasPrices.json';
@ -21,7 +27,16 @@ export const storageGasOracleConfig: AllStorageGasOracleConfigs =
supportedChainNames,
gasPrices,
(local, remote) =>
getTokenExchangeRateFromValues(local, remote, tokenPrices),
getTokenExchangeRateFromValues({
local,
remote,
tokenPrices,
exchangeRateMarginPct: EXCHANGE_RATE_MARGIN_PCT,
decimals: {
local: mustGetChainNativeToken(local).decimals,
remote: mustGetChainNativeToken(remote).decimals,
},
}),
);
export const igp: ChainMap<IgpConfig> = objMap(

@ -12,6 +12,7 @@ import {
HyperlaneDeploymentArtifacts,
MultiProvider,
buildAgentConfig,
getCosmosChainGasPrice,
} from '@hyperlane-xyz/sdk';
import {
ProtocolType,
@ -26,7 +27,6 @@ import {
DeployEnvironment,
envNameToAgentEnv,
} from '../../src/config/environment.js';
import { getCosmosChainGasPrice } from '../../src/config/gas-oracle.js';
import {
chainIsProtocol,
filterRemoteDomainMetadata,
@ -125,7 +125,7 @@ export async function writeAgentConfig(
.map(async (chain) => [
chain,
{
gasPrice: await getCosmosChainGasPrice(chain),
gasPrice: await getCosmosChainGasPrice(chain, multiProvider),
},
]),
),

@ -1,7 +1,12 @@
import { Provider } from '@ethersproject/providers';
import { ethers } from 'ethers';
import { ChainMap, MultiProtocolProvider } from '@hyperlane-xyz/sdk';
import {
ChainMap,
GasPriceConfig,
MultiProtocolProvider,
getCosmosChainGasPrice,
} from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
// Intentionally circumvent `mainnet3/index.ts` and `getEnvironmentConfig('mainnet3')`
@ -12,10 +17,6 @@ import { supportedChainNames as mainnet3SupportedChainNames } from '../config/en
import { getRegistry as getTestnet4Registry } from '../config/environments/testnet4/chains.js';
import testnet4GasPrices from '../config/environments/testnet4/gasPrices.json' assert { type: 'json' };
import { supportedChainNames as testnet4SupportedChainNames } from '../config/environments/testnet4/supportedChainNames.js';
import {
GasPriceConfig,
getCosmosChainGasPrice,
} from '../src/config/gas-oracle.js';
import { getArgs } from './agent-utils.js';
@ -69,8 +70,7 @@ async function getGasPrice(
};
}
case ProtocolType.Cosmos: {
const { amount } = await getCosmosChainGasPrice(chain);
const { amount } = await getCosmosChainGasPrice(chain, mpp);
return {
amount,
decimals: 1,

@ -2,56 +2,37 @@ import chalk from 'chalk';
import { BigNumber, ethers } from 'ethers';
import {
AgentCosmosGasPrice,
ChainMap,
ChainName,
StorageGasOracleConfig as DestinationOracleConfig,
TOKEN_EXCHANGE_RATE_DECIMALS,
GasPriceConfig,
StorageGasOracleConfig,
TOKEN_EXCHANGE_RATE_SCALE,
defaultMultisigConfigs,
getCosmosRegistryChain,
multisigIsmVerificationCost,
} from '@hyperlane-xyz/sdk';
import { ProtocolType, convertDecimals } from '@hyperlane-xyz/utils';
import { getChain } from '../../config/registry.js';
import {
isEthereumProtocolChain,
mustGetChainNativeToken,
} from '../utils/utils.js';
// Gas data to configure on a single local chain. Includes DestinationOracleConfig
// for each remote chain.
export type StorageGasOracleConfig = ChainMap<DestinationOracleConfig>;
// StorageGasOracleConfigs for each local chain
export type AllStorageGasOracleConfigs = ChainMap<StorageGasOracleConfig>;
import { isEthereumProtocolChain } from '../utils/utils.js';
// A configuration for a gas price.
// Some chains, e.g. Neutron, have gas prices that are
// not integers and and are still quoted in the "wei" version
// of the token. Therefore it's possible for the amount to be a
// float (e.g. "0.0053") and for decimals to be 1. This is why
// we intentionally don't deal with BigNumber here.
export interface GasPriceConfig {
amount: string;
decimals: number;
}
// gas oracle configs for each chain, which includes
// a map for each chain's remote chains
export type AllStorageGasOracleConfigs = ChainMap<
ChainMap<StorageGasOracleConfig>
>;
// Overcharge by 50% to account for market making risk
const EXCHANGE_RATE_MARGIN_PCT = 50;
export const EXCHANGE_RATE_MARGIN_PCT = 50;
// Gets the StorageGasOracleConfig for a particular local chain.
// Gets the StorageGasOracleConfig for each remote chain for a particular local chain.
// Accommodates small non-integer gas prices by scaling up the gas price
// and scaling down the exchange rate by the same factor.
function getLocalStorageGasOracleConfig(
function getLocalStorageGasOracleConfigOverride(
local: ChainName,
remotes: ChainName[],
gasPrices: ChainMap<GasPriceConfig>,
getTokenExchangeRate: (local: ChainName, remote: ChainName) => BigNumber,
getTokenUsdPrice?: (chain: ChainName) => number,
getOverhead?: (local: ChainName, remote: ChainName) => number,
): StorageGasOracleConfig {
): ChainMap<StorageGasOracleConfig> {
return remotes.reduce((agg, remote) => {
let exchangeRate = getTokenExchangeRate(local, remote);
if (!gasPrices[remote]) {
@ -203,7 +184,7 @@ export function getOverhead(
: FOREIGN_DEFAULT_OVERHEAD; // non-ethereum overhead
}
// Gets the StorageGasOracleConfig for each local chain
// Gets the map of remote gas oracle configs for each local chain
export function getAllStorageGasOracleConfigs(
chainNames: ChainName[],
gasPrices: ChainMap<GasPriceConfig>,
@ -215,7 +196,7 @@ export function getAllStorageGasOracleConfigs(
const remotes = chainNames.filter((chain) => local !== chain);
return {
...agg,
[local]: getLocalStorageGasOracleConfig(
[local]: getLocalStorageGasOracleConfigOverride(
local,
remotes,
gasPrices,
@ -226,71 +207,3 @@ export function getAllStorageGasOracleConfigs(
};
}, {}) as AllStorageGasOracleConfigs;
}
// Gets the exchange rate of the remote quoted in local tokens
export function getTokenExchangeRateFromValues(
local: ChainName,
remote: ChainName,
tokenPrices: ChainMap<string>,
): BigNumber {
// Workaround for chicken-egg dependency problem.
// We need to provide some default value here to satisfy the config on initial load,
// whilst knowing that it will get overwritten when a script actually gets run.
// We set default token price to 1 to mitigate underflow/overflow errors that occurred
// on some pairings if the exchange rate itself was set to 1.
const defaultValue = '1';
const localValue = ethers.utils.parseUnits(
tokenPrices[local] ?? defaultValue,
TOKEN_EXCHANGE_RATE_DECIMALS,
);
const remoteValue = ethers.utils.parseUnits(
tokenPrices[remote] ?? defaultValue,
TOKEN_EXCHANGE_RATE_DECIMALS,
);
// This does not yet account for decimals!
let exchangeRate = remoteValue.mul(TOKEN_EXCHANGE_RATE_SCALE).div(localValue);
// Apply the premium
exchangeRate = exchangeRate.mul(100 + EXCHANGE_RATE_MARGIN_PCT).div(100);
return BigNumber.from(
convertDecimals(
mustGetChainNativeToken(remote).decimals,
mustGetChainNativeToken(local).decimals,
exchangeRate.toString(),
),
);
}
// Gets the gas price for a Cosmos chain
export async function getCosmosChainGasPrice(
chain: ChainName,
): Promise<AgentCosmosGasPrice> {
const metadata = getChain(chain);
if (!metadata) {
throw new Error(`No metadata found for Cosmos chain ${chain}`);
}
if (metadata.protocol !== ProtocolType.Cosmos) {
throw new Error(`Chain ${chain} is not a Cosmos chain`);
}
const cosmosRegistryChain = await getCosmosRegistryChain(chain);
const nativeToken = mustGetChainNativeToken(chain);
const fee = cosmosRegistryChain.fees?.fee_tokens.find(
(fee: { denom: string }) => {
return (
fee.denom === nativeToken.denom || fee.denom === `u${nativeToken.denom}`
);
},
);
if (!fee || fee.average_gas_price === undefined) {
throw new Error(`No gas price found for Cosmos chain ${chain}`);
}
return {
denom: fee.denom,
amount: fee.average_gas_price.toString(),
};
}

@ -0,0 +1,251 @@
import { Provider } from '@ethersproject/providers';
import { BigNumber, ethers } from 'ethers';
import { ProtocolType, convertDecimals, objMap } from '@hyperlane-xyz/utils';
import {
TOKEN_EXCHANGE_RATE_DECIMALS,
TOKEN_EXCHANGE_RATE_SCALE,
} from '../consts/igp.js';
import { ChainMetadataManager } from '../metadata/ChainMetadataManager.js';
import { AgentCosmosGasPrice } from '../metadata/agentConfig.js';
import { ChainMetadata } from '../metadata/chainMetadataTypes.js';
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider.js';
import { ChainMap, ChainName } from '../types.js';
import { getCosmosRegistryChain } from '../utils/cosmos.js';
import { StorageGasOracleConfig } from './oracle/types.js';
export interface GasPriceConfig {
amount: string;
decimals: number;
}
export interface NativeTokenPriceConfig {
price: string;
decimals: number;
}
export interface ChainGasOracleParams {
gasPrice: GasPriceConfig;
nativeToken: NativeTokenPriceConfig;
}
export async function getGasPrice(
mpp: MultiProtocolProvider,
chain: string,
): Promise<GasPriceConfig> {
const protocolType = mpp.getProtocol(chain);
switch (protocolType) {
case ProtocolType.Ethereum: {
const provider = mpp.getProvider(chain);
const gasPrice = await (provider.provider as Provider).getGasPrice();
return {
amount: ethers.utils.formatUnits(gasPrice, 'gwei'),
decimals: 9,
};
}
case ProtocolType.Cosmos: {
const { amount } = await getCosmosChainGasPrice(chain, mpp);
return {
amount,
decimals: 1,
};
}
case ProtocolType.Sealevel:
// TODO get a reasonable value
return {
amount: '0.001',
decimals: 9,
};
default:
throw new Error(`Unsupported protocol type: ${protocolType}`);
}
}
// Gets the gas price for a Cosmos chain
export async function getCosmosChainGasPrice(
chain: ChainName,
chainMetadataManager: ChainMetadataManager,
): Promise<AgentCosmosGasPrice> {
const metadata = chainMetadataManager.getChainMetadata(chain);
if (!metadata) {
throw new Error(`No metadata found for Cosmos chain ${chain}`);
}
if (metadata.protocol !== ProtocolType.Cosmos) {
throw new Error(`Chain ${chain} is not a Cosmos chain`);
}
const cosmosRegistryChain = await getCosmosRegistryChain(chain);
const nativeToken = metadata.nativeToken;
if (!nativeToken) {
throw new Error(`No native token found for Cosmos chain ${chain}`);
}
if (!nativeToken.denom) {
throw new Error(`No denom found for native token on Cosmos chain ${chain}`);
}
const fee = cosmosRegistryChain.fees?.fee_tokens.find(
(fee: { denom: string }) => {
return (
fee.denom === nativeToken.denom || fee.denom === `u${nativeToken.denom}`
);
},
);
if (!fee || fee.average_gas_price === undefined) {
throw new Error(`No gas price found for Cosmos chain ${chain}`);
}
return {
denom: fee.denom,
amount: fee.average_gas_price.toString(),
};
}
// Gets the exchange rate of the remote quoted in local tokens
export function getTokenExchangeRateFromValues({
local,
remote,
tokenPrices,
exchangeRateMarginPct,
decimals,
}: {
local: ChainName;
remote: ChainName;
tokenPrices: ChainMap<string>;
exchangeRateMarginPct: number;
decimals: { local: number; remote: number };
}): BigNumber {
// Workaround for chicken-egg dependency problem.
// We need to provide some default value here to satisfy the config on initial load,
// whilst knowing that it will get overwritten when a script actually gets run.
const defaultValue = '1';
const localValue = ethers.utils.parseUnits(
tokenPrices[local] ?? defaultValue,
TOKEN_EXCHANGE_RATE_DECIMALS,
);
const remoteValue = ethers.utils.parseUnits(
tokenPrices[remote] ?? defaultValue,
TOKEN_EXCHANGE_RATE_DECIMALS,
);
// This does not yet account for decimals!
let exchangeRate = remoteValue.mul(TOKEN_EXCHANGE_RATE_SCALE).div(localValue);
// Apply the premium
exchangeRate = exchangeRate.mul(100 + exchangeRateMarginPct).div(100);
return BigNumber.from(
convertDecimals(decimals.remote, decimals.local, exchangeRate.toString()),
);
}
// Gets the StorageGasOracleConfig for each remote chain for a particular local chain.
// Accommodates small non-integer gas prices by scaling up the gas price
// and scaling down the exchange rate by the same factor.
export function getLocalStorageGasOracleConfig({
local,
gasOracleParams,
exchangeRateMarginPct,
}: {
local: ChainName;
gasOracleParams: ChainMap<ChainGasOracleParams>;
exchangeRateMarginPct: number;
}): ChainMap<StorageGasOracleConfig> {
const remotes = Object.keys(gasOracleParams).filter(
(remote) => remote !== local,
);
const tokenPrices: ChainMap<string> = objMap(
gasOracleParams,
(chain) => gasOracleParams[chain].nativeToken.price,
);
const localDecimals = gasOracleParams[local].nativeToken.decimals;
return remotes.reduce((agg, remote) => {
const remoteDecimals = gasOracleParams[remote].nativeToken.decimals;
let exchangeRate = getTokenExchangeRateFromValues({
local,
remote,
tokenPrices,
exchangeRateMarginPct,
decimals: { local: localDecimals, remote: remoteDecimals },
});
// First parse as a number, so we have floating point precision.
// Recall it's possible to have gas prices that are not integers, even
// after converting to the "wei" version of the token.
let gasPrice =
parseFloat(gasOracleParams[remote].gasPrice.amount) *
Math.pow(10, gasOracleParams[remote].gasPrice.decimals);
if (isNaN(gasPrice)) {
throw new Error(
`Invalid gas price for chain ${remote}: ${gasOracleParams[remote].gasPrice.amount}`,
);
}
// We have very little precision and ultimately need an integer value for
// the gas price that will be set on-chain. We scale up the gas price and
// scale down the exchange rate by the same factor.
if (gasPrice < 10 && gasPrice % 1 !== 0) {
// Scale up the gas price by 1e4
const gasPriceScalingFactor = 1e4;
// Check that there's no significant underflow when applying
// this to the exchange rate:
const adjustedExchangeRate = exchangeRate.div(gasPriceScalingFactor);
const recoveredExchangeRate = adjustedExchangeRate.mul(
gasPriceScalingFactor,
);
if (recoveredExchangeRate.mul(100).div(exchangeRate).lt(99)) {
throw new Error('Too much underflow when downscaling exchange rate');
}
// Apply the scaling factor
exchangeRate = adjustedExchangeRate;
gasPrice *= gasPriceScalingFactor;
}
// Our integer gas price.
const gasPriceBn = BigNumber.from(Math.ceil(gasPrice));
return {
...agg,
[remote]: {
tokenExchangeRate: exchangeRate.toString(),
gasPrice: gasPriceBn.toString(),
},
};
}, {} as ChainMap<StorageGasOracleConfig>);
}
const COINGECKO_PRICE_API = 'https://api.coingecko.com/api/v3/simple/price';
export async function getCoingeckoTokenPrices(
chainMetadata: ChainMap<ChainMetadata>,
currency = 'usd',
): Promise<ChainMap<string | undefined>> {
const ids = objMap(
chainMetadata,
(_, metadata) => metadata.gasCurrencyCoinGeckoId ?? metadata.name,
);
const resp = await fetch(
`${COINGECKO_PRICE_API}?ids=${Object.entries(ids).join(
',',
)}&vs_currencies=${currency}`,
);
const idPrices = await resp.json();
const prices = objMap(ids, (chain, id) => {
const idData = idPrices[id];
if (!idData) {
return undefined;
}
const price = idData[currency];
if (!price) {
return undefined;
}
return price.toString();
});
return prices;
}

@ -539,3 +539,13 @@ export {
export { EvmIsmModule } from './ism/EvmIsmModule.js';
export { AnnotatedEV5Transaction } from './providers/ProviderType.js';
export { EvmERC20WarpModule } from './token/EvmERC20WarpModule.js';
export {
GasPriceConfig,
NativeTokenPriceConfig,
ChainGasOracleParams,
getCoingeckoTokenPrices,
getCosmosChainGasPrice,
getGasPrice,
getLocalStorageGasOracleConfig,
getTokenExchangeRateFromValues,
} from './gas/utils.js';

Loading…
Cancel
Save