diff --git a/.changeset/thin-dolls-deliver.md b/.changeset/thin-dolls-deliver.md new file mode 100644 index 0000000000..99601354e9 --- /dev/null +++ b/.changeset/thin-dolls-deliver.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/cli': patch +--- + +Improve UX of the send and status commands diff --git a/solidity/contracts/token/README.md b/solidity/contracts/token/README.md index ef7e12efba..3d8c900820 100644 --- a/solidity/contracts/token/README.md +++ b/solidity/contracts/token/README.md @@ -1,8 +1,8 @@ # Hyperlane Tokens and Warp Routes -This repo contains contracts and SDK tooling for Hyperlane-connected ERC20 and ERC721 tokens. The contracts herein can be used to create [Hyperlane Warp Routes](https://docs.hyperlane.xyz/docs/deploy/deploy-warp-route) across different chains. +This repo contains contracts and SDK tooling for Hyperlane-connected ERC20 and ERC721 tokens. The contracts herein can be used to create [Hyperlane Warp Routes](https://docs.hyperlane.xyz/docs/reference/applications/warp-routes) across different chains. -For instructions on deploying Warp Routes, see [the deployment documentation](https://docs.hyperlane.xyz/docs/deploy/deploy-warp-route/deploy-a-warp-route) and the [Hyperlane-Deploy repository](https://github.com/hyperlane-xyz/hyperlane-deploy). +For instructions on deploying Warp Routes, see [the deployment documentation](https://docs.hyperlane.xyz/docs/deploy-hyperlane#deploy-a-warp-route) and the [Hyperlane CLI](https://www.npmjs.com/package/@hyperlane-xyz/cli). ## Warp Route Architecture @@ -51,7 +51,7 @@ The Token Router contract comes in several flavors and a warp route can be compo ## Interchain Security Models -Warp routes are unique amongst token bridging solutions because they provide modular security. Because the `TokenRouter` implements the `IMessageRecipient` interface, it can be configured with a custom interchain security module. Please refer to the relevant guide to specifying interchain security modules on the [Messaging API receive docs](https://docs.hyperlane.xyz/docs/apis/messaging-api/receive#interchain-security-modules). +Warp routes are unique amongst token bridging solutions because they provide modular security. Because the `TokenRouter` implements the `IMessageRecipient` interface, it can be configured with a custom interchain security module. Please refer to the relevant guide to specifying interchain security modules on the [Messaging API receive docs](https://docs.hyperlane.xyz/docs/reference/messaging/messaging-interface). ## Remote Transfer Lifecycle Diagrams @@ -67,7 +67,7 @@ interface TokenRouter { } ``` -**NOTE:** The [Relayer](https://docs.hyperlane.xyz/docs/protocol/agents/relayer) shown below must be compensated. Please refer to the relevant guide on [paying for interchain gas](https://docs.hyperlane.xyz/docs/build-with-hyperlane/guides/paying-for-interchain-gas) on the `messageID` returned from the `transferRemote` call. +**NOTE:** The [Relayer](https://docs.hyperlane.xyz/docs/operate/relayer/run-relayer) shown below must be compensated. Please refer to the details on [paying for interchain gas](https://docs.hyperlane.xyz/docs/protocol/interchain-gas-payment). Depending on the flavor of TokenRouter on the source and destination chain, this flow looks slightly different. The following diagrams illustrate these differences. @@ -227,26 +227,6 @@ graph TB | [audit-v2-remediation]() | 2023-02-15 | Hyperlane V2 Audit remediation | | [main]() | ~ | Bleeding edge | -## Setup for local development - -```sh -# Install dependencies -yarn - -# Build source and generate types -yarn build:dev -``` - -## Unit testing - -```sh -# Run all unit tests -yarn test - -# Lint check code -yarn lint -``` - ## Learn more -For more information, see the [Hyperlane introduction documentation](https://docs.hyperlane.xyz/docs/introduction/readme) or the [details about Warp Routes](https://docs.hyperlane.xyz/docs/deploy/deploy-warp-route). +For more information, see the [Hyperlane introduction documentation](https://docs.hyperlane.xyz/docs/intro). diff --git a/typescript/cli/src/commands/send.ts b/typescript/cli/src/commands/send.ts index 90bd7e526e..42491c90dd 100644 --- a/typescript/cli/src/commands/send.ts +++ b/typescript/cli/src/commands/send.ts @@ -35,15 +35,13 @@ const messageOptions: { [k: string]: Options } = { origin: { type: 'string', description: 'Origin chain to send message from', - demandOption: true, }, destination: { type: 'string', description: 'Destination chain to send message to', - demandOption: true, }, - core: coreArtifactsOption, chains: chainsCommandOption, + core: coreArtifactsOption, timeout: { type: 'number', description: 'Timeout in seconds', @@ -63,9 +61,9 @@ const messageCommand: CommandModule = { handler: async (argv: any) => { const key: string = argv.key || process.env.HYP_KEY; const chainConfigPath: string = argv.chains; - const coreArtifactsPath: string = argv.core; - const origin: string = argv.origin; - const destination: string = argv.destination; + const coreArtifactsPath: string | undefined = argv.core; + const origin: string | undefined = argv.origin; + const destination: string | undefined = argv.destination; const timeoutSec: number = argv.timeout; const skipWaitForDelivery: boolean = argv.quick; await sendTestMessage({ @@ -97,7 +95,7 @@ const transferCommand: CommandModule = { }, type: { type: 'string', - description: 'Warp token type (native of collateral)', + description: 'Warp token type (native or collateral)', default: TokenType.collateral, choices: [TokenType.collateral, TokenType.native], }, @@ -114,11 +112,11 @@ const transferCommand: CommandModule = { handler: async (argv: any) => { const key: string = argv.key || process.env.HYP_KEY; const chainConfigPath: string = argv.chains; - const coreArtifactsPath: string = argv.core; - const origin: string = argv.origin; - const destination: string = argv.destination; + const coreArtifactsPath: string | undefined = argv.core; + const origin: string | undefined = argv.origin; + const destination: string | undefined = argv.destination; const timeoutSec: number = argv.timeout; - const routerAddress: string = argv.router; + const routerAddress: string | undefined = argv.router; const tokenType: TokenType = argv.type; const wei: string = argv.wei; const recipient: string | undefined = argv.recipient; diff --git a/typescript/cli/src/commands/status.ts b/typescript/cli/src/commands/status.ts index 6cc99db458..a7a671a63b 100644 --- a/typescript/cli/src/commands/status.ts +++ b/typescript/cli/src/commands/status.ts @@ -12,21 +12,19 @@ export const statusCommand: CommandModule = { id: { type: 'string', description: 'Message ID', - demandOption: true, }, destination: { type: 'string', description: 'Destination chain name', - demandOption: true, }, chains: chainsCommandOption, core: coreArtifactsOption, }), handler: async (argv: any) => { const chainConfigPath: string = argv.chains; - const coreArtifactsPath: string = argv.core; - const messageId: string = argv.id; - const destination: string = argv.destination; + const coreArtifactsPath: string | undefined = argv.core; + const messageId: string | undefined = argv.id; + const destination: string | undefined = argv.destination; await checkMessageStatus({ chainConfigPath, coreArtifactsPath, diff --git a/typescript/cli/src/config/artifacts.ts b/typescript/cli/src/config/artifacts.ts index d5643c94f1..5b54b169f4 100644 --- a/typescript/cli/src/config/artifacts.ts +++ b/typescript/cli/src/config/artifacts.ts @@ -3,7 +3,7 @@ import { ZodTypeAny, z } from 'zod'; import { ChainName, HyperlaneContractsMap } from '@hyperlane-xyz/sdk'; -import { log, logBlue, logRed } from '../../logger.js'; +import { log, logBlue } from '../../logger.js'; import { readYamlOrJson, runFileSelectionStep } from '../utils/files.js'; const RecursiveObjectSchema: ZodTypeAny = z.lazy(() => @@ -37,7 +37,9 @@ export async function runDeploymentArtifactStep( artifactsPath?: string, message?: string, selectedChains?: ChainName[], -) { + defaultArtifactsPath = './artifacts', + defaultArtifactsNamePattern = 'core-deployment', +): Promise | undefined> { if (!artifactsPath) { const useArtifacts = await confirm({ message: message || 'Do you want use some existing contract addresses?', @@ -45,9 +47,9 @@ export async function runDeploymentArtifactStep( if (!useArtifacts) return undefined; artifactsPath = await runFileSelectionStep( - './artifacts', - 'contract artifacts', - 'core-deployment', + defaultArtifactsPath, + 'contract deployment artifacts', + defaultArtifactsNamePattern, ); } const artifacts = readDeploymentArtifacts(artifactsPath); @@ -57,7 +59,7 @@ export async function runDeploymentArtifactStep( selectedChains.includes(c), ); if (artifactChains.length === 0) { - logRed('No artifacts found for selected chains'); + log('No artifacts found for selected chains'); } else { log(`Found existing artifacts for chains: ${artifactChains.join(', ')}`); } diff --git a/typescript/cli/src/config/chain.ts b/typescript/cli/src/config/chain.ts index 7a1f2509e8..2f33292bcb 100644 --- a/typescript/cli/src/config/chain.ts +++ b/typescript/cli/src/config/chain.ts @@ -60,8 +60,8 @@ export function readChainConfigs(filePath: string) { return chainToMetadata; } -export function readChainConfigsIfExists(filePath: string) { - if (!isFile(filePath)) { +export function readChainConfigsIfExists(filePath?: string) { + if (!filePath || !isFile(filePath)) { log('No chain config file provided'); return {}; } else { diff --git a/typescript/cli/src/context.test.ts b/typescript/cli/src/context.test.ts new file mode 100644 index 0000000000..41805197e4 --- /dev/null +++ b/typescript/cli/src/context.test.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai'; +import { ethers } from 'ethers'; + +import { getContext } from './context.js'; + +describe('context', () => { + it('Gets minimal read-only context correctly', async () => { + const context = await getContext({ chainConfigPath: './fakePath' }); + expect(!!context.multiProvider).to.be.true; + expect(context.customChains).to.eql({}); + }); + + it('Handles conditional type correctly', async () => { + const randomWallet = ethers.Wallet.createRandom(); + const context = await getContext({ + chainConfigPath: './fakePath', + keyConfig: { key: randomWallet.privateKey }, + }); + expect(!!context.multiProvider).to.be.true; + expect(context.customChains).to.eql({}); + expect(await context.signer.getAddress()).to.eql(randomWallet.address); + }); +}); diff --git a/typescript/cli/src/context.ts b/typescript/cli/src/context.ts index 1de98f981b..d3aa6d58e2 100644 --- a/typescript/cli/src/context.ts +++ b/typescript/cli/src/context.ts @@ -1,3 +1,4 @@ +import { input } from '@inquirer/prompts'; import { ethers } from 'ethers'; import { @@ -11,6 +12,7 @@ import { } from '@hyperlane-xyz/sdk'; import { objFilter, objMap, objMerge } from '@hyperlane-xyz/utils'; +import { runDeploymentArtifactStep } from './config/artifacts.js'; import { readChainConfigsIfExists } from './config/chain.js'; import { keyToSigner } from './utils/keys.js'; @@ -43,17 +45,69 @@ export function getMergedContractAddresses( ) as HyperlaneContractsMap; } -export function getContext(chainConfigPath: string) { - const customChains = readChainConfigsIfExists(chainConfigPath); - const multiProvider = getMultiProvider(customChains); - return { customChains, multiProvider }; +interface ContextSettings { + chainConfigPath?: string; + coreConfig?: { + coreArtifactsPath?: string; + promptMessage?: string; + }; + keyConfig?: { + key?: string; + promptMessage?: string; + }; +} + +interface CommandContextBase { + customChains: ChainMap; + multiProvider: MultiProvider; } -export function getContextWithSigner(key: string, chainConfigPath: string) { - const signer = keyToSigner(key); +// This makes return type dynamic based on the input settings +type CommandContext

= CommandContextBase & + (P extends { keyConfig: object } + ? { signer: ethers.Signer } + : { signer: undefined }) & + (P extends { coreConfig: object } + ? { coreArtifacts: HyperlaneContractsMap } + : { coreArtifacts: undefined }); + +export async function getContext

({ + chainConfigPath, + coreConfig, + keyConfig, +}: P): Promise> { const customChains = readChainConfigsIfExists(chainConfigPath); + + let signer = undefined; + if (keyConfig) { + const key = + keyConfig.key || + (await input({ + message: + keyConfig.promptMessage || + 'Please enter a private key or use the HYP_KEY environment variable', + })); + signer = keyToSigner(key); + } + + let coreArtifacts = undefined; + if (coreConfig) { + coreArtifacts = + (await runDeploymentArtifactStep( + coreConfig.coreArtifactsPath, + coreConfig.promptMessage || + 'Do you want to use some core deployment address artifacts? This is required for PI chains (non-core chains).', + )) || {}; + } + const multiProvider = getMultiProvider(customChains, signer); - return { signer, customChains, multiProvider }; + + return { + customChains, + signer, + multiProvider, + coreArtifacts, + } as CommandContext

; } export function getMultiProvider( diff --git a/typescript/cli/src/deploy/agent.ts b/typescript/cli/src/deploy/agent.ts index 8628611af4..7fbf97ade0 100644 --- a/typescript/cli/src/deploy/agent.ts +++ b/typescript/cli/src/deploy/agent.ts @@ -21,7 +21,7 @@ export async function runKurtosisAgentDeploy({ chainConfigPath: string; agentConfigurationPath: string; }) { - const { customChains } = getContext(chainConfigPath); + const { customChains } = await getContext({ chainConfigPath }); if (!originChain) { originChain = await runSingleChainSelectionStep( diff --git a/typescript/cli/src/deploy/core.ts b/typescript/cli/src/deploy/core.ts index bb9e5c7fef..44cd8f12c8 100644 --- a/typescript/cli/src/deploy/core.ts +++ b/typescript/cli/src/deploy/core.ts @@ -36,7 +36,7 @@ import { readIsmConfig } from '../config/ism.js'; import { readMultisigConfig } from '../config/multisig.js'; import { MINIMUM_CORE_DEPLOY_GAS } from '../consts.js'; import { - getContextWithSigner, + getContext, getMergedContractAddresses, sdkContractAddressesMap, } from '../context.js'; @@ -76,10 +76,10 @@ export async function runCoreDeploy({ outPath: string; skipConfirmation: boolean; }) { - const { customChains, multiProvider, signer } = getContextWithSigner( - key, + const { customChains, multiProvider, signer } = await getContext({ chainConfigPath, - ); + keyConfig: { key }, + }); if (!chains?.length) { chains = await runMultiChainSelectionStep( @@ -119,8 +119,7 @@ export async function runCoreDeploy({ function runArtifactStep(selectedChains: ChainName[], artifactsPath?: string) { logBlue( - '\n', - 'Deployments can be totally new or can use some existing contract addresses.', + '\nDeployments can be totally new or can use some existing contract addresses.', ); return runDeploymentArtifactStep(artifactsPath, undefined, selectedChains); } diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index 9d3f8fecb6..778160edc3 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -20,13 +20,9 @@ import { import { Address, ProtocolType, objMap } from '@hyperlane-xyz/utils'; import { log, logBlue, logGray, logGreen } from '../../logger.js'; -import { runDeploymentArtifactStep } from '../config/artifacts.js'; import { WarpRouteConfig, readWarpRouteConfig } from '../config/warp.js'; import { MINIMUM_WARP_DEPLOY_GAS } from '../consts.js'; -import { - getContextWithSigner, - getMergedContractAddresses, -} from '../context.js'; +import { getContext, getMergedContractAddresses } from '../context.js'; import { isFile, prepNewArtifactsFiles, @@ -52,7 +48,11 @@ export async function runWarpDeploy({ outPath: string; skipConfirmation: boolean; }) { - const { multiProvider, signer } = getContextWithSigner(key, chainConfigPath); + const { multiProvider, signer, coreArtifacts } = await getContext({ + chainConfigPath, + coreConfig: { coreArtifactsPath }, + keyConfig: { key }, + }); if (!warpConfigPath || !isFile(warpConfigPath)) { warpConfigPath = await runFileSelectionStep( @@ -65,14 +65,9 @@ export async function runWarpDeploy({ } const warpRouteConfig = readWarpRouteConfig(warpConfigPath); - const artifacts = await runDeploymentArtifactStep( - coreArtifactsPath, - 'Do you want use some core deployment address artifacts? This is required for warp deployments to PI chains (non-core chains).', - ); - const configs = await runBuildConfigStep({ warpRouteConfig, - artifacts, + coreArtifacts, multiProvider, signer, }); @@ -99,12 +94,12 @@ async function runBuildConfigStep({ warpRouteConfig, multiProvider, signer, - artifacts, + coreArtifacts, }: { warpRouteConfig: WarpRouteConfig; multiProvider: MultiProvider; signer: ethers.Signer; - artifacts?: HyperlaneContractsMap; + coreArtifacts?: HyperlaneContractsMap; }) { log('Assembling token configs'); const { base, synthetics } = warpRouteConfig; @@ -118,7 +113,7 @@ async function runBuildConfigStep({ ); const mergedContractAddrs = getMergedContractAddresses( - artifacts, + coreArtifacts, Object.keys(warpRouteConfig), ); diff --git a/typescript/cli/src/send/message.ts b/typescript/cli/src/send/message.ts index 09d1028522..dc0a2f29cd 100644 --- a/typescript/cli/src/send/message.ts +++ b/typescript/cli/src/send/message.ts @@ -9,18 +9,13 @@ import { import { addressToBytes32, timeout } from '@hyperlane-xyz/utils'; import { errorRed, log, logBlue, logGreen } from '../../logger.js'; -import { readDeploymentArtifacts } from '../config/artifacts.js'; import { MINIMUM_TEST_SEND_GAS } from '../consts.js'; -import { - getContextWithSigner, - getMergedContractAddresses, -} from '../context.js'; +import { getContext, getMergedContractAddresses } from '../context.js'; import { runPreflightChecks } from '../deploy/utils.js'; +import { runSingleChainSelectionStep } from '../utils/chains.js'; const MESSAGE_BODY = '0x48656c6c6f21'; // Hello!' -// TODO improve the UX here by making params optional and -// prompting for missing values export async function sendTestMessage({ key, chainConfigPath, @@ -32,16 +27,32 @@ export async function sendTestMessage({ }: { key: string; chainConfigPath: string; - coreArtifactsPath: string; - origin: ChainName; - destination: ChainName; + coreArtifactsPath?: string; + origin?: ChainName; + destination?: ChainName; timeoutSec: number; skipWaitForDelivery: boolean; }) { - const { signer, multiProvider } = getContextWithSigner(key, chainConfigPath); - const coreArtifacts = coreArtifactsPath - ? readDeploymentArtifacts(coreArtifactsPath) - : undefined; + const { signer, multiProvider, customChains, coreArtifacts } = + await getContext({ + chainConfigPath, + coreConfig: { coreArtifactsPath }, + keyConfig: { key }, + }); + + if (!origin) { + origin = await runSingleChainSelectionStep( + customChains, + 'Select the origin chain', + ); + } + + if (!destination) { + destination = await runSingleChainSelectionStep( + customChains, + 'Select the destination chain', + ); + } await runPreflightChecks({ origin, diff --git a/typescript/cli/src/send/transfer.ts b/typescript/cli/src/send/transfer.ts index 5e413309ba..b1a54a5cfb 100644 --- a/typescript/cli/src/send/transfer.ts +++ b/typescript/cli/src/send/transfer.ts @@ -1,3 +1,4 @@ +import { input } from '@inquirer/prompts'; import { BigNumber, ethers } from 'ethers'; import { @@ -16,17 +17,12 @@ import { import { Address, timeout } from '@hyperlane-xyz/utils'; import { log, logBlue, logGreen } from '../../logger.js'; -import { readDeploymentArtifacts } from '../config/artifacts.js'; import { MINIMUM_TEST_SEND_GAS } from '../consts.js'; -import { - getContextWithSigner, - getMergedContractAddresses, -} from '../context.js'; +import { getContext, getMergedContractAddresses } from '../context.js'; import { runPreflightChecks } from '../deploy/utils.js'; import { assertNativeBalances, assertTokenBalance } from '../utils/balances.js'; +import { runSingleChainSelectionStep } from '../utils/chains.js'; -// TODO improve the UX here by making params optional and -// prompting for missing values export async function sendTestTransfer({ key, chainConfigPath, @@ -42,20 +38,42 @@ export async function sendTestTransfer({ }: { key: string; chainConfigPath: string; - coreArtifactsPath: string; - origin: ChainName; - destination: ChainName; - routerAddress: Address; + coreArtifactsPath?: string; + origin?: ChainName; + destination?: ChainName; + routerAddress?: Address; tokenType: TokenType; wei: string; recipient?: string; timeoutSec: number; skipWaitForDelivery: boolean; }) { - const { signer, multiProvider } = getContextWithSigner(key, chainConfigPath); - const artifacts = coreArtifactsPath - ? readDeploymentArtifacts(coreArtifactsPath) - : undefined; + const { signer, multiProvider, customChains, coreArtifacts } = + await getContext({ + chainConfigPath, + coreConfig: { coreArtifactsPath }, + keyConfig: { key }, + }); + + if (!origin) { + origin = await runSingleChainSelectionStep( + customChains, + 'Select the origin chain', + ); + } + + if (!destination) { + destination = await runSingleChainSelectionStep( + customChains, + 'Select the destination chain', + ); + } + + if (!routerAddress) { + routerAddress = await input({ + message: 'Please specify the router address', + }); + } if (tokenType === TokenType.collateral) { await assertTokenBalance( @@ -91,7 +109,7 @@ export async function sendTestTransfer({ recipient, signer, multiProvider, - artifacts, + coreArtifacts, skipWaitForDelivery, }), timeoutSec * 1000, @@ -108,7 +126,7 @@ async function executeDelivery({ recipient, multiProvider, signer, - artifacts, + coreArtifacts, skipWaitForDelivery, }: { origin: ChainName; @@ -119,13 +137,13 @@ async function executeDelivery({ recipient?: string; multiProvider: MultiProvider; signer: ethers.Signer; - artifacts?: HyperlaneContractsMap; + coreArtifacts?: HyperlaneContractsMap; skipWaitForDelivery: boolean; }) { const signerAddress = await signer.getAddress(); recipient ||= signerAddress; - const mergedContractAddrs = getMergedContractAddresses(artifacts); + const mergedContractAddrs = getMergedContractAddresses(coreArtifacts); const core = HyperlaneCore.fromAddressesMap( mergedContractAddrs, diff --git a/typescript/cli/src/status/message.ts b/typescript/cli/src/status/message.ts index 933b75cea2..0001f9be0e 100644 --- a/typescript/cli/src/status/message.ts +++ b/typescript/cli/src/status/message.ts @@ -1,8 +1,10 @@ +import { input } from '@inquirer/prompts'; + import { ChainName, HyperlaneCore } from '@hyperlane-xyz/sdk'; import { log, logBlue, logGreen } from '../../logger.js'; -import { readDeploymentArtifacts } from '../config/artifacts.js'; import { getContext, getMergedContractAddresses } from '../context.js'; +import { runSingleChainSelectionStep } from '../utils/chains.js'; export async function checkMessageStatus({ chainConfigPath, @@ -11,14 +13,27 @@ export async function checkMessageStatus({ destination, }: { chainConfigPath: string; - coreArtifactsPath: string; - messageId: string; - destination: ChainName; + coreArtifactsPath?: string; + messageId?: string; + destination?: ChainName; }) { - const { multiProvider } = getContext(chainConfigPath); - const coreArtifacts = coreArtifactsPath - ? readDeploymentArtifacts(coreArtifactsPath) - : undefined; + const { multiProvider, customChains, coreArtifacts } = await getContext({ + chainConfigPath, + coreConfig: { coreArtifactsPath }, + }); + + if (!destination) { + destination = await runSingleChainSelectionStep( + customChains, + 'Select the destination chain', + ); + } + + if (!messageId) { + messageId = await input({ + message: 'Please specify the message id', + }); + } const mergedContractAddrs = getMergedContractAddresses(coreArtifacts); const core = HyperlaneCore.fromAddressesMap(