diff --git a/.changeset/sixty-eggs-smoke.md b/.changeset/sixty-eggs-smoke.md new file mode 100644 index 00000000000..24906d7c690 --- /dev/null +++ b/.changeset/sixty-eggs-smoke.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/cli': minor +--- + +Enable configuration of IGP hooks in the CLI diff --git a/.changeset/thin-tips-explain.md b/.changeset/thin-tips-explain.md new file mode 100644 index 00000000000..330e57a8a39 --- /dev/null +++ b/.changeset/thin-tips-explain.md @@ -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. diff --git a/typescript/cli/src/config/hooks.ts b/typescript/cli/src/config/hooks.ts index a075d665506..0bfd8cb1f6c 100644 --- a/typescript/cli/src/config/hooks.ts +++ b/typescript/cli/src/config/hooks.ts @@ -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 => { - 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 => { - 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 => { + // 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 = {}; - 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 = {}; + 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, +) { + 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 = {}; + + 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, diff --git a/typescript/cli/src/utils/chains.ts b/typescript/cli/src/utils/chains.ts index 09975aca36f..f5fb2b34142 100644 --- a/typescript/cli/src/utils/chains.ts +++ b/typescript/cli/src/utils/chains.ts @@ -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(); diff --git a/typescript/infra/config/environments/mainnet3/igp.ts b/typescript/infra/config/environments/mainnet3/igp.ts index 6b0d8fbbc2f..fa4ab8e9195 100644 --- a/typescript/infra/config/environments/mainnet3/igp.ts +++ b/typescript/infra/config/environments/mainnet3/igp.ts @@ -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), ); diff --git a/typescript/infra/config/environments/test/gas-oracle.ts b/typescript/infra/config/environments/test/gas-oracle.ts index cacf8f3a339..65c09d9cdb0 100644 --- a/typescript/infra/config/environments/test/gas-oracle.ts +++ b/typescript/infra/config/environments/test/gas-oracle.ts @@ -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'; diff --git a/typescript/infra/config/environments/testnet4/igp.ts b/typescript/infra/config/environments/testnet4/igp.ts index 302d6aeb727..0cabe5b69ab 100644 --- a/typescript/infra/config/environments/testnet4/igp.ts +++ b/typescript/infra/config/environments/testnet4/igp.ts @@ -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 = objMap( diff --git a/typescript/infra/scripts/agents/update-agent-config.ts b/typescript/infra/scripts/agents/update-agent-config.ts index de01a2cffe2..7044bc436ea 100644 --- a/typescript/infra/scripts/agents/update-agent-config.ts +++ b/typescript/infra/scripts/agents/update-agent-config.ts @@ -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), }, ]), ), diff --git a/typescript/infra/scripts/print-gas-prices.ts b/typescript/infra/scripts/print-gas-prices.ts index bbe15c7b10e..39eca69ee24 100644 --- a/typescript/infra/scripts/print-gas-prices.ts +++ b/typescript/infra/scripts/print-gas-prices.ts @@ -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, diff --git a/typescript/infra/src/config/gas-oracle.ts b/typescript/infra/src/config/gas-oracle.ts index ac3516d1034..c040ff45bb8 100644 --- a/typescript/infra/src/config/gas-oracle.ts +++ b/typescript/infra/src/config/gas-oracle.ts @@ -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; - -// StorageGasOracleConfigs for each local chain -export type AllStorageGasOracleConfigs = ChainMap; +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 +>; // 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, getTokenExchangeRate: (local: ChainName, remote: ChainName) => BigNumber, getTokenUsdPrice?: (chain: ChainName) => number, getOverhead?: (local: ChainName, remote: ChainName) => number, -): StorageGasOracleConfig { +): ChainMap { 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, @@ -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, -): 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 { - 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(), - }; -} diff --git a/typescript/sdk/src/gas/utils.ts b/typescript/sdk/src/gas/utils.ts new file mode 100644 index 00000000000..3a658d9b6ae --- /dev/null +++ b/typescript/sdk/src/gas/utils.ts @@ -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 { + 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 { + 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; + 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; + exchangeRateMarginPct: number; +}): ChainMap { + const remotes = Object.keys(gasOracleParams).filter( + (remote) => remote !== local, + ); + const tokenPrices: ChainMap = 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); +} + +const COINGECKO_PRICE_API = 'https://api.coingecko.com/api/v3/simple/price'; + +export async function getCoingeckoTokenPrices( + chainMetadata: ChainMap, + currency = 'usd', +): Promise> { + 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; +} diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index e9bd31bb423..5ab4630b99d 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -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';