Skip to content

Commit

Permalink
feat: ergonomic IGP configuration in CLI (#4635)
Browse files Browse the repository at this point in the history
### 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,
hyperlane-xyz/hyperlane-registry#236 (comment).
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
  • Loading branch information
paulbalaji authored Oct 28, 2024
1 parent a028e16 commit 956ff75
Show file tree
Hide file tree
Showing 12 changed files with 516 additions and 176 deletions.
5 changes: 5 additions & 0 deletions .changeset/sixty-eggs-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/cli': minor
---

Enable configuration of IGP hooks in the CLI
5 changes: 5 additions & 0 deletions .changeset/thin-tips-explain.md
Original file line number Diff line number Diff line change
@@ -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.
231 changes: 174 additions & 57 deletions typescript/cli/src/config/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
});
Expand All @@ -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}`);
}
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions typescript/cli/src/utils/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,26 @@ type RunMultiChainSelectionStepOptions = {
* @default false
*/
requiresConfirmation?: boolean;

/**
* The network type to filter the chains by
*
* @default undefined
*/
networkType?: 'mainnet' | 'testnet';
};

export async function runMultiChainSelectionStep({
chainMetadata,
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();
Expand Down
22 changes: 19 additions & 3 deletions typescript/infra/config/environments/mainnet3/igp.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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),
);
Expand Down
2 changes: 1 addition & 1 deletion typescript/infra/config/environments/test/gas-oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading

0 comments on commit 956ff75

Please sign in to comment.