diff --git a/.changeset/clean-numbers-know.md b/.changeset/clean-numbers-know.md deleted file mode 100644 index b48a6d303..000000000 --- a/.changeset/clean-numbers-know.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@hyperlane-xyz/helloworld': minor -'@hyperlane-xyz/infra': minor -'@hyperlane-xyz/cli': minor ---- - -Upgrade registry to 2.1.1 diff --git a/.eslintrc b/.eslintrc index 0fffcede5..23ad387d6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -23,6 +23,7 @@ "no-extra-boolean-cast": ["error"], "no-ex-assign": ["error"], "no-constant-condition": ["off"], + "no-return-await": ["error"], "guard-for-in": ["error"], "@typescript-eslint/ban-ts-comment": ["off"], "@typescript-eslint/explicit-module-boundary-types": ["off"], diff --git a/.github/workflows/test-skipped.yml b/.github/workflows/test-skipped.yml index 7b4091321..6b3f3b081 100644 --- a/.github/workflows/test-skipped.yml +++ b/.github/workflows/test-skipped.yml @@ -70,7 +70,7 @@ jobs: - name: Instant pass run: echo "e2e job passed" - cli-e2e: + cli-advanced-e2e: runs-on: ubuntu-latest if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.base_ref == 'main') || github.event_name == 'merge_group' strategy: @@ -81,7 +81,7 @@ jobs: - test-type: pi_with_core_chain steps: - name: Instant pass - run: echo "cli-e2e job passed" + run: echo "cli-advanced-e2e job passed" env-test: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8584a60f2..ebd05df12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,11 +1,12 @@ name: test on: - # Triggers the workflow on push or pull request against main + # Triggers the workflow on pushes to main branch push: branches: [main] paths-ignore: - '*.md' + # Triggers on pull requests ignoring md files pull_request: branches: - '*' # run against all branches @@ -187,7 +188,7 @@ jobs: e2e-matrix: runs-on: larger-runner - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.base_ref == 'main') || github.event_name == 'merge_group' + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.base_ref == 'main' || github.base_ref == 'cli-2.0') || github.event_name == 'merge_group' needs: [yarn-build] strategy: matrix: @@ -281,7 +282,7 @@ jobs: prebuild-cli-e2e: runs-on: larger-runner - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.base_ref == 'main') || github.event_name == 'merge_group' + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.base_ref == 'main' || github.base_ref == 'cli-2.0') || github.event_name == 'merge_group' steps: - uses: actions/checkout@v4 with: @@ -328,9 +329,9 @@ jobs: env: RUST_BACKTRACE: 'full' - cli-e2e: + cli-advanced-e2e: runs-on: larger-runner - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.base_ref == 'main') || github.event_name == 'merge_group' + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.base_ref == 'main' || github.base_ref == 'cli-2.0') || github.event_name == 'merge_group' needs: [yarn-build, prebuild-cli-e2e] strategy: matrix: @@ -399,7 +400,7 @@ jobs: uses: ./.github/actions/checkout-registry - name: cli e2e tests - run: ./typescript/cli/ci-test.sh ${{ matrix.test-type }} + run: ./typescript/cli/ci-advanced-test.sh ${{ matrix.test-type }} env-test: runs-on: ubuntu-latest diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index b7e03e675..2466daf87 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -11,7 +11,7 @@ This CoC applies to all members of the Hyperlane Network's community including, 1. Never harass or bully anyone. Not verbally, not physically, not sexually. Harassment will not be tolerated. 2. Never discriminate on the basis of personal characteristics or group membership. 3. Treat your fellow contributors with respect, fairness, and professionalism, especially in situations of high pressure. -4. Seek, offer, and accept objective criticism of yours and others work, strive to acknowledge the contributions of others. +4. Seek, offer, and accept objective criticism of yours and others work, strive to acknowledge the contributions of others. 5. Be transparent and honest about your qualifications and any potential conflicts of interest. Transparency is a key tenet of the Hyperlane project and we expect it from all contributors. 6. Bring an open and curious mind, the Hyperlane project is designed to enable developers to express their curiosity, experiment, and build things we couldn't have imagined ourselves. 7. Stay on track - Do your best to avoid off-topic discussion and make sure you are posting to the correct channel and repositories. Distractions are costly and it is far too easy for work to go off track. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b88646265..9b993d205 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1094,13 +1094,13 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67523a3b4be3ce1989d607a828d036249522dd9c1c8de7f4dd2dae43a37369d1" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.1", + "libloading 0.8.4", ] [[package]] @@ -5182,12 +5182,12 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c571b676ddfc9a8c12f1f3d3085a7b163966a8fd8098a90640953ce5f6170161" +checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if", - "windows-sys 0.48.0", + "windows-targets 0.52.0", ] [[package]] @@ -5273,9 +5273,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.14" +version = "1.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "295c17e837573c8c821dbaeb3cceb3d745ad082f7572191409e69cbc1b3fd050" +checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" dependencies = [ "cc", "pkg-config", @@ -6469,9 +6469,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" +checksum = "8d3928fb5db768cb86f891ff014f0144589297e3c6a1aba6ed7cecfdace270c7" dependencies = [ "proc-macro2 1.0.76", "syn 2.0.48", diff --git a/rust/agents/relayer/src/msg/op_queue.rs b/rust/agents/relayer/src/msg/op_queue.rs index 07869b0de..41adc685f 100644 --- a/rust/agents/relayer/src/msg/op_queue.rs +++ b/rust/agents/relayer/src/msg/op_queue.rs @@ -166,6 +166,10 @@ mod test { todo!() } + fn retrieve_status_from_db(&self) -> Option { + todo!() + } + fn get_operation_labels(&self) -> (String, String) { Default::default() } diff --git a/rust/agents/relayer/src/msg/op_submitter.rs b/rust/agents/relayer/src/msg/op_submitter.rs index 014070dc1..ec345be72 100644 --- a/rust/agents/relayer/src/msg/op_submitter.rs +++ b/rust/agents/relayer/src/msg/op_submitter.rs @@ -186,9 +186,14 @@ async fn receive_task( // make sure things are getting wired up correctly; if this works in testing it // should also be valid in production. debug_assert_eq!(*op.destination_domain(), domain); - prepare_queue - .push(op, Some(PendingOperationStatus::FirstPrepareAttempt)) - .await; + let status = op.retrieve_status_from_db().unwrap_or_else(|| { + trace!( + ?op, + "No status found for message, defaulting to FirstPrepareAttempt" + ); + PendingOperationStatus::FirstPrepareAttempt + }); + prepare_queue.push(op, Some(status)).await; } } diff --git a/rust/agents/relayer/src/msg/pending_message.rs b/rust/agents/relayer/src/msg/pending_message.rs index 4887e192c..760a791cb 100644 --- a/rust/agents/relayer/src/msg/pending_message.rs +++ b/rust/agents/relayer/src/msg/pending_message.rs @@ -127,6 +127,13 @@ impl PendingOperation for PendingMessage { } fn set_status(&mut self, status: PendingOperationStatus) { + if let Err(e) = self + .ctx + .origin_db + .store_status_by_message_id(&self.message.id(), &self.status) + { + warn!(message_id = ?self.message.id(), err = %e, status = %self.status, "Persisting `status` failed for message"); + } self.status = status; } @@ -142,6 +149,16 @@ impl PendingOperation for PendingMessage { self.ctx.destination_mailbox.domain() } + fn retrieve_status_from_db(&self) -> Option { + match self.ctx.origin_db.retrieve_status_by_message_id(&self.id()) { + Ok(status) => status, + Err(e) => { + warn!(error=?e, "Failed to retrieve status for message"); + None + } + } + } + fn app_context(&self) -> Option { self.app_context.clone() } diff --git a/rust/hyperlane-base/Cargo.toml b/rust/hyperlane-base/Cargo.toml index 36a8432bb..0564c06a7 100644 --- a/rust/hyperlane-base/Cargo.toml +++ b/rust/hyperlane-base/Cargo.toml @@ -46,7 +46,6 @@ url.workspace = true warp.workspace = true ya-gcp.workspace = true - backtrace = { workspace = true, optional = true } backtrace-oneline = { path = "../utils/backtrace-oneline", optional = true } @@ -58,7 +57,6 @@ hyperlane-sealevel = { path = "../chains/hyperlane-sealevel" } hyperlane-cosmos = { path = "../chains/hyperlane-cosmos"} hyperlane-test = { path = "../hyperlane-test" } - # dependency version is determined by etheres rusoto_core = "*" rusoto_kms = "*" diff --git a/rust/hyperlane-base/src/db/rocks/hyperlane_db.rs b/rust/hyperlane-base/src/db/rocks/hyperlane_db.rs index b4323613a..6b83e051a 100644 --- a/rust/hyperlane-base/src/db/rocks/hyperlane_db.rs +++ b/rust/hyperlane-base/src/db/rocks/hyperlane_db.rs @@ -7,7 +7,7 @@ use hyperlane_core::{ GasPaymentKey, HyperlaneDomain, HyperlaneLogStore, HyperlaneMessage, HyperlaneSequenceAwareIndexerStoreReader, HyperlaneWatermarkedLogStore, Indexed, InterchainGasExpenditure, InterchainGasPayment, InterchainGasPaymentMeta, LogMeta, - MerkleTreeInsertion, H256, + MerkleTreeInsertion, PendingOperationStatus, H256, }; use super::{ @@ -27,6 +27,7 @@ const HIGHEST_SEEN_MESSAGE_NONCE: &str = "highest_seen_message_nonce_"; const GAS_PAYMENT_FOR_MESSAGE_ID: &str = "gas_payment_sequence_for_message_id_v2_"; const GAS_PAYMENT_META_PROCESSED: &str = "gas_payment_meta_processed_v3_"; const GAS_EXPENDITURE_FOR_MESSAGE_ID: &str = "gas_expenditure_for_message_id_v2_"; +const STATUS_BY_MESSAGE_ID: &str = "status_by_message_id_"; const PENDING_MESSAGE_RETRY_COUNT_FOR_MESSAGE_ID: &str = "pending_message_retry_count_for_message_id_"; const MERKLE_TREE_INSERTION: &str = "merkle_tree_insertion_"; @@ -501,6 +502,13 @@ make_store_and_retrieve!(pub(self), dispatched_block_number_by_nonce, MESSAGE_DI make_store_and_retrieve!(pub, processed_by_nonce, NONCE_PROCESSED, u32, bool); make_store_and_retrieve!(pub(self), processed_by_gas_payment_meta, GAS_PAYMENT_META_PROCESSED, InterchainGasPaymentMeta, bool); make_store_and_retrieve!(pub(self), interchain_gas_expenditure_data_by_message_id, GAS_EXPENDITURE_FOR_MESSAGE_ID, H256, InterchainGasExpenditureData); +make_store_and_retrieve!( + pub, + status_by_message_id, + STATUS_BY_MESSAGE_ID, + H256, + PendingOperationStatus +); make_store_and_retrieve!(pub(self), interchain_gas_payment_data_by_gas_payment_key, GAS_PAYMENT_FOR_MESSAGE_ID, GasPaymentKey, InterchainGasPaymentData); make_store_and_retrieve!(pub(self), gas_payment_by_sequence, GAS_PAYMENT_BY_SEQUENCE, u32, InterchainGasPayment); make_store_and_retrieve!(pub(self), gas_payment_block_by_sequence, GAS_PAYMENT_BY_SEQUENCE, u32, u64); diff --git a/rust/hyperlane-base/src/lib.rs b/rust/hyperlane-base/src/lib.rs index ce6843e58..7ff1fc235 100644 --- a/rust/hyperlane-base/src/lib.rs +++ b/rust/hyperlane-base/src/lib.rs @@ -12,6 +12,9 @@ pub mod settings; mod agent; pub use agent::*; +/// The local database used by agents +pub mod db; + pub mod metrics; pub use metrics::*; @@ -28,8 +31,5 @@ pub use traits::*; mod types; pub use types::*; -/// Hyperlane database utils -pub mod db; - #[cfg(feature = "oneline-eyre")] pub mod oneline_eyre; diff --git a/rust/hyperlane-base/src/settings/mod.rs b/rust/hyperlane-base/src/settings/mod.rs index aa7bee534..6eb127953 100644 --- a/rust/hyperlane-base/src/settings/mod.rs +++ b/rust/hyperlane-base/src/settings/mod.rs @@ -65,9 +65,6 @@ pub use base::*; pub use chains::*; pub use checkpoint_syncer::*; -/// Export this so they don't need to import paste. -#[doc(hidden)] -pub use paste; pub use signers::*; pub use trace::*; diff --git a/rust/hyperlane-base/src/settings/trace/mod.rs b/rust/hyperlane-base/src/settings/trace/mod.rs index 00d9cb4c5..21999d159 100644 --- a/rust/hyperlane-base/src/settings/trace/mod.rs +++ b/rust/hyperlane-base/src/settings/trace/mod.rs @@ -66,12 +66,12 @@ impl TracingConfig { if self.level < Level::DependencyTrace { // Reduce log noise from trusted libraries that we can reasonably assume are working correctly target_layer = target_layer - .with_target("hyper", Level::Info) + .with_target("hyper::", Level::Info) .with_target("rusoto_core", Level::Info) .with_target("rustls", Level::Info) .with_target("reqwest", Level::Info) .with_target("runtime", Level::Debug) - .with_target("h2", Level::Info) + .with_target("h2::", Level::Info) .with_target("tower", Level::Info) .with_target("tendermint", Level::Info) .with_target("tokio", Level::Debug) @@ -81,7 +81,9 @@ impl TracingConfig { if self.level < Level::Trace { // only show sqlx query logs at trace level - target_layer = target_layer.with_target("sqlx::query", Level::Warn); + target_layer = target_layer + .with_target("sqlx::query", Level::Warn) + .with_target("hyper::", Level::Warn); } let fmt_layer: LogOutputLayer<_> = self.fmt.into(); let err_layer = tracing_error::ErrorLayer::default(); diff --git a/rust/hyperlane-core/src/traits/pending_operation.rs b/rust/hyperlane-core/src/traits/pending_operation.rs index 476ec9e4b..ed6148bb8 100644 --- a/rust/hyperlane-core/src/traits/pending_operation.rs +++ b/rust/hyperlane-core/src/traits/pending_operation.rs @@ -1,12 +1,14 @@ +use serde::{Deserialize, Serialize}; use std::{ cmp::Ordering, fmt::{Debug, Display}, + io::Write, time::{Duration, Instant}, }; use crate::{ - ChainResult, FixedPointNumber, HyperlaneDomain, HyperlaneMessage, TryBatchAs, TxOutcome, H256, - U256, + ChainResult, Decode, Encode, FixedPointNumber, HyperlaneDomain, HyperlaneMessage, + HyperlaneProtocolError, TryBatchAs, TxOutcome, H256, U256, }; use async_trait::async_trait; use num::CheckedDiv; @@ -50,6 +52,9 @@ pub trait PendingOperation: Send + Sync + Debug + TryBatchAs { /// The domain this originates from. fn origin_domain_id(&self) -> u32; + /// Get the operation status from the local db, if there is one + fn retrieve_status_from_db(&self) -> Option; + /// The domain this operation will take place on. fn destination_domain(&self) -> &HyperlaneDomain; @@ -114,8 +119,10 @@ pub trait PendingOperation: Send + Sync + Debug + TryBatchAs { fn set_retries(&mut self, retries: u32); } -#[derive(Debug, Display, Clone)] +#[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq)] /// Status of a pending operation +/// WARNING: This enum is serialized to JSON and stored in the database, so to keep backwards compatibility, we shouldn't remove or rename any variants. +/// Adding new variants is fine. pub enum PendingOperationStatus { /// The operation is ready to be prepared for the first time, or has just been loaded from storage FirstPrepareAttempt, @@ -129,8 +136,38 @@ pub enum PendingOperationStatus { Confirm(ConfirmReason), } -#[derive(Display, Debug, Clone)] +impl Encode for PendingOperationStatus { + fn write_to(&self, writer: &mut W) -> std::io::Result + where + W: Write, + { + // Serialize to JSON and write to the writer, to avoid having to implement the encoding manually + let serialized = serde_json::to_vec(self) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::Other, "Failed to serialize"))?; + writer.write(&serialized) + } +} + +impl Decode for PendingOperationStatus { + fn read_from(reader: &mut R) -> Result + where + R: std::io::Read, + Self: Sized, + { + // Deserialize from JSON and read from the reader, to avoid having to implement the encoding / decoding manually + serde_json::from_reader(reader).map_err(|err| { + HyperlaneProtocolError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to deserialize. Error: {}", err), + )) + }) + } +} + +#[derive(Display, Debug, Clone, Serialize, Deserialize, PartialEq)] /// Reasons for repreparing an operation +/// WARNING: This enum is serialized to JSON and stored in the database, so to keep backwards compatibility, we shouldn't remove or rename any variants. +/// Adding new variants is fine. pub enum ReprepareReason { #[strum(to_string = "Error checking message delivery status")] /// Error checking message delivery status @@ -167,8 +204,10 @@ pub enum ReprepareReason { RevertedOrReorged, } -#[derive(Display, Debug, Clone)] +#[derive(Display, Debug, Clone, Serialize, Deserialize, PartialEq)] /// Reasons for repreparing an operation +/// WARNING: This enum is serialized to JSON and stored in the database, so to keep backwards compatibility, we shouldn't remove or rename any variants. +/// Adding new variants is fine. pub enum ConfirmReason { #[strum(to_string = "Submitted by this relayer")] /// Operation was submitted by this relayer @@ -274,3 +313,16 @@ pub enum PendingOperationResult { /// Send this message straight to the confirm queue Confirm(ConfirmReason), } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_encoding_pending_operation_status() { + let status = PendingOperationStatus::Retry(ReprepareReason::CouldNotFetchMetadata); + let encoded = status.to_vec(); + let decoded = PendingOperationStatus::read_from(&mut &encoded[..]).unwrap(); + assert_eq!(status, decoded); + } +} diff --git a/rust/utils/run-locally/src/invariants.rs b/rust/utils/run-locally/src/invariants.rs index b8ad984ee..e751bebdf 100644 --- a/rust/utils/run-locally/src/invariants.rs +++ b/rust/utils/run-locally/src/invariants.rs @@ -62,12 +62,17 @@ pub fn termination_invariants_met( .sum::(); let log_file_path = AGENT_LOGGING_DIR.join("RLY-output.log"); + const STORING_NEW_MESSAGE_LOG_MESSAGE: &str = "Storing new message in db"; + const LOOKING_FOR_EVENTS_LOG_MESSAGE: &str = "Looking for events in index range"; + const HYPER_INCOMING_BODY_LOG_MESSAGE: &str = "incoming body completed"; let relayer_logfile = File::open(log_file_path)?; - let gas_expenditure_log_count = - get_matching_lines(&relayer_logfile, GAS_EXPENDITURE_LOG_MESSAGE) - .unwrap() - .len(); - + let invariant_logs = &[ + STORING_NEW_MESSAGE_LOG_MESSAGE, + LOOKING_FOR_EVENTS_LOG_MESSAGE, + GAS_EXPENDITURE_LOG_MESSAGE, + HYPER_INCOMING_BODY_LOG_MESSAGE, + ]; + let log_counts = get_matching_lines(&relayer_logfile, invariant_logs); // Zero insertion messages don't reach `submit` stage where gas is spent, so we only expect these logs for the other messages. // TODO: Sometimes we find more logs than expected. This may either mean that gas is deducted twice for the same message due to a bug, // or that submitting the message transaction fails for some messages. Figure out which is the case and convert this check to @@ -75,12 +80,26 @@ pub fn termination_invariants_met( // EDIT: Having had a quick look, it seems like there are some legitimate reverts happening in the confirm step // (`Transaction attempting to process message either reverted or was reorged`) // in which case more gas expenditure logs than messages are expected. + let gas_expenditure_log_count = log_counts.get(GAS_EXPENDITURE_LOG_MESSAGE).unwrap(); assert!( - gas_expenditure_log_count as u32 >= total_messages_expected, + gas_expenditure_log_count >= &total_messages_expected, "Didn't record gas payment for all delivered messages. Got {} gas payment logs, expected at least {}", gas_expenditure_log_count, total_messages_expected ); + // These tests check that we fixed https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3915, where some logs would not show up + assert!( + log_counts.get(STORING_NEW_MESSAGE_LOG_MESSAGE).unwrap() > &0, + "Didn't find any logs about storing messages in db" + ); + assert!( + log_counts.get(LOOKING_FOR_EVENTS_LOG_MESSAGE).unwrap() > &0, + "Didn't find any logs about looking for events in index range" + ); + assert!( + log_counts.get(HYPER_INCOMING_BODY_LOG_MESSAGE).is_none(), + "Verbose logs not expected at the log level set in e2e" + ); let gas_payment_sealevel_events_count = fetch_metric( "9092", diff --git a/rust/utils/run-locally/src/utils.rs b/rust/utils/run-locally/src/utils.rs index 531970174..5e5dd6a12 100644 --- a/rust/utils/run-locally/src/utils.rs +++ b/rust/utils/run-locally/src/utils.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs::File; use std::io::{self, BufRead}; use std::path::{Path, PathBuf}; @@ -118,15 +119,18 @@ pub fn stop_child(child: &mut Child) { }; } -pub fn get_matching_lines(file: &File, search_string: &str) -> io::Result> { +pub fn get_matching_lines(file: &File, search_strings: &[&str]) -> HashMap { let reader = io::BufReader::new(file); - - // Read lines and collect those that contain the search string - let matching_lines: Vec = reader - .lines() - .map_while(Result::ok) - .filter(|line| line.contains(search_string)) - .collect(); - - Ok(matching_lines) + let mut matches = HashMap::new(); + + let mut lines = reader.lines(); + while let Some(Ok(line)) = lines.next() { + search_strings.iter().for_each(|search_string| { + if line.contains(search_string) { + let count = matches.entry(search_string.to_string()).or_insert(0); + *count += 1; + } + }); + } + matches } diff --git a/solidity/CHANGELOG.md b/solidity/CHANGELOG.md index f8089d59e..c0feda2a6 100644 --- a/solidity/CHANGELOG.md +++ b/solidity/CHANGELOG.md @@ -1,5 +1,15 @@ # @hyperlane-xyz/core +## 4.0.0 + +### Minor Changes + +- 44cc9bf6b: Add CLI command to support AVS validator status check + +### Patch Changes + +- @hyperlane-xyz/utils@4.0.0 + ## 3.16.0 ### Patch Changes diff --git a/solidity/contracts/interfaces/avs/vendored/IDelegationManager.sol b/solidity/contracts/interfaces/avs/vendored/IDelegationManager.sol index 8af9f453a..50fe9295a 100644 --- a/solidity/contracts/interfaces/avs/vendored/IDelegationManager.sol +++ b/solidity/contracts/interfaces/avs/vendored/IDelegationManager.sol @@ -20,6 +20,11 @@ interface IDelegationManager { uint32 stakerOptOutWindowBlocks; } + event OperatorMetadataURIUpdated( + address indexed operator, + string metadataURI + ); + function registerAsOperator( OperatorDetails calldata registeringOperatorDetails, string calldata metadataURI diff --git a/solidity/package.json b/solidity/package.json index ee53a0c7c..347dcc663 100644 --- a/solidity/package.json +++ b/solidity/package.json @@ -1,10 +1,10 @@ { "name": "@hyperlane-xyz/core", "description": "Core solidity contracts for Hyperlane", - "version": "3.16.0", + "version": "4.0.0", "dependencies": { "@eth-optimism/contracts": "^0.6.0", - "@hyperlane-xyz/utils": "3.16.0", + "@hyperlane-xyz/utils": "4.0.0", "@layerzerolabs/lz-evm-oapp-v2": "2.0.2", "@openzeppelin/contracts": "^4.9.3", "@openzeppelin/contracts-upgradeable": "^v4.9.3", diff --git a/typescript/ccip-server/CHANGELOG.md b/typescript/ccip-server/CHANGELOG.md index 19b944f34..edff9f9e5 100644 --- a/typescript/ccip-server/CHANGELOG.md +++ b/typescript/ccip-server/CHANGELOG.md @@ -1,5 +1,7 @@ # @hyperlane-xyz/ccip-server +## 4.0.0 + ## 3.16.0 ## 3.15.1 diff --git a/typescript/ccip-server/package.json b/typescript/ccip-server/package.json index 64929084b..4d92b4f63 100644 --- a/typescript/ccip-server/package.json +++ b/typescript/ccip-server/package.json @@ -1,6 +1,6 @@ { "name": "@hyperlane-xyz/ccip-server", - "version": "3.16.0", + "version": "4.0.0", "description": "CCIP server", "typings": "dist/index.d.ts", "typedocMain": "src/index.ts", diff --git a/typescript/cli/CHANGELOG.md b/typescript/cli/CHANGELOG.md index 1089a543b..67aba3f94 100644 --- a/typescript/cli/CHANGELOG.md +++ b/typescript/cli/CHANGELOG.md @@ -1,5 +1,43 @@ # @hyperlane-xyz/cli +## 4.0.0 + +### Major Changes + +- df6a18053: Release CLI v4.0.0. + +### Minor Changes + +- 44cc9bf6b: Add CLI command to support AVS validator status check +- b05ae38ac: Gracefully handle RPC failures during warp send & fix deriving hook error that prevents warp and core test messages on the cli. +- 9304fe241: Use metadata builders in message relaying +- 6398aab72: Upgrade registry to 2.1.1 +- 5c8ba0b85: Rename hyperlane config create chain -> hyperlane registry init. Rename all `configure` to `init` +- cd419c98a: Add a validator preFlightCheck command verifying that the validator has been announced for a given chain +- 35f869950: Add command to support creating agent configs +- bf7ad09da: feat(cli): add `warp --symbol` flag +- b0828b3d0: Reintroduce `ism read` and `hook read` commands +- 129bd871d: Add chain displayName prompt with default +- 4040db723: Fix createDefaultWarpIsmConfig to default to trusted relayer and fallback routing without prompts +- 6db9fa9ad: Implement hyperlane warp deploy +- bd3ca9195: Updates ci-test.sh to ci-advanced-test.sh. +- b7003cf35: Add stdout.rows to pagesize calculation with DEFAULT_PAGE_SIZE + +### Patch Changes + +- 3283eefd6: Removes default pattern for chain name when creating a new chain. +- 4dd2651ee: Add xerc20 limit lookups to warp read +- 6b63c5d82: Adds deployment support for IsmConfig within a WarpRouteConfig +- Updated dependencies [b05ae38ac] +- Updated dependencies [9304fe241] +- Updated dependencies [bdcbe1d16] +- Updated dependencies [6b63c5d82] +- Updated dependencies [e38d31685] +- Updated dependencies [e0f226806] +- Updated dependencies [6db9fa9ad] + - @hyperlane-xyz/sdk@4.0.0 + - @hyperlane-xyz/utils@4.0.0 + ## 3.16.0 ### Patch Changes diff --git a/typescript/cli/ci-test.sh b/typescript/cli/ci-advanced-test.sh similarity index 91% rename from typescript/cli/ci-test.sh rename to typescript/cli/ci-advanced-test.sh index 7fbeea04b..c794632cb 100755 --- a/typescript/cli/ci-test.sh +++ b/typescript/cli/ci-advanced-test.sh @@ -15,7 +15,7 @@ _main() { # with the routing over igp hook (which is closer to production deployment) TEST_TYPE=$1 if [ -z "$TEST_TYPE" ]; then - echo "Usage: ci-test.sh <$TEST_TYPE_PRESET_HOOK | $TEST_TYPE_CONFIGURED_HOOK | $TEST_TYPE_PI_CORE>" + echo "Usage: ci-advanced-test.sh <$TEST_TYPE_PRESET_HOOK | $TEST_TYPE_CONFIGURED_HOOK | $TEST_TYPE_PI_CORE>" exit 1 fi @@ -34,11 +34,11 @@ _main() { run_hyperlane_deploy_warp; run_hyperlane_send_message; - cd ./rust; + # cd ./rust; - run_validator; - run_relayer; - run_hyperlane_status; + # run_validator; + # run_relayer; + # run_hyperlane_status; kill_anvil; @@ -75,6 +75,7 @@ prepare_environment_vars() { } prepare_anvil() { + CHAIN1_PORT=8545 CHAIN2_PORT=8555 @@ -142,12 +143,11 @@ run_hyperlane_deploy_core_dry_run() { update_deployer_balance; echo -e "\nDry-running contract deployments to Alfajores" - yarn workspace @hyperlane-xyz/cli run hyperlane deploy core \ + yarn workspace @hyperlane-xyz/cli run hyperlane core deploy \ --dry-run alfajores \ --registry ${TEST_CONFIGS_PATH}/dry-run \ --overrides " " \ - $(if [ "$HOOK_FLAG" == "true" ]; then echo "--hook ${EXAMPLES_PATH}/hooks.yaml"; fi) \ - --ism ${TEST_CONFIGS_PATH}/dry-run/ism.yaml \ + --config ${EXAMPLES_PATH}/core-config.yaml \ --from-address 0xfaD1C94469700833717Fa8a3017278BC1cA8031C \ --yes @@ -162,7 +162,7 @@ run_hyperlane_deploy_warp_dry_run() { update_deployer_balance; echo -e "\nDry-running warp route deployments to Alfajores" - yarn workspace @hyperlane-xyz/cli run hyperlane deploy warp \ + yarn workspace @hyperlane-xyz/cli run hyperlane warp deploy \ --dry-run alfajores \ --overrides ${TEST_CONFIGS_PATH}/dry-run \ --config ${TEST_CONFIGS_PATH}/dry-run/warp-route-deployment.yaml \ @@ -175,14 +175,21 @@ run_hyperlane_deploy_warp_dry_run() { run_hyperlane_deploy_core() { update_deployer_balance; - echo -e "\nDeploying contracts to ${CHAIN1} and ${CHAIN2}" - yarn workspace @hyperlane-xyz/cli run hyperlane deploy core \ + echo -e "\nDeploying contracts to ${CHAIN1}" + yarn workspace @hyperlane-xyz/cli run hyperlane core deploy \ + --registry $REGISTRY_PATH \ + --overrides " " \ + --config ${EXAMPLES_PATH}/core-config.yaml \ + --chain $CHAIN1 \ + --key $ANVIL_KEY \ + --yes + + echo -e "\nDeploying contracts to ${CHAIN2}" + yarn workspace @hyperlane-xyz/cli run hyperlane core deploy \ --registry $REGISTRY_PATH \ --overrides " " \ - --targets ${CHAIN1},${CHAIN2} \ - $(if [ "$HOOK_FLAG" == "true" ]; then echo "--hook ${EXAMPLES_PATH}/hooks.yaml"; fi) \ - --ism $CORE_ISM_PATH \ - --agent /tmp/agent-config.json \ + --config ${EXAMPLES_PATH}/core-config.yaml \ + --chain $CHAIN2 \ --key $ANVIL_KEY \ --yes @@ -193,7 +200,7 @@ run_hyperlane_deploy_warp() { update_deployer_balance; echo -e "\nDeploying hypNative warp route" - yarn workspace @hyperlane-xyz/cli run hyperlane deploy warp \ + yarn workspace @hyperlane-xyz/cli run hyperlane warp deploy \ --registry $REGISTRY_PATH \ --overrides " " \ --config $WARP_DEPLOY_CONFIG_PATH \ @@ -206,7 +213,7 @@ run_hyperlane_deploy_warp() { /tmp/warp-collateral-deployment.json \ echo "Deploying hypCollateral warp route" - yarn workspace @hyperlane-xyz/cli run hyperlane deploy warp \ + yarn workspace @hyperlane-xyz/cli run hyperlane warp deploy \ --registry $REGISTRY_PATH \ --overrides " " \ --config /tmp/warp-collateral-deployment.json \ @@ -238,7 +245,7 @@ run_hyperlane_send_message() { WARP_CONFIG_FILE="$REGISTRY_PATH/deployments/warp_routes/FAKE/${CHAIN1}-${CHAIN2}-config.yaml" echo -e "\nSending test warp transfer" - yarn workspace @hyperlane-xyz/cli run hyperlane send transfer \ + yarn workspace @hyperlane-xyz/cli run hyperlane warp send \ --registry $REGISTRY_PATH \ --overrides " " \ --origin ${CHAIN1} \ diff --git a/typescript/cli/cli.ts b/typescript/cli/cli.ts index a8b9127f3..2328c2531 100644 --- a/typescript/cli/cli.ts +++ b/typescript/cli/cli.ts @@ -6,8 +6,8 @@ import type { LogFormat, LogLevel } from '@hyperlane-xyz/utils'; import './env.js'; import { avsCommand } from './src/commands/avs.js'; -import { chainsCommand } from './src/commands/chains.js'; import { configCommand } from './src/commands/config.js'; +import { coreCommand } from './src/commands/core.js'; import { deployCommand } from './src/commands/deploy.js'; import { hookCommand } from './src/commands/hook.js'; import { ismCommand } from './src/commands/ism.js'; @@ -19,9 +19,11 @@ import { registryUriCommandOption, skipConfirmationOption, } from './src/commands/options.js'; +import { registryCommand } from './src/commands/registry.js'; import { sendCommand } from './src/commands/send.js'; import { statusCommand } from './src/commands/status.js'; import { validatorCommand } from './src/commands/validator.js'; +import { warpCommand } from './src/commands/warp.js'; import { contextMiddleware } from './src/context/context.js'; import { configureLogger, errorRed } from './src/logger.js'; import { checkVersion } from './src/utils/version-check.js'; @@ -51,14 +53,16 @@ try { contextMiddleware, ]) .command(avsCommand) - .command(chainsCommand) .command(configCommand) + .command(coreCommand) .command(deployCommand) .command(hookCommand) .command(ismCommand) + .command(registryCommand) .command(sendCommand) .command(statusCommand) .command(validatorCommand) + .command(warpCommand) .version(VERSION) .demandCommand() .strict() diff --git a/typescript/cli/examples/core-config.yaml b/typescript/cli/examples/core-config.yaml new file mode 100644 index 000000000..cfc548543 --- /dev/null +++ b/typescript/cli/examples/core-config.yaml @@ -0,0 +1,19 @@ +# A config to define the core contract deployments +owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' +defaultIsm: + type: 'testIsm' + threshold: 1 # Number: Signatures required to approve a message + validators: # Array: List of validator addresses + - '0xa0ee7a142d267c1f36714e4a8f75612f20a79720' +defaultHook: + type: protocolFee + maxProtocolFee: '1000000000000000000' # in wei (string) + protocolFee: '200000000000000' # in wei (string) + beneficiary: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' + owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' +requiredHook: + type: protocolFee + maxProtocolFee: '1000000000000000000' # in wei (string) + protocolFee: '200000000000000' # in wei (string) + beneficiary: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' + owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' diff --git a/typescript/cli/examples/hooks.yaml b/typescript/cli/examples/hooks.yaml index 9fc19433a..d33bf0934 100644 --- a/typescript/cli/examples/hooks.yaml +++ b/typescript/cli/examples/hooks.yaml @@ -39,8 +39,10 @@ anvil1: oracleKey: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' overhead: anvil2: 50000 # gas amount (number) - gasOracleType: - anvil2: StorageGasOracle + oracleConfig: + anvil2: + gasPrice: '100' + tokenExchangeRate: '100' anvil2: required: type: protocolFee @@ -62,5 +64,7 @@ anvil2: oracleKey: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' overhead: anvil1: 50000 - gasOracleType: - anvil1: StorageGasOracle + oracleConfig: + anvil1: + gasPrice: '100' + tokenExchangeRate: '100' diff --git a/typescript/cli/package.json b/typescript/cli/package.json index 1cf7e607c..bcd5d077b 100644 --- a/typescript/cli/package.json +++ b/typescript/cli/package.json @@ -1,13 +1,13 @@ { "name": "@hyperlane-xyz/cli", - "version": "3.16.0", + "version": "4.0.0", "description": "A command-line utility for common Hyperlane operations", "dependencies": { "@aws-sdk/client-kms": "^3.577.0", "@aws-sdk/client-s3": "^3.577.0", "@hyperlane-xyz/registry": "2.1.1", - "@hyperlane-xyz/sdk": "3.16.0", - "@hyperlane-xyz/utils": "3.16.0", + "@hyperlane-xyz/sdk": "4.0.0", + "@hyperlane-xyz/utils": "4.0.0", "@inquirer/prompts": "^3.0.0", "asn1.js": "^5.4.1", "bignumber.js": "^9.1.1", @@ -18,7 +18,8 @@ "tsx": "^4.7.1", "yaml": "^2.4.1", "yargs": "^17.7.2", - "zod": "^3.21.2" + "zod": "^3.21.2", + "zod-validation-error": "^3.3.0" }, "devDependencies": { "@ethersproject/abi": "*", diff --git a/typescript/cli/src/avs/check.ts b/typescript/cli/src/avs/check.ts new file mode 100644 index 000000000..6730463e6 --- /dev/null +++ b/typescript/cli/src/avs/check.ts @@ -0,0 +1,449 @@ +import { Wallet } from 'ethers'; + +import { + ECDSAStakeRegistry__factory, + IDelegationManager__factory, + MerkleTreeHook__factory, + ValidatorAnnounce__factory, +} from '@hyperlane-xyz/core'; +import { ChainMap, ChainName, MultiProvider } from '@hyperlane-xyz/sdk'; +import { Address, ProtocolType, isObjEmpty } from '@hyperlane-xyz/utils'; + +import { CommandContext } from '../context/types.js'; +import { + errorRed, + log, + logBlue, + logBlueKeyValue, + logBoldBlue, + logDebug, + logGreen, + warnYellow, +} from '../logger.js'; +import { indentYamlOrJson } from '../utils/files.js'; +import { + getLatestMerkleTreeCheckpointIndex, + getLatestValidatorCheckpointIndexAndUrl, + getValidatorStorageLocations, + isValidatorSigningLatestCheckpoint, +} from '../validator/utils.js'; + +import { avsAddresses } from './config.js'; +import { readOperatorFromEncryptedJson } from './stakeRegistry.js'; + +interface ChainInfo { + storageLocation?: string; + latestMerkleTreeCheckpointIndex?: number; + latestValidatorCheckpointIndex?: number; + validatorSynced?: boolean; + warnings?: string[]; +} + +interface ValidatorInfo { + operatorAddress: Address; + operatorName?: string; + chains: ChainMap; +} + +export const checkValidatorAvsSetup = async ( + chain: string, + context: CommandContext, + operatorKeyPath?: string, + operatorAddress?: string, +) => { + logBlue( + `Checking AVS validator status for ${chain}, ${ + !operatorKeyPath ? 'this may take up to a minute to run' : '' + }...`, + ); + + const { multiProvider } = context; + + const topLevelErrors: string[] = []; + + let operatorWallet: Wallet | undefined; + if (operatorKeyPath) { + operatorWallet = await readOperatorFromEncryptedJson(operatorKeyPath); + } + + const avsOperatorRecord = await getAvsOperators( + chain, + multiProvider, + topLevelErrors, + operatorAddress ?? operatorWallet?.address, + ); + + await setOperatorName( + chain, + avsOperatorRecord, + multiProvider, + topLevelErrors, + ); + + if (!isObjEmpty(avsOperatorRecord)) { + await setValidatorInfo(context, avsOperatorRecord, topLevelErrors); + } + + logOutput(avsOperatorRecord, topLevelErrors); +}; + +const getAvsOperators = async ( + chain: string, + multiProvider: MultiProvider, + topLevelErrors: string[], + operatorKey?: string, +): Promise> => { + const avsOperators: Record = {}; + + const ecdsaStakeRegistryAddress = getEcdsaStakeRegistryAddress( + chain, + topLevelErrors, + ); + + if (!ecdsaStakeRegistryAddress) { + return avsOperators; + } + + const ecdsaStakeRegistry = ECDSAStakeRegistry__factory.connect( + ecdsaStakeRegistryAddress, + multiProvider.getProvider(chain), + ); + + if (operatorKey) { + // If operator key is provided, only fetch the operator's validator info + const signingKey = await ecdsaStakeRegistry.getLastestOperatorSigningKey( + operatorKey, + ); + avsOperators[signingKey] = { + operatorAddress: operatorKey, + chains: {}, + }; + + return avsOperators; + } + + const filter = ecdsaStakeRegistry.filters.SigningKeyUpdate(null, null); + const provider = multiProvider.getProvider(chain); + const latestBlock = await provider.getBlockNumber(); + const blockLimit = 50000; // 50k blocks per query + + let fromBlock = 1625972; // when ecdsaStakeRegistry was deployed + + while (fromBlock < latestBlock) { + const toBlock = Math.min(fromBlock + blockLimit, latestBlock); + const logs = await ecdsaStakeRegistry.queryFilter( + filter, + fromBlock, + toBlock, + ); + + logs.forEach((log) => { + const event = ecdsaStakeRegistry.interface.parseLog(log); + const operatorKey = event.args.operator; + const signingKey = event.args.newSigningKey; + + if (avsOperators[signingKey]) { + avsOperators[signingKey].operatorAddress = operatorKey; + } else { + avsOperators[signingKey] = { + operatorAddress: operatorKey, + chains: {}, + }; + } + }); + + fromBlock = toBlock + 1; + } + + return avsOperators; +}; + +const getAVSMetadataURI = async ( + chain: string, + operatorAddress: string, + multiProvider: MultiProvider, +): Promise => { + const delegationManagerAddress = avsAddresses[chain]['delegationManager']; + + const delegationManager = IDelegationManager__factory.connect( + delegationManagerAddress, + multiProvider.getProvider(chain), + ); + + const filter = delegationManager.filters.OperatorMetadataURIUpdated( + operatorAddress, + null, + ); + + const provider = multiProvider.getProvider(chain); + const latestBlock = await provider.getBlockNumber(); + const blockLimit = 50000; // 50k blocks per query + + let fromBlock = 17445563; + while (fromBlock < latestBlock) { + const toBlock = Math.min(fromBlock + blockLimit, latestBlock); + const logs = await delegationManager.queryFilter( + filter, + fromBlock, + toBlock, + ); + + if (logs.length > 0) { + const event = delegationManager.interface.parseLog(logs[0]); + return event.args.metadataURI; + } + + fromBlock = toBlock + 1; + } + + return undefined; +}; + +const setOperatorName = async ( + chain: string, + avsOperatorRecord: Record, + multiProvider: MultiProvider, + topLevelErrors: string[] = [], +) => { + for (const [_, validatorInfo] of Object.entries(avsOperatorRecord)) { + const metadataURI = await getAVSMetadataURI( + chain, + validatorInfo.operatorAddress, + multiProvider, + ); + + if (metadataURI) { + const operatorName = await fetchOperatorName(metadataURI); + if (operatorName) { + validatorInfo.operatorName = operatorName; + } else { + topLevelErrors.push( + `❗️ Failed to fetch operator name from metadataURI: ${metadataURI}`, + ); + } + } + } +}; + +const setValidatorInfo = async ( + context: CommandContext, + avsOperatorRecord: Record, + topLevelErrors: string[], +) => { + const { multiProvider, registry, chainMetadata } = context; + const failedToReadChains: string[] = []; + + const validatorAddresses = Object.keys(avsOperatorRecord); + + const chains = await registry.getChains(); + const addresses = await registry.getAddresses(); + + for (const chain of chains) { + // skip if chain is not an Ethereum chain + if (chainMetadata[chain].protocol !== ProtocolType.Ethereum) continue; + + const chainAddresses = addresses[chain]; + + // skip if no contract addresses are found for this chain + if (chainAddresses === undefined) continue; + + if (!chainAddresses.validatorAnnounce) { + topLevelErrors.push(`❗️ ValidatorAnnounce is not deployed on ${chain}`); + } + + if (!chainAddresses.merkleTreeHook) { + topLevelErrors.push(`❗️ MerkleTreeHook is not deployed on ${chain}`); + } + + if (!chainAddresses.validatorAnnounce || !chainAddresses.merkleTreeHook) { + continue; + } + + const validatorAnnounce = ValidatorAnnounce__factory.connect( + chainAddresses.validatorAnnounce, + multiProvider.getProvider(chain), + ); + + const merkleTreeHook = MerkleTreeHook__factory.connect( + chainAddresses.merkleTreeHook, + multiProvider.getProvider(chain), + ); + + const latestMerkleTreeCheckpointIndex = + await getLatestMerkleTreeCheckpointIndex(merkleTreeHook, chain); + + const validatorStorageLocations = await getValidatorStorageLocations( + validatorAnnounce, + validatorAddresses, + chain, + ); + + if (!validatorStorageLocations) { + failedToReadChains.push(chain); + continue; + } + + for (let i = 0; i < validatorAddresses.length; i++) { + const validatorAddress = validatorAddresses[i]; + const storageLocation = validatorStorageLocations[i]; + const warnings: string[] = []; + + // Skip if no storage location is found, address is not validating on this chain or if storage location string doesn't not start with s3:// + if ( + storageLocation.length === 0 || + !storageLocation[0].startsWith('s3://') + ) { + continue; + } + + const [latestValidatorCheckpointIndex, latestCheckpointUrl] = + (await getLatestValidatorCheckpointIndexAndUrl(storageLocation[0])) ?? [ + undefined, + undefined, + ]; + + if (!latestMerkleTreeCheckpointIndex) { + warnings.push( + `❗️ Failed to fetch latest checkpoint index of merkleTreeHook on ${chain}.`, + ); + } + + if (!latestValidatorCheckpointIndex) { + warnings.push( + `❗️ Failed to fetch latest signed checkpoint index of validator on ${chain}, this is likely due to failing to read an S3 bucket`, + ); + } + + let validatorSynced = undefined; + if (latestMerkleTreeCheckpointIndex && latestValidatorCheckpointIndex) { + validatorSynced = isValidatorSigningLatestCheckpoint( + latestValidatorCheckpointIndex, + latestMerkleTreeCheckpointIndex, + ); + } + + const chainInfo: ChainInfo = { + storageLocation: latestCheckpointUrl, + latestMerkleTreeCheckpointIndex, + latestValidatorCheckpointIndex, + validatorSynced, + warnings, + }; + + const validatorInfo = avsOperatorRecord[validatorAddress]; + if (validatorInfo) { + validatorInfo.chains[chain as ChainName] = chainInfo; + } + } + } + + if (failedToReadChains.length > 0) { + topLevelErrors.push( + `❗️ Failed to read storage locations onchain for ${failedToReadChains.join( + ', ', + )}`, + ); + } +}; + +const logOutput = ( + avsKeysRecord: Record, + topLevelErrors: string[], +) => { + if (topLevelErrors.length > 0) { + for (const error of topLevelErrors) { + errorRed(error); + } + } + + for (const [validatorAddress, data] of Object.entries(avsKeysRecord)) { + log('\n\n'); + if (data.operatorName) logBlueKeyValue('Operator name', data.operatorName); + logBlueKeyValue('Operator address', data.operatorAddress); + logBlueKeyValue('Validator address', validatorAddress); + + if (!isObjEmpty(data.chains)) { + logBoldBlue(indentYamlOrJson('Validating on...', 2)); + for (const [chain, chainInfo] of Object.entries(data.chains)) { + logBoldBlue(indentYamlOrJson(chain, 2)); + + if (chainInfo.storageLocation) { + logBlueKeyValue( + indentYamlOrJson('Storage location', 2), + chainInfo.storageLocation, + ); + } + + if (chainInfo.latestMerkleTreeCheckpointIndex) { + logBlueKeyValue( + indentYamlOrJson('Latest merkle tree checkpoint index', 2), + String(chainInfo.latestMerkleTreeCheckpointIndex), + ); + } + + if (chainInfo.latestValidatorCheckpointIndex) { + logBlueKeyValue( + indentYamlOrJson('Latest validator checkpoint index', 2), + String(chainInfo.latestValidatorCheckpointIndex), + ); + + if (chainInfo.validatorSynced) { + logGreen( + indentYamlOrJson('✅ Validator is signing latest checkpoint', 2), + ); + } else { + errorRed( + indentYamlOrJson( + '❌ Validator is not signing latest checkpoint', + 2, + ), + ); + } + } else { + errorRed( + indentYamlOrJson( + '❌ Failed to fetch latest signed checkpoint index', + 2, + ), + ); + } + + if (chainInfo.warnings && chainInfo.warnings.length > 0) { + warnYellow( + indentYamlOrJson('The following warnings were encountered:', 2), + ); + for (const warning of chainInfo.warnings) { + warnYellow(indentYamlOrJson(warning, 3)); + } + } + } + } else { + logBlue('Validator is not validating on any chain'); + } + } +}; + +const getEcdsaStakeRegistryAddress = ( + chain: string, + topLevelErrors: string[], +): Address | undefined => { + try { + return avsAddresses[chain]['ecdsaStakeRegistry']; + } catch (err) { + topLevelErrors.push( + `❗️ EcdsaStakeRegistry address not found for ${chain}`, + ); + return undefined; + } +}; + +const fetchOperatorName = async (metadataURI: string) => { + try { + const response = await fetch(metadataURI); + const data = await response.json(); + return data['name']; + } catch (err) { + logDebug(`Failed to fetch operator name from ${metadataURI}: ${err}`); + return undefined; + } +}; diff --git a/typescript/cli/src/avs/config.ts b/typescript/cli/src/avs/config.ts index 79715a676..72b4f0dfc 100644 --- a/typescript/cli/src/avs/config.ts +++ b/typescript/cli/src/avs/config.ts @@ -3,6 +3,7 @@ import { Address } from '@hyperlane-xyz/utils'; interface AVSContracts { avsDirectory: Address; + delegationManager: Address; proxyAdmin: Address; ecdsaStakeRegistry: Address; hyperlaneServiceManager: Address; @@ -12,12 +13,14 @@ interface AVSContracts { export const avsAddresses: ChainMap = { holesky: { avsDirectory: '0x055733000064333CaDDbC92763c58BF0192fFeBf', + delegationManager: '0xA44151489861Fe9e3055d95adC98FbD462B948e7', proxyAdmin: '0x33dB966328Ea213b0f76eF96CA368AB37779F065', ecdsaStakeRegistry: '0xFfa913705484C9BAea32Ffe9945BeA099A1DFF72', hyperlaneServiceManager: '0xc76E477437065093D353b7d56c81ff54D167B0Ab', }, ethereum: { avsDirectory: '0x135dda560e946695d6f155dacafc6f1f25c1f5af', + delegationManager: '0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A', proxyAdmin: '0x75EE15Ee1B4A75Fa3e2fDF5DF3253c25599cc659', ecdsaStakeRegistry: '0x272CF0BB70D3B4f79414E0823B426d2EaFd48910', hyperlaneServiceManager: '0xe8E59c6C8B56F2c178f63BCFC4ce5e5e2359c8fc', diff --git a/typescript/cli/src/avs/stakeRegistry.ts b/typescript/cli/src/avs/stakeRegistry.ts index d1bdd0716..a9c50918d 100644 --- a/typescript/cli/src/avs/stakeRegistry.ts +++ b/typescript/cli/src/avs/stakeRegistry.ts @@ -109,7 +109,7 @@ export async function deregisterOperator({ ); } -async function readOperatorFromEncryptedJson( +export async function readOperatorFromEncryptedJson( operatorKeyPath: string, ): Promise { const encryptedJson = readFileAtPath(resolvePath(operatorKeyPath)); @@ -119,7 +119,7 @@ async function readOperatorFromEncryptedJson( message: 'Enter the password for the operator key file: ', }); - return await Wallet.fromEncryptedJson(encryptedJson, keyFilePassword); + return Wallet.fromEncryptedJson(encryptedJson, keyFilePassword); } async function getOperatorSignature( diff --git a/typescript/cli/src/commands/avs.ts b/typescript/cli/src/commands/avs.ts index ce238df62..66b1a9264 100644 --- a/typescript/cli/src/commands/avs.ts +++ b/typescript/cli/src/commands/avs.ts @@ -1,14 +1,21 @@ import { CommandModule, Options } from 'yargs'; import { ChainName } from '@hyperlane-xyz/sdk'; -import { Address } from '@hyperlane-xyz/utils'; +import { Address, ProtocolType } from '@hyperlane-xyz/utils'; +import { checkValidatorAvsSetup } from '../avs/check.js'; import { deregisterOperator, registerOperatorWithSignature, } from '../avs/stakeRegistry.js'; import { CommandModuleWithWriteContext } from '../context/types.js'; -import { log } from '../logger.js'; +import { errorRed, log } from '../logger.js'; + +import { + avsChainCommandOption, + demandOption, + operatorKeyPathCommandOption, +} from './options.js'; /** * Parent command @@ -20,6 +27,7 @@ export const avsCommand: CommandModule = { yargs .command(registerCommand) .command(deregisterCommand) + .command(checkCommand) .version(false) .demandCommand(), handler: () => log('Command required'), @@ -29,17 +37,8 @@ export const avsCommand: CommandModule = { * Registration command */ export const registrationOptions: { [k: string]: Options } = { - chain: { - type: 'string', - description: 'Chain to interact with the AVS on', - demandOption: true, - choices: ['holesky', 'ethereum'], - }, - operatorKeyPath: { - type: 'string', - description: 'Path to the operator key file', - demandOption: true, - }, + chain: avsChainCommandOption, + operatorKeyPath: demandOption(operatorKeyPathCommandOption), avsSigningKeyAddress: { type: 'string', description: 'Address of the AVS signing key', @@ -87,3 +86,47 @@ const deregisterCommand: CommandModuleWithWriteContext<{ process.exit(0); }, }; + +const checkCommand: CommandModuleWithWriteContext<{ + chain: ChainName; + operatorKeyPath?: string; + operatorAddress?: string; +}> = { + command: 'check', + describe: 'Check AVS', + builder: { + chain: avsChainCommandOption, + operatorKeyPath: operatorKeyPathCommandOption, + operatorAddress: { + type: 'string', + description: 'Address of the operator to check', + }, + }, + handler: async ({ context, chain, operatorKeyPath, operatorAddress }) => { + const { multiProvider } = context; + + // validate chain + if (!multiProvider.hasChain(chain)) { + errorRed( + `❌ No metadata found for ${chain}. Ensure it is included in your configured registry.`, + ); + process.exit(1); + } + + const chainMetadata = multiProvider.getChainMetadata(chain); + + if (chainMetadata.protocol !== ProtocolType.Ethereum) { + errorRed(`\n❌ Validator AVS check only supports EVM chains. Exiting.`); + process.exit(1); + } + + await checkValidatorAvsSetup( + chain, + context, + operatorKeyPath, + operatorAddress, + ); + + process.exit(0); + }, +}; diff --git a/typescript/cli/src/commands/config.ts b/typescript/cli/src/commands/config.ts index bf30e78ee..7b145ca44 100644 --- a/typescript/cli/src/commands/config.ts +++ b/typescript/cli/src/commands/config.ts @@ -1,20 +1,13 @@ import { CommandModule } from 'yargs'; -import { createChainConfig, readChainConfigs } from '../config/chain.js'; -import { createHooksConfigMap } from '../config/hooks.js'; -import { createIsmConfigMap, readIsmConfig } from '../config/ism.js'; -import { - createMultisigConfig, - readMultisigConfig, -} from '../config/multisig.js'; -import { - createWarpRouteDeployConfig, - readWarpRouteDeployConfig, -} from '../config/warp.js'; +import { readChainConfigs } from '../config/chain.js'; +import { readIsmConfig } from '../config/ism.js'; +import { readMultisigConfig } from '../config/multisig.js'; +import { readWarpRouteDeployConfig } from '../config/warp.js'; import { CommandModuleWithContext } from '../context/types.js'; import { log, logGreen } from '../logger.js'; -import { inputFileCommandOption, outputFileCommandOption } from './options.js'; +import { inputFileCommandOption } from './options.js'; /** * Parent command @@ -23,90 +16,10 @@ export const configCommand: CommandModule = { command: 'config', describe: 'Create or validate Hyperlane configs', builder: (yargs) => - yargs - .command(createCommand) - .command(validateCommand) - .version(false) - .demandCommand(), + yargs.command(validateCommand).version(false).demandCommand(), handler: () => log('Command required'), }; -/** - * Create commands - */ -const createCommand: CommandModule = { - command: 'create', - describe: 'Create a new Hyperlane config', - builder: (yargs) => - yargs - .command(createChainConfigCommand) - .command(createIsmConfigCommand) - .command(createHookConfigCommand) - .command(createWarpRouteDeployConfigCommand) - .version(false) - .demandCommand(), - handler: () => log('Command required'), -}; - -const createChainConfigCommand: CommandModuleWithContext<{}> = { - command: 'chain', - describe: 'Create a new, minimal Hyperlane chain config (aka chain metadata)', - handler: async ({ context }) => { - await createChainConfig({ context }); - process.exit(0); - }, -}; - -const createIsmConfigCommand: CommandModuleWithContext<{ - out: string; - advanced: boolean; -}> = { - command: 'ism', - describe: 'Create a basic or advanced ISM config for a validator set', - builder: { - out: outputFileCommandOption('./configs/ism.yaml'), - advanced: { - type: 'boolean', - describe: 'Create an advanced ISM configuration', - default: false, - }, - }, - handler: async ({ context, out, advanced }) => { - if (advanced) { - await createIsmConfigMap({ context, outPath: out }); - } else { - await createMultisigConfig({ context, outPath: out }); - } - process.exit(0); - }, -}; - -const createHookConfigCommand: CommandModuleWithContext<{ out: string }> = { - command: 'hooks', - describe: 'Create a new hooks config (required & default)', - builder: { - out: outputFileCommandOption('./configs/hooks.yaml'), - }, - handler: async ({ context, out }) => { - await createHooksConfigMap({ context, outPath: out }); - process.exit(0); - }, -}; - -const createWarpRouteDeployConfigCommand: CommandModuleWithContext<{ - out: string; -}> = { - command: 'warp', - describe: 'Create a new Warp Route deployment config', - builder: { - out: outputFileCommandOption('./configs/warp-route-deployment.yaml'), - }, - handler: async ({ context, out }) => { - await createWarpRouteDeployConfig({ context, outPath: out }); - process.exit(0); - }, -}; - /** * Validate commands */ diff --git a/typescript/cli/src/commands/core.ts b/typescript/cli/src/commands/core.ts new file mode 100644 index 000000000..54a5786f6 --- /dev/null +++ b/typescript/cli/src/commands/core.ts @@ -0,0 +1,155 @@ +import { stringify as yamlStringify } from 'yaml'; +import { CommandModule } from 'yargs'; + +import { EvmCoreReader } from '@hyperlane-xyz/sdk'; + +import { createCoreDeployConfig } from '../config/core.js'; +import { + CommandModuleWithContext, + CommandModuleWithWriteContext, +} from '../context/types.js'; +import { runCoreDeploy } from '../deploy/core.js'; +import { evaluateIfDryRunFailure } from '../deploy/dry-run.js'; +import { log, logGray, logGreen } from '../logger.js'; +import { + indentYamlOrJson, + readYamlOrJson, + writeYamlOrJson, +} from '../utils/files.js'; + +import { + chainCommandOption, + dryRunCommandOption, + fromAddressCommandOption, + outputFileCommandOption, +} from './options.js'; + +/** + * Parent command + */ +export const coreCommand: CommandModule = { + command: 'core', + describe: 'Manage core Hyperlane contracts & configs', + builder: (yargs) => + yargs + .command(deploy) + .command(init) + .command(read) + .version(false) + .demandCommand(), + handler: () => log('Command required'), +}; + +/** + * Generates a command module for deploying Hyperlane contracts, given a command + * + * @param commandName - the deploy command key used to look up the deployFunction + * @returns A command module used to deploy Hyperlane contracts. + */ +export const deploy: CommandModuleWithWriteContext<{ + chain: string; + config: string; + dryRun: string; + fromAddress: string; +}> = { + command: 'deploy', + describe: 'Deploy Hyperlane contracts', + builder: { + chain: chainCommandOption, + config: outputFileCommandOption( + './configs/core-config.yaml', + false, + 'The path to a JSON or YAML file with a core deployment config.', + ), + 'dry-run': dryRunCommandOption, + 'from-address': fromAddressCommandOption, + }, + handler: async ({ context, chain, config: configFilePath, dryRun }) => { + logGray(`Hyperlane permissionless deployment${dryRun ? ' dry-run' : ''}`); + logGray(`------------------------------------------------`); + + try { + await runCoreDeploy({ + context, + chain, + config: readYamlOrJson(configFilePath), + }); + } catch (error: any) { + evaluateIfDryRunFailure(error, dryRun); + throw error; + } + process.exit(0); + }, +}; + +export const init: CommandModuleWithContext<{ + advanced: boolean; + config: string; +}> = { + command: 'init', + describe: 'Create a core configuration, including ISMs and hooks.', + builder: { + advanced: { + type: 'boolean', + describe: 'Create an advanced ISM & hook configuration', + default: false, + }, + config: outputFileCommandOption( + './configs/core-config.yaml', + false, + 'The path to output a Core Config JSON or YAML file.', + ), + }, + handler: async ({ context, advanced, config: configFilePath }) => { + logGray('Hyperlane Core Configure'); + logGray('------------------------'); + + await createCoreDeployConfig({ + context, + configFilePath, + advanced, + }); + + process.exit(0); + }, +}; + +export const read: CommandModuleWithContext<{ + chain: string; + mailbox: string; + config: string; +}> = { + command: 'read', + describe: 'Reads onchain Core configuration for a given mailbox address', + builder: { + chain: { + ...chainCommandOption, + demandOption: true, + }, + mailbox: { + type: 'string', + description: 'Mailbox address used to derive the core config', + demandOption: true, + }, + config: outputFileCommandOption( + './configs/core-config.yaml', + false, + 'The path to output a Core Config JSON or YAML file.', + ), + }, + handler: async ({ context, chain, mailbox, config: configFilePath }) => { + logGray('Hyperlane Core Read'); + logGray('-------------------'); + + const evmCoreReader = new EvmCoreReader(context.multiProvider, chain); + const coreConfig = await evmCoreReader.deriveCoreConfig(mailbox); + + writeYamlOrJson(configFilePath, coreConfig, 'yaml'); + logGreen( + `✅ Warp route config written successfully to ${configFilePath}:\n`, + ); + log(indentYamlOrJson(yamlStringify(coreConfig, null, 2), 4)); + + process.exit(0); + }, +}; diff --git a/typescript/cli/src/commands/deploy.ts b/typescript/cli/src/commands/deploy.ts index 7de7fb182..aa73f1899 100644 --- a/typescript/cli/src/commands/deploy.ts +++ b/typescript/cli/src/commands/deploy.ts @@ -1,25 +1,13 @@ import { CommandModule } from 'yargs'; -import { - CommandModuleWithContext, - CommandModuleWithWriteContext, -} from '../context/types.js'; +import { CommandModuleWithContext } from '../context/types.js'; import { runKurtosisAgentDeploy } from '../deploy/agent.js'; -import { runCoreDeploy } from '../deploy/core.js'; -import { evaluateIfDryRunFailure } from '../deploy/dry-run.js'; -import { runWarpRouteDeploy } from '../deploy/warp.js'; import { log, logGray } from '../logger.js'; import { agentConfigCommandOption, agentTargetsCommandOption, - coreTargetsCommandOption, - dryRunCommandOption, - fromAddressCommandOption, - hookCommandOption, - ismCommandOption, originCommandOption, - warpDeploymentConfigCommandOption, } from './options.js'; /** @@ -29,12 +17,7 @@ export const deployCommand: CommandModule = { command: 'deploy', describe: 'Permissionlessly deploy a Hyperlane contracts or extensions', builder: (yargs) => - yargs - .command(coreCommand) - .command(warpCommand) - .command(agentCommand) - .version(false) - .demandCommand(), + yargs.command(agentCommand).version(false).demandCommand(), handler: () => log('Command required'), }; @@ -65,79 +48,3 @@ const agentCommand: CommandModuleWithContext<{ process.exit(0); }, }; - -/** - * Core command - */ -const coreCommand: CommandModuleWithWriteContext<{ - targets: string; - ism?: string; - hook?: string; - 'dry-run': string; - 'from-address': string; - agent: string; -}> = { - command: 'core', - describe: 'Deploy core Hyperlane contracts', - builder: { - targets: coreTargetsCommandOption, - ism: ismCommandOption, - hook: hookCommandOption, - agent: agentConfigCommandOption(false, './configs/agent.json'), - 'dry-run': dryRunCommandOption, - 'from-address': fromAddressCommandOption, - }, - handler: async ({ context, targets, ism, hook, agent, dryRun }) => { - logGray( - `Hyperlane permissionless core deployment${dryRun ? ' dry-run' : ''}`, - ); - logGray(`------------------------------------------------`); - - try { - const chains = targets?.split(',').map((r: string) => r.trim()); - await runCoreDeploy({ - context, - chains, - ismConfigPath: ism, - hookConfigPath: hook, - agentOutPath: agent, - }); - } catch (error: any) { - evaluateIfDryRunFailure(error, dryRun); - throw error; - } - process.exit(0); - }, -}; - -/** - * Warp command - */ -const warpCommand: CommandModuleWithWriteContext<{ - config: string; - 'dry-run': string; - 'from-address': string; -}> = { - command: 'warp', - describe: 'Deploy Warp Route contracts', - builder: { - config: warpDeploymentConfigCommandOption, - 'dry-run': dryRunCommandOption, - 'from-address': fromAddressCommandOption, - }, - handler: async ({ context, config, dryRun }) => { - logGray(`Hyperlane warp route deployment${dryRun ? ' dry-run' : ''}`); - logGray('------------------------------------------------'); - - try { - await runWarpRouteDeploy({ - context, - warpRouteDeploymentConfigPath: config, - }); - } catch (error: any) { - evaluateIfDryRunFailure(error, dryRun); - throw error; - } - process.exit(0); - }, -}; diff --git a/typescript/cli/src/commands/hook.ts b/typescript/cli/src/commands/hook.ts index 34410dcbd..986b0d2be 100644 --- a/typescript/cli/src/commands/hook.ts +++ b/typescript/cli/src/commands/hook.ts @@ -2,7 +2,7 @@ import { CommandModule } from 'yargs'; import { CommandModuleWithContext } from '../context/types.js'; import { readHookConfig } from '../hook/read.js'; -import { log } from '../logger.js'; +import { log, logGray } from '../logger.js'; import { addressCommandOption, @@ -41,6 +41,8 @@ export const read: CommandModuleWithContext<{ out: outputFileCommandOption(), }, handler: async (args) => { + logGray('Hyperlane Hook Read'); + logGray('------------------'); await readHookConfig(args); process.exit(0); }, diff --git a/typescript/cli/src/commands/ism.ts b/typescript/cli/src/commands/ism.ts index 831ea7207..c4363ec82 100644 --- a/typescript/cli/src/commands/ism.ts +++ b/typescript/cli/src/commands/ism.ts @@ -2,7 +2,7 @@ import { CommandModule } from 'yargs'; import { CommandModuleWithContext } from '../context/types.js'; import { readIsmConfig } from '../ism/read.js'; -import { log } from '../logger.js'; +import { log, logGray } from '../logger.js'; import { addressCommandOption, @@ -46,6 +46,8 @@ export const read: CommandModuleWithContext<{ out: outputFileCommandOption(), }, handler: async (argv) => { + logGray('Hyperlane ISM Read'); + logGray('------------------'); await readIsmConfig(argv); process.exit(0); }, diff --git a/typescript/cli/src/commands/options.ts b/typescript/cli/src/commands/options.ts index 3774b43cb..f91df414c 100644 --- a/typescript/cli/src/commands/options.ts +++ b/typescript/cli/src/commands/options.ts @@ -8,6 +8,11 @@ import { ENV } from '../utils/env.js'; /* Global options */ +export const demandOption = (option: Options): Options => ({ + ...option, + demandOption: true, +}); + export const logFormatCommandOption: Options = { type: 'string', description: 'Log output format', @@ -91,8 +96,6 @@ export const warpCoreConfigCommandOption: Options = { type: 'string', description: 'File path to Warp Route config', alias: 'w', - // TODO make this optional and have the commands get it from the registry - demandOption: true, }; export const agentConfigCommandOption = ( @@ -106,11 +109,23 @@ export const agentConfigCommandOption = ( default: defaultPath, }); -export const outputFileCommandOption = (defaultPath?: string): Options => ({ +export const chainTargetsCommandOption: Options = { + type: 'string', + description: 'Comma-separated list of chain names', + alias: 'c', + demandOption: true, +}; + +export const outputFileCommandOption = ( + defaultPath?: string, + demandOption = false, + description = 'Output file path', +): Options => ({ type: 'string', - description: 'Output file path', + description, default: defaultPath, alias: 'o', + demandOption, }); export const inputFileCommandOption: Options = { @@ -138,6 +153,17 @@ export const chainCommandOption: Options = { description: 'The specific chain to perform operations with.', }; +export const symbolCommandOption: Options = { + type: 'string', + description: 'Token symbol (e.g. ETH, USDC)', +}; + +export const validatorCommandOption: Options = { + type: 'string', + description: 'Comma separated list of validator addresses', + demandOption: true, +}; + export const addressCommandOption = ( description: string, demandOption = false, @@ -178,3 +204,15 @@ export const awsKeyIdCommandOption: Options = { type: 'string', describe: 'Key ID from AWS KMS', }; + +export const operatorKeyPathCommandOption: Options = { + type: 'string', + description: 'Path to the operator key file', +}; + +export const avsChainCommandOption: Options = { + type: 'string', + description: 'Chain to interact with the AVS on', + demandOption: true, + choices: ['holesky', 'ethereum'], +}; diff --git a/typescript/cli/src/commands/chains.ts b/typescript/cli/src/commands/registry.ts similarity index 53% rename from typescript/cli/src/commands/chains.ts rename to typescript/cli/src/commands/registry.ts index 9d0e970fa..9334064d5 100644 --- a/typescript/cli/src/commands/chains.ts +++ b/typescript/cli/src/commands/registry.ts @@ -1,21 +1,28 @@ import { CommandModule } from 'yargs'; -import { CommandModuleWithContext } from '../context/types.js'; -import { log, logBlue, logGray, logTable } from '../logger.js'; +import { createAgentConfig } from '../config/agent.js'; +import { createChainConfig } from '../config/chain.js'; +import { CommandContext, CommandModuleWithContext } from '../context/types.js'; +import { log, logBlue, logGray, logRed, logTable } from '../logger.js'; -const ChainTypes = ['mainnet', 'testnet']; -type ChainType = (typeof ChainTypes)[number]; +import { + chainTargetsCommandOption, + outputFileCommandOption, +} from './options.js'; +import { ChainType, ChainTypes } from './types.js'; /** * Parent command */ -export const chainsCommand: CommandModule = { - command: 'chains', - describe: 'View information about Hyperlane chains in a registry', +export const registryCommand: CommandModule = { + command: 'registry', + describe: 'Manage Hyperlane chains in a registry', builder: (yargs) => yargs - .command(listCommand) .command(addressesCommand) + .command(createAgentConfigCommand) + .command(initCommand) + .command(listCommand) .version(false) .demandCommand(), handler: () => log('Command required'), @@ -88,3 +95,59 @@ const addressesCommand: CommandModuleWithContext<{ name: string }> = { } }, }; + +/** + * agent-config command + */ +const createAgentConfigCommand: CommandModuleWithContext<{ + chains: string; + out: string; +}> = { + command: 'agent-config', + describe: 'Create a new agent config', + + builder: { + chains: chainTargetsCommandOption, + out: outputFileCommandOption( + './configs/agent-config.json', + false, + 'The path to output an agent config JSON file.', + ), + }, + handler: async ({ + context, + chains, + out, + }: { + context: CommandContext; + chains: string; + out: string; + }) => { + const { multiProvider } = context; + + const chainNames = chains.split(','); + const invalidChainNames = chainNames.filter( + (chainName) => !multiProvider.hasChain(chainName), + ); + if (invalidChainNames.length > 0) { + logRed( + `Invalid chain names: ${invalidChainNames + .join(', ') + .replace(/, $/, '')}`, + ); + process.exit(1); + } + + await createAgentConfig({ context, chains: chainNames, out }); + process.exit(0); + }, +}; + +const initCommand: CommandModuleWithContext<{}> = { + command: 'init', + describe: 'Create a new, minimal Hyperlane chain config (aka chain metadata)', + handler: async ({ context }) => { + await createChainConfig({ context }); + process.exit(0); + }, +}; diff --git a/typescript/cli/src/commands/send.ts b/typescript/cli/src/commands/send.ts index e77a0998c..1167b3b55 100644 --- a/typescript/cli/src/commands/send.ts +++ b/typescript/cli/src/commands/send.ts @@ -4,22 +4,15 @@ import { CommandModule, Options } from 'yargs'; import { CommandModuleWithWriteContext } from '../context/types.js'; import { log } from '../logger.js'; import { sendTestMessage } from '../send/message.js'; -import { sendTestTransfer } from '../send/transfer.js'; - -import { warpCoreConfigCommandOption } from './options.js'; /** * Parent command */ export const sendCommand: CommandModule = { command: 'send', - describe: 'Send a test message or transfer', + describe: 'Send a test message', builder: (yargs) => - yargs - .command(messageCommand) - .command(transferCommand) - .version(false) - .demandCommand(), + yargs.command(messageCommand).version(false).demandCommand(), handler: () => log('Command required'), }; @@ -94,55 +87,3 @@ const messageCommand: CommandModuleWithWriteContext< process.exit(0); }, }; - -/** - * Transfer command - */ -const transferCommand: CommandModuleWithWriteContext< - MessageOptionsArgTypes & { - warp: string; - router?: string; - wei: string; - recipient?: string; - } -> = { - command: 'transfer', - describe: 'Send a test token transfer on a warp route', - builder: { - ...messageOptions, - warp: warpCoreConfigCommandOption, - wei: { - type: 'string', - description: 'Amount in wei to send', - default: 1, - }, - recipient: { - type: 'string', - description: 'Token recipient address (defaults to sender)', - }, - }, - handler: async ({ - context, - origin, - destination, - timeout, - quick, - relay, - warp, - wei, - recipient, - }) => { - await sendTestTransfer({ - context, - warpConfigPath: warp, - origin, - destination, - wei, - recipient, - timeoutSec: timeout, - skipWaitForDelivery: quick, - selfRelay: relay, - }); - process.exit(0); - }, -}; diff --git a/typescript/cli/src/commands/types.ts b/typescript/cli/src/commands/types.ts new file mode 100644 index 000000000..bc017069a --- /dev/null +++ b/typescript/cli/src/commands/types.ts @@ -0,0 +1,2 @@ +export const ChainTypes = ['mainnet', 'testnet']; +export type ChainType = (typeof ChainTypes)[number]; diff --git a/typescript/cli/src/commands/validator.ts b/typescript/cli/src/commands/validator.ts index 973c0cd25..a065e7acb 100644 --- a/typescript/cli/src/commands/validator.ts +++ b/typescript/cli/src/commands/validator.ts @@ -1,8 +1,16 @@ import { CommandModule } from 'yargs'; +import { + Address, + ProtocolType, + isValidAddressEvm, + normalizeAddressEvm, +} from '@hyperlane-xyz/utils'; + import { CommandModuleWithContext } from '../context/types.js'; -import { log } from '../logger.js'; +import { errorRed, log } from '../logger.js'; import { getValidatorAddress } from '../validator/address.js'; +import { checkValidatorSetup } from '../validator/preFlightCheck.js'; import { awsAccessKeyCommandOption, @@ -10,13 +18,20 @@ import { awsKeyIdCommandOption, awsRegionCommandOption, awsSecretKeyCommandOption, + chainCommandOption, + demandOption, + validatorCommandOption, } from './options.js'; // Parent command to help configure and set up Hyperlane validators export const validatorCommand: CommandModule = { command: 'validator', describe: 'Configure and manage Hyperlane validators', - builder: (yargs) => yargs.command(addressCommand).demandCommand(), + builder: (yargs) => + yargs + .command(addressCommand) + .command(preFlightCheckCommand) + .demandCommand(), handler: () => log('Command required'), }; @@ -49,3 +64,58 @@ const addressCommand: CommandModuleWithContext<{ process.exit(0); }, }; + +const preFlightCheckCommand: CommandModuleWithContext<{ + chain: string; + validators: string; +}> = { + command: 'check', + describe: 'Check the validator has announced correctly for a given chain', + builder: { + chain: demandOption(chainCommandOption), + validators: validatorCommandOption, + }, + handler: async ({ context, chain, validators }) => { + const { multiProvider } = context; + + // validate chain + if (!multiProvider.hasChain(chain)) { + errorRed( + `❌ No metadata found for ${chain}. Ensure it is included in your configured registry.`, + ); + process.exit(1); + } + + const chainMetadata = multiProvider.getChainMetadata(chain); + + if (chainMetadata.protocol !== ProtocolType.Ethereum) { + errorRed( + `\n❌ Validator pre flight check only supports EVM chains. Exiting.`, + ); + process.exit(1); + } + + // validate validators addresses + const validatorList = validators.split(','); + const invalidAddresses: Set
= new Set(); + const validAddresses: Set
= new Set(); + + for (const address of validatorList) { + if (isValidAddressEvm(address)) { + validAddresses.add(normalizeAddressEvm(address)); + } else { + invalidAddresses.add(address); + } + } + + if (invalidAddresses.size > 0) { + errorRed( + `❌ Invalid addresses: ${Array.from(invalidAddresses).join(', ')}`, + ); + process.exit(1); + } + + await checkValidatorSetup(context, chain, validAddresses); + process.exit(0); + }, +}; diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts new file mode 100644 index 000000000..7a1367779 --- /dev/null +++ b/typescript/cli/src/commands/warp.ts @@ -0,0 +1,290 @@ +import { ethers } from 'ethers'; +import { stringify as yamlStringify } from 'yaml'; +import { CommandModule } from 'yargs'; + +import { + HypXERC20Lockbox__factory, + HypXERC20__factory, + IXERC20__factory, +} from '@hyperlane-xyz/core'; +import { + ChainMap, + EvmERC20WarpRouteReader, + TokenStandard, + WarpCoreConfig, +} from '@hyperlane-xyz/sdk'; +import { objMap, promiseObjAll } from '@hyperlane-xyz/utils'; + +import { + createWarpRouteDeployConfig, + readWarpCoreConfig, +} from '../config/warp.js'; +import { + CommandModuleWithContext, + CommandModuleWithWriteContext, +} from '../context/types.js'; +import { evaluateIfDryRunFailure } from '../deploy/dry-run.js'; +import { runWarpRouteDeploy } from '../deploy/warp.js'; +import { log, logGray, logGreen, logRed, logTable } from '../logger.js'; +import { sendTestTransfer } from '../send/transfer.js'; +import { indentYamlOrJson, writeYamlOrJson } from '../utils/files.js'; +import { selectRegistryWarpRoute } from '../utils/tokens.js'; + +import { + addressCommandOption, + chainCommandOption, + dryRunCommandOption, + fromAddressCommandOption, + outputFileCommandOption, + symbolCommandOption, + warpCoreConfigCommandOption, + warpDeploymentConfigCommandOption, +} from './options.js'; +import { MessageOptionsArgTypes, messageOptions } from './send.js'; + +/** + * Parent command + */ +export const warpCommand: CommandModule = { + command: 'warp', + describe: 'Manage Hyperlane warp routes', + builder: (yargs) => + yargs + .command(deploy) + .command(init) + .command(read) + .command(send) + .version(false) + .demandCommand(), + + handler: () => log('Command required'), +}; + +export const deploy: CommandModuleWithWriteContext<{ + config: string; + 'dry-run': string; + 'from-address': string; +}> = { + command: 'deploy', + describe: 'Deploy Warp Route contracts', + builder: { + config: warpDeploymentConfigCommandOption, + 'dry-run': dryRunCommandOption, + 'from-address': fromAddressCommandOption, + }, + handler: async ({ context, config, dryRun }) => { + logGray(`Hyperlane warp route deployment${dryRun ? ' dry-run' : ''}`); + logGray('------------------------------------------------'); + + try { + await runWarpRouteDeploy({ + context, + warpRouteDeploymentConfigPath: config, + }); + } catch (error: any) { + evaluateIfDryRunFailure(error, dryRun); + throw error; + } + process.exit(0); + }, +}; + +export const init: CommandModuleWithContext<{ + advanced: boolean; + out: string; +}> = { + command: 'init', + describe: 'Create a warp route configuration.', + builder: { + advanced: { + type: 'boolean', + describe: 'Create an advanced ISM', + default: false, + }, + out: outputFileCommandOption('./configs/warp-route-deployment.yaml'), + }, + handler: async ({ context, advanced, out }) => { + logGray('Hyperlane Warp Configure'); + logGray('------------------------'); + + await createWarpRouteDeployConfig({ + context, + outPath: out, + advanced, + }); + process.exit(0); + }, +}; + +export const read: CommandModuleWithContext<{ + chain?: string; + address?: string; + out?: string; + symbol?: string; +}> = { + command: 'read', + describe: 'Derive the warp route config from onchain artifacts', + builder: { + symbol: { + ...symbolCommandOption, + demandOption: false, + }, + chain: { + ...chainCommandOption, + demandOption: false, + }, + address: addressCommandOption( + 'Address of the router contract to read.', + false, + ), + out: outputFileCommandOption(), + }, + handler: async ({ context, chain, address, out, symbol }) => { + logGray('Hyperlane Warp Reader'); + logGray('---------------------'); + + const { multiProvider } = context; + + let addresses: ChainMap; + if (symbol) { + const warpCoreConfig = await selectRegistryWarpRoute( + context.registry, + symbol, + ); + + // TODO: merge with XERC20TokenAdapter and WarpRouteReader + const xerc20Limits = await Promise.all( + warpCoreConfig.tokens + .filter( + (t) => + t.standard === TokenStandard.EvmHypXERC20 || + t.standard === TokenStandard.EvmHypXERC20Lockbox, + ) + .map(async (t) => { + const provider = multiProvider.getProvider(t.chainName); + const router = t.addressOrDenom!; + const xerc20Address = + t.standard === TokenStandard.EvmHypXERC20Lockbox + ? await HypXERC20Lockbox__factory.connect( + router, + provider, + ).xERC20() + : await HypXERC20__factory.connect( + router, + provider, + ).wrappedToken(); + + const xerc20 = IXERC20__factory.connect(xerc20Address, provider); + const mint = await xerc20.mintingCurrentLimitOf(router); + const burn = await xerc20.burningCurrentLimitOf(router); + + const formattedLimits = objMap({ mint, burn }, (_, v) => + ethers.utils.formatUnits(v, t.decimals), + ); + + return [t.chainName, formattedLimits]; + }), + ); + if (xerc20Limits.length > 0) { + logGray('xERC20 Limits:'); + logTable(Object.fromEntries(xerc20Limits)); + } + + addresses = Object.fromEntries( + warpCoreConfig.tokens.map((t) => [t.chainName, t.addressOrDenom!]), + ); + } else if (chain && address) { + addresses = { + [chain]: address, + }; + } else { + logGreen(`Please specify either a symbol or chain and address`); + process.exit(0); + } + + const config = await promiseObjAll( + objMap(addresses, async (chain, address) => + new EvmERC20WarpRouteReader(multiProvider, chain).deriveWarpRouteConfig( + address, + ), + ), + ); + + if (out) { + writeYamlOrJson(out, config, 'yaml'); + logGreen(`✅ Warp route config written successfully to ${out}:\n`); + } else { + logGreen(`✅ Warp route config read successfully:\n`); + } + log(indentYamlOrJson(yamlStringify(config, null, 2), 4)); + process.exit(0); + }, +}; + +const send: CommandModuleWithWriteContext< + MessageOptionsArgTypes & { + warp?: string; + symbol?: string; + router?: string; + amount: string; + recipient?: string; + } +> = { + command: 'send', + describe: 'Send a test token transfer on a warp route', + builder: { + ...messageOptions, + symbol: { + ...symbolCommandOption, + demandOption: false, + }, + warp: { + ...warpCoreConfigCommandOption, + demandOption: false, + }, + amount: { + type: 'string', + description: 'Amount to send (in smallest unit)', + default: 1, + }, + recipient: { + type: 'string', + description: 'Token recipient address (defaults to sender)', + }, + }, + handler: async ({ + context, + origin, + destination, + timeout, + quick, + relay, + symbol, + warp, + amount, + recipient, + }) => { + let warpCoreConfig: WarpCoreConfig; + if (symbol) { + warpCoreConfig = await selectRegistryWarpRoute(context.registry, symbol); + } else if (warp) { + warpCoreConfig = readWarpCoreConfig(warp); + } else { + logRed(`Please specify either a symbol or warp config`); + process.exit(0); + } + + await sendTestTransfer({ + context, + warpCoreConfig, + origin, + destination, + amount, + recipient, + timeoutSec: timeout, + skipWaitForDelivery: quick, + selfRelay: relay, + }); + process.exit(0); + }, +}; diff --git a/typescript/cli/src/config/agent.ts b/typescript/cli/src/config/agent.ts new file mode 100644 index 000000000..d4b59d68f --- /dev/null +++ b/typescript/cli/src/config/agent.ts @@ -0,0 +1,75 @@ +import { fromError } from 'zod-validation-error'; + +import { + AgentConfigSchema, + ChainMap, + HyperlaneCore, + HyperlaneDeploymentArtifacts, + buildAgentConfig, +} from '@hyperlane-xyz/sdk'; +import { objMap, promiseObjAll } from '@hyperlane-xyz/utils'; + +import { CommandContext } from '../context/types.js'; +import { errorRed, logBlue, logGreen, logRed } from '../logger.js'; +import { writeYamlOrJson } from '../utils/files.js'; + +export async function createAgentConfig({ + context, + chains, + out, +}: { + context: CommandContext; + chains: string[]; + out: string; +}) { + logBlue('\nCreating agent config...'); + + const { registry, multiProvider, chainMetadata } = context; + const addresses = await registry.getAddresses(); + + const core = HyperlaneCore.fromAddressesMap(addresses, multiProvider); + + const startBlocks = await promiseObjAll( + objMap(addresses, async (chain, _) => { + // If the index.from is specified in the chain metadata, use that. + const indexFrom = chainMetadata[chain].index?.from; + if (indexFrom !== undefined) { + return indexFrom; + } + + const mailbox = core.getContracts(chain).mailbox; + try { + const deployedBlock = await mailbox.deployedBlock(); + return deployedBlock.toNumber(); + } catch (err) { + logRed( + `Failed to get deployed block to set an index for ${chain}, this is potentially an issue with rpc provider or a misconfiguration`, + ); + process.exit(1); + } + }), + ); + + // @TODO: consider adding additional config used to pass in gas prices for Cosmos chains + const agentConfig = buildAgentConfig( + chains, + multiProvider, + addresses as ChainMap, + startBlocks, + ); + + try { + AgentConfigSchema.parse(agentConfig); + } catch (e) { + errorRed( + `Agent config is invalid, this is possibly due to required contracts not being deployed. See details below:\n${fromError( + e, + ).toString()}`, + ); + process.exit(1); + } + + logBlue(`Agent config is valid, writing to file ${out}`); + writeYamlOrJson(out, agentConfig, 'json'); + logGreen(`✅ Agent config successfully written to ${out}`); +} diff --git a/typescript/cli/src/config/chain.ts b/typescript/cli/src/config/chain.ts index c2655cab3..924bbd325 100644 --- a/typescript/cli/src/config/chain.ts +++ b/typescript/cli/src/config/chain.ts @@ -1,13 +1,18 @@ import { confirm, input } from '@inquirer/prompts'; import { ethers } from 'ethers'; +import { stringify as yamlStringify } from 'yaml'; -import { ChainMetadata, ChainMetadataSchema } from '@hyperlane-xyz/sdk'; +import { + ChainMetadata, + ChainMetadataSchema, + ZChainName, +} from '@hyperlane-xyz/sdk'; import { ProtocolType } from '@hyperlane-xyz/utils'; import { CommandContext } from '../context/types.js'; import { errorRed, log, logBlue, logGreen } from '../logger.js'; -import { detectAndConfirmOrPrompt } from '../utils/chains.js'; -import { readYamlOrJson } from '../utils/files.js'; +import { indentYamlOrJson, readYamlOrJson } from '../utils/files.js'; +import { detectAndConfirmOrPrompt } from '../utils/input.js'; export function readChainConfigs(filePath: string) { log(`Reading file configs in ${filePath}`); @@ -48,19 +53,19 @@ export async function createChainConfig({ }, 'Enter http or https', 'rpc url', + 'JSON RPC provider', ); const provider = new ethers.providers.JsonRpcProvider(rpcUrl); - const name = await detectAndConfirmOrPrompt( - async () => { - const clientName = await provider.send('web3_clientVersion', []); - const port = rpcUrl.split(':').slice(-1); - const client = clientName.split('/')[0]; - return `${client}${port}`; - }, - 'Enter (one word, lower case)', - 'chain name', - ); + const name = await input({ + message: 'Enter chain name (one word, lower case)', + validate: (chainName) => ZChainName.safeParse(chainName).success, + }); + + const displayName = await input({ + message: 'Enter chain display name', + default: name[0].toUpperCase() + name.slice(1), + }); const chainId = parseInt( await detectAndConfirmOrPrompt( @@ -70,79 +75,29 @@ export async function createChainConfig({ }, 'Enter a (number)', 'chain id', + 'JSON RPC provider', ), 10, ); const metadata: ChainMetadata = { name, + displayName, chainId, domainId: chainId, protocol: ProtocolType.Ethereum, rpcUrls: [{ http: rpcUrl }], }; - const wantAdvancedConfig = await confirm({ - default: false, - message: - 'Do you want to set block or gas properties for this chain config?', - }); - if (wantAdvancedConfig) { - const wantBlockConfig = await confirm({ - message: 'Do you want to add block config for this chain?', - }); - if (wantBlockConfig) { - const blockConfirmation = await input({ - message: - 'Enter no. of blocks to wait before considering a transaction confirmed(0-500)', - validate: (value) => parseInt(value) >= 0 && parseInt(value) <= 500, - }); - const blockReorgPeriod = await input({ - message: - 'Enter no. of blocks before a transaction has a near-zero chance of reverting(0-500)', - validate: (value) => parseInt(value) >= 0 && parseInt(value) <= 500, - }); - const blockTimeEstimate = await input({ - message: 'Enter the rough estimate of time per block in seconds(0-20)', - validate: (value) => parseInt(value) >= 0 && parseInt(value) <= 20, - }); - metadata.blocks = { - confirmations: parseInt(blockConfirmation, 10), - reorgPeriod: parseInt(blockReorgPeriod, 10), - estimateBlockTime: parseInt(blockTimeEstimate, 10), - }; - } - const wantGasConfig = await confirm({ - message: 'Do you want to add gas config for this chain?', - }); - if (wantGasConfig) { - const isEIP1559 = await confirm({ - message: 'Is your chain an EIP1559 enabled?', - }); - if (isEIP1559) { - const maxFeePerGas = await input({ - message: 'Enter the max fee per gas in gwei', - }); - const maxPriorityFeePerGas = await input({ - message: 'Enter the max priority fee per gas in gwei', - }); - metadata.transactionOverrides = { - maxFeePerGas: BigInt(maxFeePerGas) * BigInt(10 ** 9), - maxPriorityFeePerGas: BigInt(maxPriorityFeePerGas) * BigInt(10 ** 9), - }; - } else { - const gasPrice = await input({ - message: 'Enter the gas price in gwei', - }); - metadata.transactionOverrides = { - gasPrice: BigInt(gasPrice) * BigInt(10 ** 9), - }; - } - } - } + await addBlockOrGasConfig(metadata); + + await addNativeTokenConfig(metadata); + const parseResult = ChainMetadataSchema.safeParse(metadata); if (parseResult.success) { - logGreen(`Chain config is valid, writing to registry`); + logGreen(`Chain config is valid, writing to registry:`); + const metadataYaml = yamlStringify(metadata, null, 2); + log(indentYamlOrJson(metadataYaml, 4)); await context.registry.updateChain({ chainName: metadata.name, metadata }); } else { errorRed( @@ -152,3 +107,97 @@ export async function createChainConfig({ throw new Error('Invalid chain config'); } } + +async function addBlockOrGasConfig(metadata: ChainMetadata): Promise { + const wantBlockOrGasConfig = await confirm({ + default: false, + message: 'Do you want to set block or gas properties for this chain config', + }); + if (wantBlockOrGasConfig) { + await addBlockConfig(metadata); + await addGasConfig(metadata); + } +} + +async function addBlockConfig(metadata: ChainMetadata): Promise { + const wantBlockConfig = await confirm({ + message: 'Do you want to add block config for this chain', + }); + if (wantBlockConfig) { + const blockConfirmation = await input({ + message: + 'Enter no. of blocks to wait before considering a transaction confirmed (0-500):', + validate: (value) => parseInt(value) >= 0 && parseInt(value) <= 500, + }); + const blockReorgPeriod = await input({ + message: + 'Enter no. of blocks before a transaction has a near-zero chance of reverting (0-500):', + validate: (value) => parseInt(value) >= 0 && parseInt(value) <= 500, + }); + const blockTimeEstimate = await input({ + message: 'Enter the rough estimate of time per block in seconds (0-20):', + validate: (value) => parseInt(value) >= 0 && parseInt(value) <= 20, + }); + metadata.blocks = { + confirmations: parseInt(blockConfirmation, 10), + reorgPeriod: parseInt(blockReorgPeriod, 10), + estimateBlockTime: parseInt(blockTimeEstimate, 10), + }; + } +} + +async function addGasConfig(metadata: ChainMetadata): Promise { + const wantGasConfig = await confirm({ + message: 'Do you want to add gas config for this chain', + }); + if (wantGasConfig) { + const isEIP1559 = await confirm({ + message: 'Is your chain an EIP1559 enabled', + }); + if (isEIP1559) { + const maxFeePerGas = await input({ + message: 'Enter the max fee per gas (gwei):', + }); + const maxPriorityFeePerGas = await input({ + message: 'Enter the max priority fee per gas (gwei):', + }); + metadata.transactionOverrides = { + maxFeePerGas: BigInt(maxFeePerGas) * BigInt(10 ** 9), + maxPriorityFeePerGas: BigInt(maxPriorityFeePerGas) * BigInt(10 ** 9), + }; + } else { + const gasPrice = await input({ + message: 'Enter the gas price (gwei):', + }); + metadata.transactionOverrides = { + gasPrice: BigInt(gasPrice) * BigInt(10 ** 9), + }; + } + } +} + +async function addNativeTokenConfig(metadata: ChainMetadata): Promise { + const wantNativeConfig = await confirm({ + default: false, + message: + 'Do you want to set native token properties for this chain config (defaults to ETH)', + }); + let symbol, name, decimals; + if (wantNativeConfig) { + symbol = await input({ + message: "Enter the native token's symbol:", + }); + name = await input({ + message: `Enter the native token's name:`, + }); + decimals = await input({ + message: "Enter the native token's decimals:", + }); + } + + metadata.nativeToken = { + symbol: symbol ?? 'ETH', + name: name ?? 'Ether', + decimals: decimals ? parseInt(decimals, 10) : 18, + }; +} diff --git a/typescript/cli/src/config/core.ts b/typescript/cli/src/config/core.ts new file mode 100644 index 000000000..0588185f4 --- /dev/null +++ b/typescript/cli/src/config/core.ts @@ -0,0 +1,71 @@ +import { stringify as yamlStringify } from 'yaml'; + +import { CoreConfigSchema, HookConfig, IsmConfig } from '@hyperlane-xyz/sdk'; + +import { CommandContext } from '../context/types.js'; +import { errorRed, log, logBlue, logGreen } from '../logger.js'; +import { indentYamlOrJson, writeYamlOrJson } from '../utils/files.js'; +import { detectAndConfirmOrPrompt } from '../utils/input.js'; + +import { + createHookConfig, + createMerkleTreeConfig, + createProtocolFeeConfig, +} from './hooks.js'; +import { createAdvancedIsmConfig, createTrustedRelayerConfig } from './ism.js'; + +export async function createCoreDeployConfig({ + context, + configFilePath, + advanced = false, +}: { + context: CommandContext; + configFilePath: string; + advanced: boolean; +}) { + logBlue('Creating a new core deployment config...'); + + const owner = await detectAndConfirmOrPrompt( + async () => context.signer?.getAddress(), + 'Enter the desired', + 'owner address', + 'signer', + ); + + const defaultIsm: IsmConfig = advanced + ? await createAdvancedIsmConfig(context) + : await createTrustedRelayerConfig(context, advanced); + + let defaultHook: HookConfig, requiredHook: HookConfig; + if (advanced) { + defaultHook = await createHookConfig({ + context, + selectMessage: 'Select default hook type', + advanced, + }); + requiredHook = await createHookConfig({ + context, + selectMessage: 'Select required hook type', + advanced, + }); + } else { + defaultHook = await createMerkleTreeConfig(); + requiredHook = await createProtocolFeeConfig(context, advanced); + } + + try { + const coreConfig = CoreConfigSchema.parse({ + owner, + defaultIsm, + defaultHook, + requiredHook, + }); + logBlue(`Core config is valid, writing to file ${configFilePath}:\n`); + log(indentYamlOrJson(yamlStringify(coreConfig, null, 2), 4)); + writeYamlOrJson(configFilePath, coreConfig, 'yaml'); + logGreen('✅ Successfully created new core deployment config.'); + } catch (e) { + errorRed(`Core deployment config is invalid.`); + throw e; + } +} diff --git a/typescript/cli/src/config/hooks.ts b/typescript/cli/src/config/hooks.ts index aeb449dec..18308b81a 100644 --- a/typescript/cli/src/config/hooks.ts +++ b/typescript/cli/src/config/hooks.ts @@ -6,9 +6,9 @@ import { z } from 'zod'; import { ChainMap, ChainName, - GasOracleContractType, + HookConfig, + HookConfigSchema, HookType, - HooksConfig, } from '@hyperlane-xyz/sdk'; import { Address, @@ -18,65 +18,24 @@ import { } from '@hyperlane-xyz/utils'; import { CommandContext } from '../context/types.js'; -import { errorRed, log, logBlue, logGreen, logRed } from '../logger.js'; +import { errorRed, logBlue, logGreen, logRed } from '../logger.js'; import { runMultiChainSelectionStep } from '../utils/chains.js'; -import { mergeYamlOrJson, readYamlOrJson } from '../utils/files.js'; +import { readYamlOrJson } from '../utils/files.js'; +import { detectAndConfirmOrPrompt, inputWithInfo } from '../utils/input.js'; -const ProtocolFeeSchema = z.object({ - type: z.literal(HookType.PROTOCOL_FEE), - owner: z.string(), - beneficiary: z.string(), - maxProtocolFee: z.string(), - protocolFee: z.string(), -}); - -const MerkleTreeSchema = z.object({ - type: z.literal(HookType.MERKLE_TREE), -}); - -const IGPSchema = z.object({ - type: z.literal(HookType.INTERCHAIN_GAS_PAYMASTER), - owner: z.string(), - beneficiary: z.string(), - overhead: z.record(z.number()), - gasOracleType: z.record(z.literal(GasOracleContractType.StorageGasOracle)), - oracleKey: z.string(), -}); - -const RoutingConfigSchema: z.ZodSchema = z.lazy(() => - z.object({ - type: z.literal(HookType.ROUTING), - owner: z.string(), - domains: z.record(HookConfigSchema), - }), -); - -const AggregationConfigSchema: z.ZodSchema = z.lazy(() => - z.object({ - type: z.literal(HookType.AGGREGATION), - hooks: z.array(HookConfigSchema), - }), -); - -const HookConfigSchema = z.union([ - ProtocolFeeSchema, - MerkleTreeSchema, - IGPSchema, - RoutingConfigSchema, - AggregationConfigSchema, -]); -export type HookConfig = z.infer; +import { callWithConfigCreationLogs } from './utils.js'; +// TODO: deprecate in favor of CoreConfigSchema const HooksConfigSchema = z.object({ - required: HookConfigSchema, default: HookConfigSchema, + required: HookConfigSchema, }); +export type HooksConfig = z.infer; const HooksConfigMapSchema = z.record(HooksConfigSchema); export type HooksConfigMap = z.infer; -export function isValidHookConfigMap(config: any) { - return HooksConfigMapSchema.safeParse(config).success; -} +const MAX_PROTOCOL_FEE_DEFAULT: string = toWei('0.1'); +const PROTOCOL_FEE_DEFAULT: string = toWei('0'); export function presetHookConfigs(owner: Address): HooksConfig { return { @@ -99,14 +58,7 @@ export function readHooksConfigMap(filePath: string) { logRed(`No hook config found at ${filePath}`); return; } - const result = HooksConfigMapSchema.safeParse(config); - if (!result.success) { - const firstIssue = result.error.issues[0]; - throw new Error( - `Invalid hook config: ${firstIssue.path} => ${firstIssue.message}`, - ); - } - const parsedConfig = result.data; + const parsedConfig = HooksConfigMapSchema.parse(config); const hooks: ChainMap = objMap( parsedConfig, (_, config) => config as HooksConfig, @@ -115,47 +67,24 @@ export function readHooksConfigMap(filePath: string) { return hooks; } -export async function createHooksConfigMap({ +export async function createHookConfig({ context, - outPath, + selectMessage = 'Select hook type', + advanced = false, }: { context: CommandContext; - outPath: string; -}) { - logBlue('Creating a new hook config'); - const chains = await runMultiChainSelectionStep(context.chainMetadata); - - const result: HooksConfigMap = {}; - for (const chain of chains) { - for (const hookRequirements of ['required', 'default']) { - log(`Setting ${hookRequirements} hook for chain ${chain}`); - const remotes = chains.filter((c) => c !== chain); - result[chain] = { - ...result[chain], - [hookRequirements]: await createHookConfig(context, chain, remotes), - }; - } - if (isValidHookConfigMap(result)) { - logGreen(`Hook config is valid, writing to file ${outPath}`); - mergeYamlOrJson(outPath, result); - } else { - errorRed( - `Hook config is invalid, please see https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/cli/examples/hooks.yaml for an example`, - ); - throw new Error('Invalid hook config'); - } - } -} - -export async function createHookConfig( - context: CommandContext, - chain: ChainName, - remotes: ChainName[], -): Promise { - let lastConfig: HookConfig; + selectMessage?: string; + advanced?: boolean; +}): Promise { const hookType = await select({ - message: 'Select hook type', + message: selectMessage, choices: [ + { + value: HookType.AGGREGATION, + name: HookType.AGGREGATION, + description: + 'Aggregate multiple hooks into a single hook (e.g. merkle tree + IGP) which will be called in sequence', + }, { value: HookType.MERKLE_TREE, name: HookType.MERKLE_TREE, @@ -167,193 +96,190 @@ 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: - 'Allow for payments for expected gas to be paid by the relayer while delivering on remote chain', - }, - { - value: HookType.AGGREGATION, - name: HookType.AGGREGATION, - description: - 'Aggregate multiple hooks into a single hook (e.g. merkle tree + IGP) which will be called in sequence', - }, - { - value: HookType.ROUTING, - name: HookType.ROUTING, - description: - 'Each destination domain can have its own hook configured via DomainRoutingHook', - }, ], pageSize: 10, }); - if (hookType === HookType.MERKLE_TREE) { - lastConfig = { type: HookType.MERKLE_TREE }; - } else if (hookType === HookType.PROTOCOL_FEE) { - lastConfig = await createProtocolFeeConfig(context, chain); - } else if (hookType === HookType.INTERCHAIN_GAS_PAYMASTER) { - lastConfig = await createIGPConfig(remotes); - } else if (hookType === HookType.AGGREGATION) { - lastConfig = await createAggregationConfig(context, chain, remotes); - } else if (hookType === HookType.ROUTING) { - lastConfig = await createRoutingConfig(context, chain, remotes); - } else { - throw new Error(`Invalid hook type: ${hookType}`); + + switch (hookType) { + case HookType.AGGREGATION: + return createAggregationConfig(context, advanced); + case HookType.MERKLE_TREE: + return createMerkleTreeConfig(); + case HookType.PROTOCOL_FEE: + return createProtocolFeeConfig(context, advanced); + default: + throw new Error(`Invalid hook type: ${hookType}`); } - return lastConfig; } -export async function createProtocolFeeConfig( - context: CommandContext, - chain: ChainName, -): Promise { - const owner = await input({ - message: 'Enter owner address', - }); - const ownerAddress = normalizeAddressEvm(owner); - let beneficiary; - let sameAsOwner = false; - sameAsOwner = await confirm({ - message: 'Use this same address for the beneficiary?', - }); - if (sameAsOwner) { - beneficiary = ownerAddress; - } else { - beneficiary = await input({ - message: 'Enter beneficiary address', - }); - } - const beneficiaryAddress = normalizeAddressEvm(beneficiary); - // TODO: input in gwei, wei, etc - const maxProtocolFee = toWei( - await input({ - message: `Enter max protocol fee ${nativeTokenAndDecimals( - context, - chain, - )} e.g. 1.0)`, - }), - ); - const protocolFee = toWei( - await input({ - message: `Enter protocol fee in ${nativeTokenAndDecimals( - context, - chain, - )} e.g. 0.01)`, - }), - ); - if (BigNumberJs(protocolFee).gt(maxProtocolFee)) { - errorRed('Protocol fee cannot be greater than max protocol fee'); - throw new Error('Invalid protocol fee'); - } +export const createMerkleTreeConfig = callWithConfigCreationLogs( + async (): Promise => { + return { type: HookType.MERKLE_TREE }; + }, + HookType.MERKLE_TREE, +); - return { - type: HookType.PROTOCOL_FEE, - maxProtocolFee: maxProtocolFee.toString(), - protocolFee: protocolFee.toString(), - beneficiary: beneficiaryAddress, - owner: ownerAddress, - }; -} +export const createProtocolFeeConfig = callWithConfigCreationLogs( + async ( + 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; -export async function createIGPConfig( - remotes: ChainName[], -): Promise { - const owner = await input({ - message: 'Enter owner address', - }); - const ownerAddress = normalizeAddressEvm(owner); - let beneficiary, oracleKey; - let sameAsOwner = false; - sameAsOwner = await confirm({ - message: 'Use this same address for the beneficiary and gasOracleKey?', - }); - if (sameAsOwner) { - beneficiary = ownerAddress; - oracleKey = ownerAddress; - } else { - beneficiary = await input({ - message: 'Enter beneficiary address', + 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); + } + // TODO: input in gwei, wei, etc + const maxProtocolFee = advanced + ? toWei( + await inputWithInfo({ + message: `Enter max protocol fee for protocol fee hook (wei):`, + info: `The max protocol fee (ProtocolFee.MAX_PROTOCOL_FEE) is the maximum value the protocol fee on the ProtocolFee hook contract can ever be set to.\nDefault is set to ${MAX_PROTOCOL_FEE_DEFAULT} wei; between 0.001 and 0.1 wei is recommended.`, + }), + ) + : MAX_PROTOCOL_FEE_DEFAULT; + const protocolFee = advanced + ? toWei( + await inputWithInfo({ + message: `Enter protocol fee for protocol fee hook (wei):`, + info: `The protocol fee is the fee collected by the beneficiary of the ProtocolFee hook for every transaction executed with this hook.\nDefault is set to 0 wei; must be less than max protocol fee of ${maxProtocolFee}.`, + }), + ) + : PROTOCOL_FEE_DEFAULT; + if (BigNumberJs(protocolFee).gt(maxProtocolFee)) { + errorRed( + `Protocol fee (${protocolFee}) cannot be greater than max protocol fee (${maxProtocolFee}).`, + ); + throw new Error(`Invalid protocol fee (${protocolFee}).`); + } + return { + type: HookType.PROTOCOL_FEE, + maxProtocolFee, + protocolFee, + beneficiary, + owner, + }; + }, + 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', }); - oracleKey = await input({ - message: 'Enter gasOracleKey address', + const owner = normalizeAddressEvm(unnormalizedOwner); + let beneficiary = owner; + let oracleKey = owner; + + const beneficiarySameAsOwner = await confirm({ + message: 'Use this same address for the beneficiary and gasOracleKey?', }); - } - const beneficiaryAddress = normalizeAddressEvm(beneficiary); - const oracleKeyAddress = normalizeAddressEvm(oracleKey); - const overheads: ChainMap = {}; - for (const chain of remotes) { - const overhead = parseInt( + + 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: {}, + }; + }, + HookType.INTERCHAIN_GAS_PAYMASTER, +); + +export const createAggregationConfig = callWithConfigCreationLogs( + async ( + context: CommandContext, + advanced: boolean = false, + ): Promise => { + const hooksNum = parseInt( await input({ - message: `Enter overhead for ${chain} (eg 75000)`, + message: 'Enter the number of hooks to aggregate (number)', }), + 10, ); - overheads[chain] = overhead; - } - return { - type: HookType.INTERCHAIN_GAS_PAYMASTER, - beneficiary: beneficiaryAddress, - owner: ownerAddress, - oracleKey: oracleKeyAddress, - overhead: overheads, - gasOracleType: objMap( - overheads, - () => GasOracleContractType.StorageGasOracle, - ), - }; -} - -export async function createAggregationConfig( - context: CommandContext, - chain: ChainName, - remotes: ChainName[], -): Promise { - const hooksNum = parseInt( - await input({ - message: 'Enter the number of hooks to aggregate (number)', - }), - 10, - ); - const hooks: Array = []; - for (let i = 0; i < hooksNum; i++) { - logBlue(`Creating hook ${i + 1} of ${hooksNum} ...`); - hooks.push(await createHookConfig(context, chain, remotes)); - } - return { - type: HookType.AGGREGATION, - hooks, - }; -} - -export async function createRoutingConfig( - context: CommandContext, - origin: ChainName, - remotes: ChainName[], -): Promise { - const owner = await input({ - message: 'Enter owner address', - }); - const ownerAddress = owner; + const hooks: Array = []; + for (let i = 0; i < hooksNum; i++) { + logBlue(`Creating hook ${i + 1} of ${hooksNum} ...`); + hooks.push( + await createHookConfig({ + context, + advanced, + }), + ); + } + return { + type: HookType.AGGREGATION, + hooks, + }; + }, + HookType.AGGREGATION, +); - const domainsMap: ChainMap = {}; - for (const chain of remotes) { - await confirm({ - message: `You are about to configure hook for remote chain ${chain}. Continue?`, +export const createRoutingConfig = callWithConfigCreationLogs( + async ( + context: CommandContext, + advanced: boolean = false, + ): Promise => { + const owner = await input({ + message: 'Enter owner address for routing ISM', }); - const config = await createHookConfig(context, origin, remotes); - domainsMap[chain] = config; - } - return { - type: HookType.ROUTING, - owner: ownerAddress, - domains: domainsMap, - }; -} + const ownerAddress = owner; + const chains = await runMultiChainSelectionStep(context.chainMetadata); -function nativeTokenAndDecimals(context: CommandContext, chain: ChainName) { - return `10^${ - context.chainMetadata[chain].nativeToken?.decimals ?? '18' - } which you cannot exceed (in ${ - context.chainMetadata[chain].nativeToken?.symbol ?? 'eth' - }`; -} + const domainsMap: ChainMap = {}; + for (const chain of chains) { + await confirm({ + message: `You are about to configure hook for remote chain ${chain}. Continue?`, + }); + const config = await createHookConfig({ context, advanced }); + domainsMap[chain] = config; + } + return { + type: HookType.ROUTING, + owner: ownerAddress, + domains: domainsMap, + }; + }, + HookType.ROUTING, +); diff --git a/typescript/cli/src/config/ism.ts b/typescript/cli/src/config/ism.ts index 66075c493..eb976f2dd 100644 --- a/typescript/cli/src/config/ism.ts +++ b/typescript/cli/src/config/ism.ts @@ -1,7 +1,15 @@ -import { confirm, input, select } from '@inquirer/prompts'; +import { input, select } from '@inquirer/prompts'; import { z } from 'zod'; -import { ChainMap, ChainName, IsmType, ZHash } from '@hyperlane-xyz/sdk'; +import { + AggregationIsmConfig, + ChainMap, + IsmConfig, + IsmConfigSchema, + IsmType, + MultisigIsmConfig, + TrustedRelayerIsmConfig, +} from '@hyperlane-xyz/sdk'; import { CommandContext } from '../context/types.js'; import { @@ -9,70 +17,22 @@ import { log, logBlue, logBoldUnderlinedRed, - logGreen, logRed, } from '../logger.js'; import { runMultiChainSelectionStep } from '../utils/chains.js'; -import { mergeYamlOrJson, readYamlOrJson } from '../utils/files.js'; - -const MultisigIsmConfigSchema = z.object({ - type: z.union([ - z.literal(IsmType.MERKLE_ROOT_MULTISIG), - z.literal(IsmType.MESSAGE_ID_MULTISIG), - ]), - threshold: z.number(), - validators: z.array(ZHash), -}); - -const RoutingIsmConfigSchema: z.ZodSchema = z.lazy(() => - z.object({ - type: z.union([ - z.literal(IsmType.ROUTING), - z.literal(IsmType.FALLBACK_ROUTING), - ]), - owner: ZHash, - domains: z.record(IsmConfigSchema), - }), -); +import { readYamlOrJson } from '../utils/files.js'; +import { detectAndConfirmOrPrompt } from '../utils/input.js'; -const AggregationIsmConfigSchema: z.ZodSchema = z - .lazy(() => - z.object({ - type: z.literal(IsmType.AGGREGATION), - modules: z.array(IsmConfigSchema), - threshold: z.number(), - }), - ) - .refine( - // check ig modules.length >= threshold - (ismConfig) => { - return ismConfig.modules.length >= ismConfig.threshold; - }, - { - message: 'Threshold cannot be greater than number of modules', - }, - ); - -const TestIsmConfigSchema = z.object({ - type: z.literal(IsmType.TEST_ISM), -}); +import { callWithConfigCreationLogs } from './utils.js'; -const TrustedRelayerIsmConfigSchema = z.object({ - type: z.literal(IsmType.TRUSTED_RELAYER), - relayer: ZHash, -}); - -const IsmConfigSchema = z.union([ - MultisigIsmConfigSchema, - RoutingIsmConfigSchema, - AggregationIsmConfigSchema, - TestIsmConfigSchema, - TrustedRelayerIsmConfigSchema, -]); const IsmConfigMapSchema = z.record(IsmConfigSchema).refine( (ismConfigMap) => { // check if any key in IsmConfigMap is found in its own RoutingIsmConfigSchema.domains for (const [key, config] of Object.entries(ismConfigMap)) { + if (typeof config === 'string') { + continue; + } + if (config.type === IsmType.ROUTING) { if (config.domains && key in config.domains) { return false; @@ -86,8 +46,6 @@ const IsmConfigMapSchema = z.record(IsmConfigSchema).refine( 'Cannot set RoutingIsm.domain to the same chain you are configuring', }, ); -export type ZodIsmConfig = z.infer; -export type ZodIsmConfigMap = z.infer; export function parseIsmConfig(filePath: string) { const config = readYamlOrJson(filePath); @@ -107,196 +65,210 @@ export function readIsmConfig(filePath: string) { return parsedConfig; } -export function isValildIsmConfig(config: any) { - return IsmConfigMapSchema.safeParse(config).success; -} +const ISM_TYPE_DESCRIPTIONS: Record = { + [IsmType.AGGREGATION]: + 'You can aggregate multiple ISMs into one ISM via AggregationISM', + [IsmType.FALLBACK_ROUTING]: + "You can specify ISM type for specific chains you like and fallback to mailbox's default ISM for other chains via DefaultFallbackRoutingISM", + [IsmType.MERKLE_ROOT_MULTISIG]: + 'Validators need to sign the root of the merkle tree of all messages from origin chain', + [IsmType.MESSAGE_ID_MULTISIG]: 'Validators need to sign just this messageId', + [IsmType.ROUTING]: + 'Each origin chain can be verified by the specified ISM type via RoutingISM', + [IsmType.TEST_ISM]: + 'ISM where you can deliver messages without any validation (WARNING: only for testing, do not use in production)', + [IsmType.TRUSTED_RELAYER]: 'Deliver messages from an authorized address', +}; -export async function createIsmConfigMap({ - context, - outPath, -}: { - context: CommandContext; - outPath: string; -}) { +export async function createAdvancedIsmConfig( + context: CommandContext, +): Promise { logBlue('Creating a new advanced ISM config'); logBoldUnderlinedRed('WARNING: USE AT YOUR RISK.'); logRed( 'Advanced ISM configs require knowledge of different ISM types and how they work together topologically. If possible, use the basic ISM configs are recommended.', ); - const chains = await runMultiChainSelectionStep( - context.chainMetadata, - 'Select chains to configure ISM for', - true, - ); - - const result: ZodIsmConfigMap = {}; - for (const chain of chains) { - log(`Setting values for chain ${chain}`); - result[chain] = await createIsmConfig(chain, chains); - // TODO consider re-enabling. Disabling based on feedback from @nambrot for now. - // repeat = await confirm({ - // message: 'Use this same config for remaining chains?', - // }); - } - - if (isValildIsmConfig(result)) { - logGreen(`ISM config is valid, writing to file ${outPath}`); - mergeYamlOrJson(outPath, result); - } else { - errorRed( - `ISM config is invalid, please see https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/cli/examples/ism.yaml for an example`, - ); - throw new Error('Invalid ISM config'); - } -} - -export async function createIsmConfig( - remote: ChainName, - origins: ChainName[], -): Promise { - let lastConfig: ZodIsmConfig; const moduleType = await select({ message: 'Select ISM type', - choices: [ - { - value: IsmType.MESSAGE_ID_MULTISIG, - description: 'Validators need to sign just this messageId', - }, - { - value: IsmType.MERKLE_ROOT_MULTISIG, - description: - 'Validators need to sign the root of the merkle tree of all messages from origin chain', - }, - { - value: IsmType.ROUTING, - description: - 'Each origin chain can be verified by the specified ISM type via RoutingISM', - }, - { - value: IsmType.FALLBACK_ROUTING, - description: - "You can specify ISM type for specific chains you like and fallback to mailbox's default ISM for other chains via DefaultFallbackRoutingISM", - }, - { - value: IsmType.AGGREGATION, - description: - 'You can aggregate multiple ISMs into one ISM via AggregationISM', - }, - { - value: IsmType.TRUSTED_RELAYER, - description: 'Deliver messages from an authorized address', - }, - { - value: IsmType.TEST_ISM, - description: - 'ISM where you can deliver messages without any validation (WARNING: only for testing, do not use in production)', - }, - ], + choices: Object.entries(ISM_TYPE_DESCRIPTIONS).map( + ([value, description]) => ({ + value, + description, + }), + ), pageSize: 10, }); - if ( - moduleType === IsmType.MESSAGE_ID_MULTISIG || - moduleType === IsmType.MERKLE_ROOT_MULTISIG - ) { - lastConfig = await createMultisigConfig(moduleType); - } else if ( - moduleType === IsmType.ROUTING || - moduleType === IsmType.FALLBACK_ROUTING - ) { - lastConfig = await createRoutingConfig(moduleType, remote, origins); - } else if (moduleType === IsmType.AGGREGATION) { - lastConfig = await createAggregationConfig(remote, origins); - } else if (moduleType === IsmType.TEST_ISM) { - lastConfig = { type: IsmType.TEST_ISM }; - } else if (moduleType === IsmType.TRUSTED_RELAYER) { - lastConfig = await createTrustedRelayerConfig(); - } else { - throw new Error(`Invalid ISM type: ${moduleType}}`); + + switch (moduleType) { + case IsmType.AGGREGATION: + return createAggregationConfig(context); + case IsmType.FALLBACK_ROUTING: + return createFallbackRoutingConfig(context); + case IsmType.MERKLE_ROOT_MULTISIG: + return createMerkleRootMultisigConfig(context); + case IsmType.MESSAGE_ID_MULTISIG: + return createMessageIdMultisigConfig(context); + case IsmType.ROUTING: + return createRoutingConfig(context); + case IsmType.TEST_ISM: + return { type: IsmType.TEST_ISM }; + case IsmType.TRUSTED_RELAYER: + return createTrustedRelayerConfig(context, true); + default: + throw new Error(`Invalid ISM type: ${moduleType}.`); } - return lastConfig; } -export async function createMultisigConfig( - type: IsmType.MERKLE_ROOT_MULTISIG | IsmType.MESSAGE_ID_MULTISIG, -): Promise { - const thresholdInput = await input({ - message: 'Enter threshold of validators (number)', - }); - const threshold = parseInt(thresholdInput, 10); +export const createMerkleRootMultisigConfig = callWithConfigCreationLogs( + async (): Promise => { + const validatorsInput = await input({ + message: + 'Enter validator addresses (comma separated list) for merkle root multisig ISM:', + }); + const validators = validatorsInput.split(',').map((v) => v.trim()); + const thresholdInput = await input({ + message: + 'Enter threshold of validators (number) for merkle root multisig ISM:', + }); + const threshold = parseInt(thresholdInput, 10); + if (threshold > validators.length) { + errorRed( + `Merkle root multisig signer threshold (${threshold}) cannot be greater than total number of validators (${validators.length}).`, + ); + throw new Error('Invalid protocol fee.'); + } + return { + type: IsmType.MERKLE_ROOT_MULTISIG, + threshold, + validators, + }; + }, + IsmType.MERKLE_ROOT_MULTISIG, +); - const validatorsInput = await input({ - message: 'Enter validator addresses (comma separated list)', - }); - const validators = validatorsInput.split(',').map((v) => v.trim()); - return { - type, - threshold, - validators, - }; -} +export const createMessageIdMultisigConfig = callWithConfigCreationLogs( + async (): Promise => { + const thresholdInput = await input({ + message: + 'Enter threshold of validators (number) for message ID multisig ISM', + }); + const threshold = parseInt(thresholdInput, 10); -async function createTrustedRelayerConfig(): Promise { - const relayer = await input({ - message: 'Enter relayer address', - }); - return { - type: IsmType.TRUSTED_RELAYER, - relayer, - }; -} + const validatorsInput = await input({ + message: + 'Enter validator addresses (comma separated list) for message ID multisig ISM', + }); + const validators = validatorsInput.split(',').map((v) => v.trim()); + return { + type: IsmType.MESSAGE_ID_MULTISIG, + threshold, + validators, + }; + }, + IsmType.MESSAGE_ID_MULTISIG, +); -export async function createAggregationConfig( - remote: ChainName, - chains: ChainName[], -): Promise { - const isms = parseInt( - await input({ - message: 'Enter the number of ISMs to aggregate (number)', - }), - 10, - ); +export const createTrustedRelayerConfig = callWithConfigCreationLogs( + async ( + context: CommandContext, + advanced: boolean = false, + ): Promise => { + const relayer = + !advanced && context.signer + ? await context.signer.getAddress() + : await detectAndConfirmOrPrompt( + async () => context.signer?.getAddress(), + 'For trusted relayer ISM, enter', + 'relayer address', + 'signer', + ); + return { + type: IsmType.TRUSTED_RELAYER, + relayer, + }; + }, + IsmType.TRUSTED_RELAYER, +); - const threshold = parseInt( - await input({ - message: 'Enter the threshold of ISMs to for verification (number)', - }), - 10, - ); +export const createAggregationConfig = callWithConfigCreationLogs( + async (context: CommandContext): Promise => { + const isms = parseInt( + await input({ + message: 'Enter the number of ISMs to aggregate (number)', + }), + 10, + ); - const modules: Array = []; - for (let i = 0; i < isms; i++) { - modules.push(await createIsmConfig(remote, chains)); - } - return { - type: IsmType.AGGREGATION, - modules, - threshold, - }; -} + const threshold = parseInt( + await input({ + message: 'Enter the threshold of ISMs for verification (number)', + }), + 10, + ); -export async function createRoutingConfig( - type: IsmType.ROUTING | IsmType.FALLBACK_ROUTING, - remote: ChainName, - chains: ChainName[], -): Promise { - const owner = await input({ - message: 'Enter owner address', - }); - const ownerAddress = owner; - const origins = chains.filter((chain) => chain !== remote); + const modules: Array = []; + for (let i = 0; i < isms; i++) { + modules.push(await createAdvancedIsmConfig(context)); + } + return { + type: IsmType.AGGREGATION, + modules, + threshold, + }; + }, + IsmType.AGGREGATION, +); - const domainsMap: ChainMap = {}; - for (const chain of origins) { - await confirm({ - message: `You are about to configure ISM from source chain ${chain}. Continue?`, +export const createRoutingConfig = callWithConfigCreationLogs( + async (context: CommandContext): Promise => { + const owner = await input({ + message: 'Enter owner address for routing ISM', }); - const config = await createIsmConfig(chain, chains); - domainsMap[chain] = config; - } - return { - type, - owner: ownerAddress, - domains: domainsMap, - }; -} + const ownerAddress = owner; + const requireMultiple = true; + const chains = await runMultiChainSelectionStep( + context.chainMetadata, + 'Select chains to configure routing ISM for', + requireMultiple, + ); + + const domainsMap: ChainMap = {}; + for (const chain of chains) { + log(`You are about to configure routing ISM from source chain ${chain}.`); + const config = await createAdvancedIsmConfig(context); + domainsMap[chain] = config; + } + return { + type: IsmType.ROUTING, + owner: ownerAddress, + domains: domainsMap, + }; + }, + IsmType.ROUTING, +); + +export const createFallbackRoutingConfig = callWithConfigCreationLogs( + async (context: CommandContext): Promise => { + const chains = await runMultiChainSelectionStep( + context.chainMetadata, + 'Select chains to configure fallback routing ISM for', + true, + ); + + const domainsMap: ChainMap = {}; + for (const chain of chains) { + log( + `You are about to configure fallback routing ISM from source chain ${chain}.`, + ); + const config = await createAdvancedIsmConfig(context); + domainsMap[chain] = config; + } + return { + type: IsmType.FALLBACK_ROUTING, + owner: '', + domains: domainsMap, + }; + }, + IsmType.FALLBACK_ROUTING, +); diff --git a/typescript/cli/src/config/utils.ts b/typescript/cli/src/config/utils.ts new file mode 100644 index 000000000..abd1c6f82 --- /dev/null +++ b/typescript/cli/src/config/utils.ts @@ -0,0 +1,18 @@ +import { HookConfig, HookType, IsmConfig, IsmType } from '@hyperlane-xyz/sdk'; + +import { logGray } from '../logger.js'; + +export function callWithConfigCreationLogs( + fn: (...args: any[]) => Promise, + type: IsmType | HookType, +) { + return async (...args: any[]): Promise => { + logGray(`Creating ${type}...`); + try { + const result = await fn(...args); + return result; + } finally { + logGray(`Created ${type}!`); + } + }; +} diff --git a/typescript/cli/src/config/warp.ts b/typescript/cli/src/config/warp.ts index d9ca9b4e5..7bd71e747 100644 --- a/typescript/cli/src/config/warp.ts +++ b/typescript/cli/src/config/warp.ts @@ -1,7 +1,10 @@ import { input, select } from '@inquirer/prompts'; +import { stringify as yamlStringify } from 'yaml'; import { ChainMap, + IsmConfig, + IsmType, MailboxClientConfig, TokenType, WarpCoreConfig, @@ -9,15 +12,19 @@ import { WarpRouteDeployConfig, WarpRouteDeployConfigSchema, } from '@hyperlane-xyz/sdk'; -import { assert, objMap, promiseObjAll } from '@hyperlane-xyz/utils'; +import { Address, assert, objMap, promiseObjAll } from '@hyperlane-xyz/utils'; import { CommandContext } from '../context/types.js'; -import { errorRed, logBlue, logGreen } from '../logger.js'; +import { errorRed, log, logBlue, logGreen } from '../logger.js'; +import { runMultiChainSelectionStep } from '../utils/chains.js'; import { - detectAndConfirmOrPrompt, - runMultiChainSelectionStep, -} from '../utils/chains.js'; -import { readYamlOrJson, writeYamlOrJson } from '../utils/files.js'; + indentYamlOrJson, + readYamlOrJson, + writeYamlOrJson, +} from '../utils/files.js'; +import { detectAndConfirmOrPrompt } from '../utils/input.js'; + +import { createAdvancedIsmConfig } from './ism.js'; const TYPE_DESCRIPTIONS: Record = { [TokenType.synthetic]: 'A new ERC20 with remote transfer functionality', @@ -94,16 +101,19 @@ export function isValidWarpRouteDeployConfig(config: any) { export async function createWarpRouteDeployConfig({ context, outPath, + advanced = false, }: { context: CommandContext; outPath: string; + advanced: boolean; }) { - logBlue('Creating a new warp route deployment config'); + logBlue('Creating a new warp route deployment config...'); const owner = await detectAndConfirmOrPrompt( async () => context.signer?.getAddress(), 'Enter the desired', 'owner address', + 'signer', ); const warpChains = await runMultiChainSelectionStep( @@ -130,8 +140,13 @@ export async function createWarpRouteDeployConfig({ }, `For ${chain}, enter the`, 'mailbox address', + 'hyperlane-registry', ); + const interchainSecurityModule = advanced + ? await createAdvancedIsmConfig(context) + : createDefaultWarpIsmConfig(owner); + switch (type) { case TokenType.collateral: case TokenType.XERC20: @@ -145,23 +160,32 @@ export async function createWarpRouteDeployConfig({ type, owner, isNft, + interchainSecurityModule, token: await input({ message: `Enter the existing token address on chain ${chain}`, }), }; break; default: - result[chain] = { mailbox, type, owner, isNft }; + result[chain] = { + mailbox, + type, + owner, + isNft, + interchainSecurityModule, + }; } } try { - const parsed = WarpRouteDeployConfigSchema.parse(result); - logGreen(`Warp Route config is valid, writing to file ${outPath}`); - writeYamlOrJson(outPath, parsed); + const warpRouteDeployConfig = WarpRouteDeployConfigSchema.parse(result); + logBlue(`Warp Route config is valid, writing to file ${outPath}:\n`); + log(indentYamlOrJson(yamlStringify(warpRouteDeployConfig, null, 2), 4)); + writeYamlOrJson(outPath, warpRouteDeployConfig, 'yaml'); + logGreen('✅ Successfully created new warp route deployment config.'); } catch (e) { errorRed( - `Warp route deployment config is invalid, please see https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/cli/examples/warp-route-deployment.yaml for an example`, + `Warp route deployment config is invalid, please see https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/cli/examples/warp-route-deployment.yaml for an example.`, ); throw e; } @@ -169,8 +193,34 @@ export async function createWarpRouteDeployConfig({ // Note, this is different than the function above which reads a config // for a DEPLOYMENT. This gets a config for using a warp route (aka WarpCoreConfig) -export function readWarpRouteConfig(filePath: string): WarpCoreConfig { +export function readWarpCoreConfig(filePath: string): WarpCoreConfig { const config = readYamlOrJson(filePath); if (!config) throw new Error(`No warp route config found at ${filePath}`); return WarpCoreConfigSchema.parse(config); } + +/** + * Creates a default configuration for an ISM with a TRUSTED_RELAYER and FALLBACK_ROUTING. + * + * Properties relayer and owner are both set as input owner. + * + * @param owner - The address of the owner of the ISM. + * @returns The default Aggregation ISM configuration. + */ +function createDefaultWarpIsmConfig(owner: Address): IsmConfig { + return { + type: IsmType.AGGREGATION, + modules: [ + { + type: IsmType.TRUSTED_RELAYER, + relayer: owner, + }, + { + type: IsmType.FALLBACK_ROUTING, + domains: {}, + owner, + }, + ], + threshold: 1, + }; +} diff --git a/typescript/cli/src/context/context.ts b/typescript/cli/src/context/context.ts index fba159a69..bc3258c3f 100644 --- a/typescript/cli/src/context/context.ts +++ b/typescript/cli/src/context/context.ts @@ -56,7 +56,7 @@ export async function getContext({ const registry = getRegistry(registryUri, registryOverrideUri); let signer: ethers.Wallet | undefined = undefined; - if (requiresKey) { + if (key || requiresKey) { ({ key, signer } = await getSigner({ key, skipConfirmation })); } const multiProvider = await getMultiProvider(registry, signer); @@ -99,9 +99,8 @@ export async function getDryRunContext( logBlue(`Dry-running against chain: ${chain}`); await verifyAnvil(); - const multiProvider = await getMultiProvider(registry); - await forkNetworkToMultiProvider(multiProvider, chain); - + let multiProvider = await getMultiProvider(registry); + multiProvider = await forkNetworkToMultiProvider(multiProvider, chain); const { impersonatedKey, impersonatedSigner } = await getImpersonatedSigner({ fromAddress, key, diff --git a/typescript/cli/src/deploy/core.ts b/typescript/cli/src/deploy/core.ts index 8f8bf2043..7e585d4b9 100644 --- a/typescript/cli/src/deploy/core.ts +++ b/typescript/cli/src/deploy/core.ts @@ -1,461 +1,90 @@ -import { confirm } from '@inquirer/prompts'; -import { ethers } from 'ethers'; +import { stringify as yamlStringify } from 'yaml'; -import { ChainAddresses, IRegistry } from '@hyperlane-xyz/registry'; -import { - ChainMap, - ChainName, - CoreConfig, - GasOracleContractType, - HooksConfig, - HyperlaneAddressesMap, - HyperlaneContractsMap, - HyperlaneCore, - HyperlaneCoreDeployer, - HyperlaneIsmFactory, - HyperlaneProxyFactoryDeployer, - IgpConfig, - IsmConfig, - IsmType, - MultisigConfig, - RoutingIsmConfig, - buildAgentConfig, - buildAggregationIsmConfigs, - defaultMultisigConfigs, - multisigIsmVerificationCost, - serializeContractsMap, -} from '@hyperlane-xyz/sdk'; -import { Address, objFilter, objMap, objMerge } from '@hyperlane-xyz/utils'; +import { ChainName, CoreConfig, EvmCoreModule } from '@hyperlane-xyz/sdk'; -import { presetHookConfigs, readHooksConfigMap } from '../config/hooks.js'; -import { readIsmConfig } from '../config/ism.js'; -import { readMultisigConfig } from '../config/multisig.js'; import { MINIMUM_CORE_DEPLOY_GAS } from '../consts.js'; import { WriteCommandContext } from '../context/types.js'; -import { - log, - logBlue, - logBoldUnderlinedRed, - logGray, - logGreen, - logRed, -} from '../logger.js'; -import { runMultiChainSelectionStep } from '../utils/chains.js'; -import { runFileSelectionStep, writeJson } from '../utils/files.js'; +import { log, logBlue, logGreen } from '../logger.js'; +import { runSingleChainSelectionStep } from '../utils/chains.js'; +import { indentYamlOrJson } from '../utils/files.js'; import { completeDeploy, - isISMConfig, - isZODISMConfig, prepareDeploy, + runDeployPlanStep, runPreflightChecksForChains, } from './utils.js'; -const CONTRACT_CACHE_EXCLUSIONS = ['interchainGasPaymaster']; - +interface DeployParams { + context: WriteCommandContext; + chain: ChainName; + config: CoreConfig; +} /** * Executes the core deploy command. */ export async function runCoreDeploy({ context, - chains, - ismConfigPath, - hookConfigPath, - agentOutPath, + chain, + config, }: { context: WriteCommandContext; - chains?: ChainName[]; - ismConfigPath?: string; - hookConfigPath?: string; - agentOutPath: string; + chain: ChainName; + config: CoreConfig; }) { - const { chainMetadata, signer, dryRunChain, skipConfirmation } = context; - - if (dryRunChain) chains = [dryRunChain]; - else if (!chains?.length) { - if (skipConfirmation) throw new Error('No chains provided'); - chains = await runMultiChainSelectionStep( + const { + signer, + isDryRun, + chainMetadata, + dryRunChain, + registry, + skipConfirmation, + } = context; + + // Select a dry-run chain if it's not supplied + if (dryRunChain) { + chain = dryRunChain; + } else if (!chain) { + if (skipConfirmation) throw new Error('No chain provided'); + chain = await runSingleChainSelectionStep( chainMetadata, - 'Select chains to connect:', - true, + 'Select chain to connect:', ); } - - const result = await runIsmStep(chains, skipConfirmation, ismConfigPath); - // we can either specify the full ISM config or just the multisig config - const isIsmConfig = isISMConfig(result); - const ismConfigs = isIsmConfig ? (result as ChainMap) : undefined; - const multisigConfigs = isIsmConfig - ? defaultMultisigConfigs - : (result as ChainMap); - const hooksConfig = await runHookStep(chains, hookConfigPath); - const deploymentParams: DeployParams = { context, - chains, - ismConfigs, - multisigConfigs, - hooksConfig, - agentOutPath, + chain, + config, }; await runDeployPlanStep(deploymentParams); await runPreflightChecksForChains({ ...deploymentParams, + chains: [chain], minGas: MINIMUM_CORE_DEPLOY_GAS, }); const userAddress = await signer.getAddress(); - const initialBalances = await prepareDeploy(context, userAddress, chains); - - await executeDeploy(deploymentParams); - - await completeDeploy(context, 'core', initialBalances, userAddress, chains); -} - -async function runIsmStep( - selectedChains: ChainName[], - skipConfirmation: boolean, - ismConfigPath?: string, -) { - if (!ismConfigPath) { - logBlue( - '\n', - 'Hyperlane instances requires an Interchain Security Module (ISM).', - ); - logGray( - 'Example config: https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/cli/typescript/cli/examples/ism.yaml', - ); - if (skipConfirmation) throw new Error('ISM config required'); - ismConfigPath = await runFileSelectionStep( - './configs', - 'ISM config', - 'ism', - ); - } - - const isAdvancedIsm = isZODISMConfig(ismConfigPath); - // separate flow for 'ism' and 'ism-advanced' options - if (isAdvancedIsm) { - logBoldUnderlinedRed( - 'WARNING: YOU ARE DEPLOYING WITH AN ADVANCED ISM CONFIG', - ); - logRed( - 'Advanced ISM configs require knowledge of different ISM types and how they work together topologically. If possible, use the basic ISM configs are recommended.', - ); - const ismConfig = readIsmConfig(ismConfigPath); - const requiredIsms = objFilter( - ismConfig, - (chain, config): config is IsmConfig => selectedChains.includes(chain), - ); - // selected chains - (user configs + default configs) = missing config - const missingConfigs = selectedChains.filter( - (c) => !Object.keys(ismConfig).includes(c), - ); - if (missingConfigs.length > 0) { - throw new Error( - `Missing advanced ISM config for one or more chains: ${missingConfigs.join( - ', ', - )}`, - ); - } - - log(`Found configs for chains: ${selectedChains.join(', ')}`); - return requiredIsms as ChainMap; - } else { - const multisigConfigs = { - ...defaultMultisigConfigs, - ...readMultisigConfig(ismConfigPath), - } as ChainMap; - const requiredMultisigs = objFilter( - multisigConfigs, - (chain, config): config is MultisigConfig => - selectedChains.includes(chain), - ); - // selected chains - (user configs + default configs) = missing config - const missingConfigs = selectedChains.filter( - (c) => !Object.keys(requiredMultisigs).includes(c), - ); - if (missingConfigs.length > 0) { - throw new Error( - `Missing ISM config for one or more chains: ${missingConfigs.join( - ', ', - )}`, - ); - } - - log(`Found configs for chains: ${selectedChains.join(', ')}`); - return requiredMultisigs as ChainMap; - } -} - -async function runHookStep( - _selectedChains: ChainName[], - hookConfigPath?: string, -) { - if (!hookConfigPath) return {}; - return readHooksConfigMap(hookConfigPath); -} - -interface DeployParams { - context: WriteCommandContext; - chains: ChainName[]; - ismConfigs?: ChainMap; - multisigConfigs?: ChainMap; - hooksConfig?: ChainMap; - agentOutPath: string; -} - -async function runDeployPlanStep({ context, chains }: DeployParams) { - const { signer, skipConfirmation } = context; - const address = await signer.getAddress(); - - logBlue('\nDeployment plan'); - logGray('==============='); - log(`Transaction signer and owner of new contracts will be ${address}`); - log(`Deploying to ${chains.join(', ')}`); - log( - `There are several contracts required for each chain but contracts in your provided registries will be skipped`, - ); + const initialBalances = await prepareDeploy(context, userAddress, [chain]); - if (skipConfirmation) return; - const isConfirmed = await confirm({ - message: 'Is this deployment plan correct?', - }); - if (!isConfirmed) throw new Error('Deployment cancelled'); -} - -async function executeDeploy({ - context, - chains, - ismConfigs = {}, - multisigConfigs = {}, - hooksConfig = {}, - agentOutPath, -}: DeployParams) { logBlue('All systems ready, captain! Beginning deployment...'); - const { signer, multiProvider, registry } = context; - - let chainAddresses = await registry.getAddresses(); - chainAddresses = filterAddressesToCache(chainAddresses); - - const owner = await signer.getAddress(); - let artifacts: HyperlaneAddressesMap = {}; - - // 1. Deploy ISM factories to all deployable chains that don't have them. - logBlue('Deploying ISM factory contracts'); - const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); - ismFactoryDeployer.cacheAddressesMap(chainAddresses); - - const ismFactoryConfig = chains.reduce((chainMap, curr) => { - chainMap[curr] = {}; - return chainMap; - }, {} as ChainMap<{}>); - const ismFactoryContracts = await ismFactoryDeployer.deploy(ismFactoryConfig); - - artifacts = await updateChainAddresses( - registry, - ismFactoryContracts, - artifacts, - context.isDryRun, - ); - - logGreen('ISM factory contracts deployed'); - - // Build an IsmFactory that covers all chains so that we can - // use it to deploy ISMs to remote chains. - const ismFactory = HyperlaneIsmFactory.fromAddressesMap( - chainAddresses, - multiProvider, - ); - // 3. Construct ISM configs for all deployable chains - const defaultIsms: ChainMap = {}; - for (const ismOrigin of chains) { - defaultIsms[ismOrigin] = - ismConfigs[ismOrigin] ?? - buildIsmConfig(owner, ismOrigin, chains, multisigConfigs); - } - - // 4. Deploy core contracts to chains - logBlue(`Deploying core contracts to ${chains.join(', ')}`); - const coreDeployer = new HyperlaneCoreDeployer(multiProvider, ismFactory); - coreDeployer.cacheAddressesMap(chainAddresses as any); - const coreConfigs = buildCoreConfigMap( - owner, - chains, - defaultIsms, - hooksConfig, - ); - const coreContracts = await coreDeployer.deploy(coreConfigs); - - // 4.5 recover the toplevel ISM address - const isms: HyperlaneAddressesMap = {}; - for (const chain of chains) { - isms[chain] = { - interchainSecurityModule: - coreDeployer.cachedAddresses[chain].interchainSecurityModule, - }; - } - artifacts = objMerge(artifacts, isms); - artifacts = await updateChainAddresses( - registry, - coreContracts, - artifacts, - context.isDryRun, - ); - logGreen('✅ Core contracts deployed'); - log(JSON.stringify(artifacts, null, 2)); - - await writeAgentConfig(context, artifacts, chains, agentOutPath); - - logBlue('Deployment is complete!'); -} - -function filterAddressesToCache(addressesMap: ChainMap) { - // Filter out the certain addresses that must always be - // deployed when deploying to a PI chain. - // See https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/2983 - // And https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/3183 - return objMap(addressesMap, (_chain, addresses) => - objFilter( - addresses, - (contract, _address): _address is string => - !CONTRACT_CACHE_EXCLUSIONS.includes(contract), - ), - ); -} - -function buildIsmConfig( - owner: Address, - local: ChainName, - chains: ChainName[], - multisigIsmConfigs: ChainMap, -): RoutingIsmConfig { - const aggregationIsmConfigs = buildAggregationIsmConfigs( - local, - chains, - multisigIsmConfigs, - ); - return { - owner, - type: IsmType.ROUTING, - domains: aggregationIsmConfigs, - }; -} - -function buildCoreConfigMap( - owner: Address, - chains: ChainName[], - defaultIsms: ChainMap, - hooksConfig: ChainMap, -): ChainMap { - return chains.reduce>((config, chain) => { - const hooks = hooksConfig[chain] ?? presetHookConfigs(owner); - config[chain] = { - owner, - defaultIsm: defaultIsms[chain], - defaultHook: hooks.default, - requiredHook: hooks.required, - }; - return config; - }, {}); -} - -export function buildIgpConfigMap( - owner: Address, - chains: ChainName[], - multisigConfigs: ChainMap, -): ChainMap { - const configMap: ChainMap = {}; - for (const chain of chains) { - const overhead: ChainMap = {}; - const gasOracleType: ChainMap = {}; - for (const remote of chains) { - if (chain === remote) continue; - // TODO: accurate estimate of gas from ChainMap - const threshold = multisigConfigs[remote] - ? multisigConfigs[remote].threshold - : 2; - const validatorsLength = multisigConfigs[remote] - ? multisigConfigs[remote].validators.length - : 3; - overhead[remote] = multisigIsmVerificationCost( - threshold, - validatorsLength, - ); - gasOracleType[remote] = GasOracleContractType.StorageGasOracle; - } - configMap[chain] = { - owner, - beneficiary: owner, - gasOracleType, - overhead, - oracleKey: owner, - }; - } - return configMap; -} - -async function updateChainAddresses( - registry: IRegistry, - newContracts: HyperlaneContractsMap, - otherAddresses: HyperlaneAddressesMap, - isDryRun?: boolean, -) { - let newAddresses = serializeContractsMap(newContracts); - // The HyperlaneCoreDeployer is returning a nested object with ISM addresses - // from other chains, which don't need to be in the artifacts atm. - newAddresses = objMap(newAddresses, (_, newChainAddresses) => { - // For each chain in the addresses chainmap, filter the values to those that are just strings - return objFilter( - newChainAddresses, - (_, value): value is string => typeof value === 'string', - ); + const evmCoreModule = await EvmCoreModule.create({ + chain, + config, + multiProvider: context.multiProvider, }); - const mergedAddresses = objMerge(otherAddresses, newAddresses); - if (isDryRun) return mergedAddresses; + await completeDeploy(context, 'core', initialBalances, userAddress, [chain]); + const deployedAddresses = evmCoreModule.serialize(); - for (const chainName of Object.keys(newContracts)) { + if (!isDryRun) { await registry.updateChain({ - chainName, - addresses: mergedAddresses[chainName], + chainName: chain, + addresses: deployedAddresses, }); } - return mergedAddresses; -} -async function writeAgentConfig( - context: WriteCommandContext, - artifacts: HyperlaneAddressesMap, - chains: ChainName[], - outPath: string, -) { - if (context.isDryRun) return; - log('Writing agent configs'); - const { multiProvider, registry } = context; - const startBlocks: ChainMap = {}; - const core = HyperlaneCore.fromAddressesMap(artifacts, multiProvider); - - for (const chain of chains) { - const mailbox = core.getContracts(chain).mailbox; - startBlocks[chain] = (await mailbox.deployedBlock()).toNumber(); - } - - const chainAddresses = await registry.getAddresses(); - for (const chain of chains) { - if (!chainAddresses[chain].interchainGasPaymaster) { - chainAddresses[chain].interchainGasPaymaster = - ethers.constants.AddressZero; - } - } - const agentConfig = buildAgentConfig( - chains, // Use only the chains that were deployed to - multiProvider, - chainAddresses as any, - startBlocks, - ); - writeJson(outPath, agentConfig); - logGreen('Agent configs written'); + logGreen('✅ Core contract deployments complete:\n'); + log(indentYamlOrJson(yamlStringify(deployedAddresses, null, 2), 4)); } diff --git a/typescript/cli/src/deploy/dry-run.ts b/typescript/cli/src/deploy/dry-run.ts index 11cbe379b..f821dc179 100644 --- a/typescript/cli/src/deploy/dry-run.ts +++ b/typescript/cli/src/deploy/dry-run.ts @@ -21,10 +21,11 @@ export async function forkNetworkToMultiProvider( chain: string, ) { multiProvider = multiProvider.extendChainMetadata({ - [chain]: { blocks: { confirmations: 0 } }, + [chain]: { blocks: { confirmations: 1 } }, }); await setFork(multiProvider, chain); + return multiProvider; } /** diff --git a/typescript/cli/src/deploy/utils.ts b/typescript/cli/src/deploy/utils.ts index f1082b301..2d92db996 100644 --- a/typescript/cli/src/deploy/utils.ts +++ b/typescript/cli/src/deploy/utils.ts @@ -1,7 +1,9 @@ +import { confirm } from '@inquirer/prompts'; import { BigNumber, ethers } from 'ethers'; import { ChainMap, + ChainMetadata, ChainName, IsmConfig, MultisigConfig, @@ -11,7 +13,14 @@ import { Address, ProtocolType } from '@hyperlane-xyz/utils'; import { parseIsmConfig } from '../config/ism.js'; import { WriteCommandContext } from '../context/types.js'; -import { log, logGreen, logPink } from '../logger.js'; +import { + log, + logBlue, + logGray, + logGreen, + logPink, + logTable, +} from '../logger.js'; import { gasBalancesAreSufficient } from '../utils/balances.js'; import { ENV } from '../utils/env.js'; import { assertSigner } from '../utils/keys.js'; @@ -55,6 +64,35 @@ export async function runPreflightChecksForChains({ if (sufficient) logGreen('✅ Balances are sufficient'); } +export async function runDeployPlanStep({ + context, + chain, +}: { + context: WriteCommandContext; + chain: ChainName; +}) { + const { signer, chainMetadata: chainMetadataMap, skipConfirmation } = context; + const address = await signer.getAddress(); + + logBlue('\nDeployment plan'); + logGray('==============='); + log(`Transaction signer and owner of new contracts: ${address}`); + log(`Deploying core contracts to network: ${chain}`); + const transformedChainMetadata = transformChainMetadataForDisplay( + chainMetadataMap[chain], + ); + logTable(transformedChainMetadata); + log( + `Note: There are several contracts required for each chain, but contracts in your provided registries will be skipped.`, + ); + + if (skipConfirmation) return; + const isConfirmed = await confirm({ + message: 'Is this deployment plan correct?', + }); + if (!isConfirmed) throw new Error('Deployment cancelled'); +} + // from parsed types export function isISMConfig( config: ChainMap | ChainMap, @@ -106,7 +144,7 @@ export async function completeDeploy( `\t- Gas required for ${command} ${ isDryRun ? 'dry-run' : 'deploy' } on ${chain}: ${ethers.utils.formatEther(balanceDelta)} ${ - multiProvider.getChainMetadata(chain).nativeToken?.symbol + multiProvider.getChainMetadata(chain).nativeToken?.symbol ?? 'ETH' }`, ); } @@ -117,3 +155,17 @@ export async function completeDeploy( export function toUpperCamelCase(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } + +function transformChainMetadataForDisplay(chainMetadata: ChainMetadata) { + return { + Name: chainMetadata.name, + 'Display Name': chainMetadata.displayName, + 'Chain ID': chainMetadata.chainId, + 'Domain ID': chainMetadata.domainId, + Protocol: chainMetadata.protocol, + 'JSON RPC URL': chainMetadata.rpcUrls[0].http, + 'Native Token: Symbol': chainMetadata.nativeToken?.symbol, + 'Native Token: Name': chainMetadata.nativeToken?.name, + 'Native Token: Decimals': chainMetadata.nativeToken?.decimals, + }; +} diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index dfcd7a2c2..58758eac8 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -1,13 +1,15 @@ import { confirm } from '@inquirer/prompts'; +import { stringify as yamlStringify } from 'yaml'; +import { IRegistry } from '@hyperlane-xyz/registry'; import { - HypXERC20Lockbox__factory, - HypXERC20__factory, -} from '@hyperlane-xyz/core'; -import { + EvmIsmModule, HypERC20Deployer, HypERC721Deployer, + HyperlaneAddresses, HyperlaneContractsMap, + HyperlaneProxyFactoryDeployer, + MultiProvider, TOKEN_TYPE_TO_STANDARD, TokenFactories, TokenType, @@ -15,14 +17,19 @@ import { WarpRouteDeployConfig, getTokenConnectionId, isTokenMetadata, + serializeContracts, } from '@hyperlane-xyz/sdk'; -import { ProtocolType } from '@hyperlane-xyz/utils'; +import { ProtocolType, objMap, promiseObjAll } from '@hyperlane-xyz/utils'; import { readWarpRouteDeployConfig } from '../config/warp.js'; import { MINIMUM_WARP_DEPLOY_GAS } from '../consts.js'; import { WriteCommandContext } from '../context/types.js'; import { log, logBlue, logGray, logGreen, logTable } from '../logger.js'; -import { isFile, runFileSelectionStep } from '../utils/files.js'; +import { + indentYamlOrJson, + isFile, + runFileSelectionStep, +} from '../utils/files.js'; import { completeDeploy, @@ -123,17 +130,124 @@ async function executeDeploy(params: DeployParams) { ? { [dryRunChain]: configMap[dryRunChain] } : configMap; - const deployedContracts = await deployer.deploy(config); + const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); - logGreen('✅ Warp contract deployments complete'); + // For each chain in WarpRouteConfig, deploy each Ism Factory, if it's not in the registry + // Then return a modified config with the ism address as a string + const modifiedConfig = await deployAndResolveWarpIsm( + config, + multiProvider, + registry, + ismFactoryDeployer, + ); + + const deployedContracts = await deployer.deploy(modifiedConfig); const warpCoreConfig = await getWarpCoreConfig(params, deployedContracts); + logGreen('✅ Warp contract deployments complete'); + if (!isDryRun) { - log('Writing deployment artifacts'); + log('Writing deployment artifacts...'); await registry.addWarpRoute(warpCoreConfig); } - log(JSON.stringify(warpCoreConfig, null, 2)); - logBlue('Deployment is complete!'); + log(indentYamlOrJson(yamlStringify(warpCoreConfig, null, 2), 4)); +} + +async function deployAndResolveWarpIsm( + warpConfig: WarpRouteDeployConfig, + multiProvider: MultiProvider, + registry: IRegistry, + ismFactoryDeployer: HyperlaneProxyFactoryDeployer, +): Promise { + return promiseObjAll( + objMap(warpConfig, async (chain, config) => { + // Skip deployment if Ism is empty, or a string + if ( + !config.interchainSecurityModule || + typeof config.interchainSecurityModule === 'string' + ) { + logGray( + `Config Ism is ${ + !config.interchainSecurityModule + ? 'empty' + : config.interchainSecurityModule + }, skipping deployment`, + ); + return config; + } + + logBlue('Loading Registry factory addresses'); + let chainAddresses = await registry.getChainAddresses(chain); // Can includes other addresses + + if (!chainAddresses) { + logGray('Registry factory addresses not found, deploying'); + chainAddresses = serializeContracts( + await ismFactoryDeployer.deployContracts(chain), + ) as Record; + } + + logGray( + `Creating ${config.interchainSecurityModule.type} Ism for ${config.type} token on ${chain} chain`, + ); + + const deployedIsm = await createWarpIsm( + chain, + warpConfig, + multiProvider, + { + domainRoutingIsmFactory: chainAddresses.domainRoutingIsmFactory, + staticAggregationHookFactory: + chainAddresses.staticAggregationHookFactory, + staticAggregationIsmFactory: + chainAddresses.staticAggregationIsmFactory, + staticMerkleRootMultisigIsmFactory: + chainAddresses.staticMerkleRootMultisigIsmFactory, + staticMessageIdMultisigIsmFactory: + chainAddresses.staticMessageIdMultisigIsmFactory, + }, + ); + + logGreen( + `Finished creating ${config.interchainSecurityModule.type} Ism for ${config.type} token on ${chain} chain`, + ); + return { ...warpConfig[chain], interchainSecurityModule: deployedIsm }; + }), + ); +} + +/** + * Deploys the Warp ISM for a given config + * + * @returns The deployed ism address + */ +async function createWarpIsm( + chain: string, + warpConfig: WarpRouteDeployConfig, + multiProvider: MultiProvider, + factoryAddresses: HyperlaneAddresses, +): Promise { + const { + domainRoutingIsmFactory, + staticAggregationHookFactory, + staticAggregationIsmFactory, + staticMerkleRootMultisigIsmFactory, + staticMessageIdMultisigIsmFactory, + } = factoryAddresses; + const evmIsmModule = await EvmIsmModule.create({ + chain, + multiProvider, + mailbox: warpConfig[chain].mailbox, + proxyFactoryFactories: { + domainRoutingIsmFactory, + staticAggregationHookFactory, + staticAggregationIsmFactory, + staticMerkleRootMultisigIsmFactory, + staticMessageIdMultisigIsmFactory, + }, + config: warpConfig[chain].interchainSecurityModule!, + }); + const { deployedIsm } = evmIsmModule.serialize(); + return deployedIsm; } async function getWarpCoreConfig( @@ -165,30 +279,8 @@ async function getWarpCoreConfig( throw new Error('Missing decimals on token metadata'); } - const collateralAddressOrDenom = await (async () => { - if (config.type === TokenType.XERC20Lockbox) { - const provider = context.multiProvider.tryGetProvider(chainName); - if (!provider) { - throw new Error(`Unable to pull provider for ${chainName}`); - } - - const xERC20 = await HypXERC20Lockbox__factory.connect( - config.token, - provider, - ).xERC20(); - const wrappedToken = await HypXERC20__factory.connect( - xERC20, - provider, - ).wrappedToken(); - return wrappedToken; - } - - if (config.type === TokenType.collateral) { - return config.token; - } - - return undefined; - })(); + const collateralAddressOrDenom = + config.type === TokenType.collateral ? config.token : undefined; warpCoreConfig.tokens.push({ chainName, standard: TOKEN_TYPE_TO_STANDARD[config.type], diff --git a/typescript/cli/src/logger.ts b/typescript/cli/src/logger.ts index b2a021d54..d5347c66d 100644 --- a/typescript/cli/src/logger.ts +++ b/typescript/cli/src/logger.ts @@ -35,6 +35,9 @@ export function logColor( } } export const logBlue = (...args: any) => logColor('info', chalk.blue, ...args); +export const logBlueKeyValue = (key: string, value: string) => { + logBlue(`${chalk.bold(`${key}:`)} ${value}`); +}; export const logPink = (...args: any) => logColor('info', chalk.magentaBright, ...args); export const logGray = (...args: any) => logColor('info', chalk.gray, ...args); @@ -43,11 +46,16 @@ export const logGreen = (...args: any) => export const logRed = (...args: any) => logColor('info', chalk.red, ...args); export const logBoldUnderlinedRed = (...args: any) => logColor('info', chalk.red.bold.underline, ...args); +export const logBoldBlue = (...args: any) => + logColor('info', chalk.blue.bold, ...args); export const logTip = (...args: any) => logColor('info', chalk.bgYellow, ...args); export const warnYellow = (...args: any) => logColor('warn', chalk.yellow, ...args); export const errorRed = (...args: any) => logColor('error', chalk.red, ...args); +export const logDebug = (msg: string, ...args: any) => + logger.debug(msg, ...args); + // No support for table in pino so print directly to console export const logTable = (...args: any) => console.table(...args); diff --git a/typescript/cli/src/send/message.ts b/typescript/cli/src/send/message.ts index 102592bbc..ca303f6eb 100644 --- a/typescript/cli/src/send/message.ts +++ b/typescript/cli/src/send/message.ts @@ -1,4 +1,4 @@ -import { ethers } from 'ethers'; +import { stringify as yamlStringify } from 'yaml'; import { ChainName, HyperlaneCore } from '@hyperlane-xyz/sdk'; import { addressToBytes32, timeout } from '@hyperlane-xyz/utils'; @@ -8,6 +8,7 @@ import { CommandContext, WriteCommandContext } from '../context/types.js'; import { runPreflightChecksForChains } from '../deploy/utils.js'; import { errorRed, log, logBlue, logGreen } from '../logger.js'; import { runSingleChainSelectionStep } from '../utils/chains.js'; +import { indentYamlOrJson } from '../utils/files.js'; export async function sendTestMessage({ context, @@ -81,18 +82,12 @@ async function executeDelivery({ const { registry, multiProvider } = context; const chainAddresses = await registry.getAddresses(); const core = HyperlaneCore.fromAddressesMap(chainAddresses, multiProvider); - const mailbox = core.getContracts(origin).mailbox; - let hook = chainAddresses[origin]?.customHook; + const hook = chainAddresses[origin]?.customHook; if (hook) { logBlue(`Using custom hook ${hook} for ${origin} -> ${destination}`); - } else { - hook = await mailbox.defaultHook(); - logBlue(`Using default hook ${hook} for ${origin} -> ${destination}`); } - const destinationDomain = multiProvider.getDomainId(destination); - let txReceipt: ethers.ContractReceipt; try { const recipient = chainAddresses[destination].testRecipient; if (!recipient) { @@ -100,42 +95,32 @@ async function executeDelivery({ } const formattedRecipient = addressToBytes32(recipient); - log('Getting gas quote'); - const value = await mailbox[ - 'quoteDispatch(uint32,bytes32,bytes,bytes,address)' - ]( - destinationDomain, - formattedRecipient, - messageBody, - ethers.utils.hexlify([]), - hook, - ); - log(`Paying for gas with ${value} wei`); - log('Dispatching message'); - const messageTx = await mailbox[ - 'dispatch(uint32,bytes32,bytes,bytes,address)' - ]( - destinationDomain, + const { dispatchTx, message } = await core.sendMessage( + origin, + destination, formattedRecipient, messageBody, - ethers.utils.hexlify([]), hook, - { - value, - }, + undefined, ); - txReceipt = await multiProvider.handleTx(origin, messageTx); - const message = core.getDispatchedMessages(txReceipt)[0]; logBlue(`Sent message from ${origin} to ${recipient} on ${destination}.`); logBlue(`Message ID: ${message.id}`); - log(`Message: ${JSON.stringify(message)}`); + log(`Message:\n${indentYamlOrJson(yamlStringify(message, null, 2), 4)}`); if (selfRelay) { log('Attempting self-relay of message'); - await core.relayMessage(message); + await core.relayMessage(message, dispatchTx); logGreen('Message was self-relayed!'); - return; + } else { + if (skipWaitForDelivery) { + return; + } + + log('Waiting for message delivery on destination chain...'); + // Max wait 10 minutes + await core.waitForMessageProcessed(dispatchTx, 10000, 60); + logGreen('Message was delivered!'); } } catch (e) { errorRed( @@ -143,11 +128,4 @@ async function executeDelivery({ ); throw e; } - - if (skipWaitForDelivery) return; - - log('Waiting for message delivery on destination chain...'); - // Max wait 10 minutes - await core.waitForMessageProcessed(txReceipt, 10000, 60); - logGreen('Message was delivered!'); } diff --git a/typescript/cli/src/send/transfer.ts b/typescript/cli/src/send/transfer.ts index 23cd5ba52..2df762b88 100644 --- a/typescript/cli/src/send/transfer.ts +++ b/typescript/cli/src/send/transfer.ts @@ -10,7 +10,6 @@ import { } from '@hyperlane-xyz/sdk'; import { timeout } from '@hyperlane-xyz/utils'; -import { readWarpRouteConfig } from '../config/warp.js'; import { MINIMUM_TEST_SEND_GAS } from '../consts.js'; import { WriteCommandContext } from '../context/types.js'; import { runPreflightChecksForChains } from '../deploy/utils.js'; @@ -20,20 +19,20 @@ import { runTokenSelectionStep } from '../utils/tokens.js'; export async function sendTestTransfer({ context, - warpConfigPath, + warpCoreConfig, origin, destination, - wei, + amount, recipient, timeoutSec, skipWaitForDelivery, selfRelay, }: { context: WriteCommandContext; - warpConfigPath: string; + warpCoreConfig: WarpCoreConfig; origin?: ChainName; destination?: ChainName; - wei: string; + amount: string; recipient?: string; timeoutSec: number; skipWaitForDelivery: boolean; @@ -41,8 +40,6 @@ export async function sendTestTransfer({ }) { const { chainMetadata } = context; - const warpCoreConfig = readWarpRouteConfig(warpConfigPath); - if (!origin) { origin = await runSingleChainSelectionStep( chainMetadata, @@ -70,7 +67,7 @@ export async function sendTestTransfer({ origin, destination, warpCoreConfig, - wei, + amount, recipient, skipWaitForDelivery, selfRelay, @@ -85,7 +82,7 @@ async function executeDelivery({ origin, destination, warpCoreConfig, - wei, + amount, recipient, skipWaitForDelivery, selfRelay, @@ -94,7 +91,7 @@ async function executeDelivery({ origin: ChainName; destination: ChainName; warpCoreConfig: WarpCoreConfig; - wei: string; + amount: string; recipient?: string; skipWaitForDelivery: boolean; selfRelay?: boolean; @@ -131,7 +128,7 @@ async function executeDelivery({ const senderAddress = await signer.getAddress(); const errors = await warpCore.validateTransfer({ - originTokenAmount: token.amount(wei), + originTokenAmount: token.amount(amount), destination, recipient: recipient ?? senderAddress, sender: senderAddress, @@ -142,7 +139,7 @@ async function executeDelivery({ } const transferTxs = await warpCore.getTransferRemoteTxs({ - originTokenAmount: new TokenAmount(wei, token), + originTokenAmount: new TokenAmount(amount, token), destination, sender: senderAddress, recipient: recipient ?? senderAddress, @@ -164,7 +161,7 @@ async function executeDelivery({ logBlue(`Message ID: ${message.id}`); if (selfRelay) { - await core.relayMessage(message); + await core.relayMessage(message, transferTxReceipt); logGreen('Message was self-relayed!'); return; } diff --git a/typescript/cli/src/status/message.ts b/typescript/cli/src/status/message.ts index 77118b6de..d7b36efd1 100644 --- a/typescript/cli/src/status/message.ts +++ b/typescript/cli/src/status/message.ts @@ -57,7 +57,7 @@ export async function checkMessageStatus({ const receipt = await core.getDispatchTx(origin, messageId); const messages = core.getDispatchedMessages(receipt); - await core.relayMessage(messages[0]); + await core.relayMessage(messages[0], receipt); logGreen(`Message ${messageId} was self-relayed!`); } } diff --git a/typescript/cli/src/submit/submit.ts b/typescript/cli/src/submit/submit.ts index cc344c2f0..fcde4afa4 100644 --- a/typescript/cli/src/submit/submit.ts +++ b/typescript/cli/src/submit/submit.ts @@ -75,7 +75,7 @@ async function getTransformer( transformerMetadata: TransformerMetadata, ): Promise> { switch (transformerMetadata.type) { - case TxTransformerType.ICA: + case TxTransformerType.INTERCHAIN_ACCOUNT: return new EV5InterchainAccountTxTransformer( multiProvider, transformerMetadata.props, diff --git a/typescript/cli/src/tests/hooks.test.ts b/typescript/cli/src/tests/hooks.test.ts index 2848c0c06..3cb15d84d 100644 --- a/typescript/cli/src/tests/hooks.test.ts +++ b/typescript/cli/src/tests/hooks.test.ts @@ -1,19 +1,14 @@ import { expect } from 'chai'; -import { - ChainMap, - GasOracleContractType, - HookType, - HooksConfig, -} from '@hyperlane-xyz/sdk'; +import { HookType } from '@hyperlane-xyz/sdk'; -import { readHooksConfigMap } from '../config/hooks.js'; +import { HooksConfigMap, readHooksConfigMap } from '../config/hooks.js'; describe('readHooksConfigMap', () => { it('parses and validates example correctly', () => { const hooks = readHooksConfigMap('examples/hooks.yaml'); - const exampleHooksConfig: ChainMap = { + const exampleHooksConfig: HooksConfigMap = { anvil1: { required: { type: HookType.PROTOCOL_FEE, @@ -37,10 +32,13 @@ describe('readHooksConfigMap', () => { beneficiary: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', oracleKey: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - gasOracleType: { - anvil2: GasOracleContractType.StorageGasOracle, - }, overhead: { anvil2: 50000 }, + oracleConfig: { + anvil2: { + gasPrice: '100', + tokenExchangeRate: '100', + }, + }, }, ], }, @@ -70,10 +68,13 @@ describe('readHooksConfigMap', () => { beneficiary: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', oracleKey: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - gasOracleType: { - anvil1: GasOracleContractType.StorageGasOracle, - }, overhead: { anvil1: 50000 }, + oracleConfig: { + anvil1: { + gasPrice: '100', + tokenExchangeRate: '100', + }, + }, }, ], }, @@ -87,6 +88,6 @@ describe('readHooksConfigMap', () => { it('parsing failure, missing internal key "overhead"', () => { expect(() => { readHooksConfigMap('src/tests/hooks/safe-parse-fail.yaml'); - }).to.throw('Invalid hook config: anvil2,default => Invalid input'); + }).to.throw(); }); }); diff --git a/typescript/cli/src/tests/hooks/safe-parse-fail.yaml b/typescript/cli/src/tests/hooks/safe-parse-fail.yaml index 4a2a5cedb..7257817d4 100644 --- a/typescript/cli/src/tests/hooks/safe-parse-fail.yaml +++ b/typescript/cli/src/tests/hooks/safe-parse-fail.yaml @@ -19,8 +19,6 @@ anvil1: oracleKey: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' overhead: anvil2: 50000 - gasOracleType: - anvil2: StorageGasOracle anvil2: required: type: protocolFee @@ -40,5 +38,3 @@ anvil2: beneficiary: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' oracleKey: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' - gasOracleType: - anvil1: StorageGasOracle diff --git a/typescript/cli/src/tests/ism.test.ts b/typescript/cli/src/tests/ism.test.ts index 4942963cf..04d84caa9 100644 --- a/typescript/cli/src/tests/ism.test.ts +++ b/typescript/cli/src/tests/ism.test.ts @@ -80,6 +80,8 @@ describe('readIsmConfig', () => { it('parsing failure, threshold > modules.length', () => { expect(function () { readIsmConfig('src/tests/ism/threshold-gt-modules-length-fail.yaml'); - }).to.throw('Threshold cannot be greater than number of modules'); + }).to.throw( + 'Threshold must be less than or equal to the number of modules', + ); }); }); diff --git a/typescript/cli/src/utils/chains.ts b/typescript/cli/src/utils/chains.ts index 3c0e7477d..b22f7683a 100644 --- a/typescript/cli/src/utils/chains.ts +++ b/typescript/cli/src/utils/chains.ts @@ -1,4 +1,4 @@ -import { Separator, checkbox, confirm, input } from '@inquirer/prompts'; +import { Separator, checkbox } from '@inquirer/prompts'; import select from '@inquirer/select'; import chalk from 'chalk'; @@ -6,6 +6,8 @@ import { ChainMap, ChainMetadata } from '@hyperlane-xyz/sdk'; import { log, logRed, logTip } from '../logger.js'; +import { calculatePageSize } from './cli-options.js'; + // A special value marker to indicate user selected // a new chain in the list const NEW_CHAIN_MARKER = '__new__'; @@ -18,7 +20,7 @@ export async function runSingleChainSelectionStep( const chain = (await select({ message, choices, - pageSize: 30, + pageSize: calculatePageSize(2), })) as string; handleNewChain([chain]); return chain; @@ -35,7 +37,7 @@ export async function runMultiChainSelectionStep( const chains = (await checkbox({ message, choices, - pageSize: 30, + pageSize: calculatePageSize(2), })) as string[]; handleNewChain(chains); if (requireMultiple && chains?.length < 2) { @@ -73,24 +75,3 @@ function handleNewChain(chainNames: string[]) { process.exit(0); } } - -export async function detectAndConfirmOrPrompt( - detect: () => Promise, - prompt: string, - label: string, -): Promise { - let detectedValue: string | undefined; - try { - detectedValue = await detect(); - if (detectedValue) { - const confirmed = await confirm({ - message: `Detected ${label} as ${detectedValue}, is this correct?`, - }); - if (confirmed) { - return detectedValue; - } - } - // eslint-disable-next-line no-empty - } catch (e) {} - return input({ message: `${prompt} ${label}`, default: detectedValue }); -} diff --git a/typescript/cli/src/utils/cli-options.ts b/typescript/cli/src/utils/cli-options.ts new file mode 100644 index 000000000..415452e7e --- /dev/null +++ b/typescript/cli/src/utils/cli-options.ts @@ -0,0 +1,17 @@ +// Functions used to manipulate CLI specific options + +/** + * Calculates the page size for a CLI Terminal output, taking into account the number of lines to skip and a default page size. + * + * @param skipSize - The number of lines to skip, which can be used to skip previous prompts. + * @param defaultPageSize - The default page size to use if the terminal height is too small. + * @returns The calculated pageSize, which is the terminal height minus the skip size, or the default page size if the terminal height is too small. + */ +export function calculatePageSize( + skipSize: number = 0, + defaultPageSize: number = 15, +) { + return process.stdout.rows > skipSize + ? process.stdout.rows - skipSize + : defaultPageSize; +} diff --git a/typescript/cli/src/utils/files.ts b/typescript/cli/src/utils/files.ts index 844eb4b79..277850af6 100644 --- a/typescript/cli/src/utils/files.ts +++ b/typescript/cli/src/utils/files.ts @@ -210,3 +210,11 @@ export async function runFileSelectionStep( if (filename) return filename; else throw new Error(`No filepath entered ${description}`); } + +export function indentYamlOrJson(str: string, indentLevel: number): string { + const indent = ' '.repeat(indentLevel); + return str + .split('\n') + .map((line) => indent + line) + .join('\n'); +} diff --git a/typescript/cli/src/utils/input.ts b/typescript/cli/src/utils/input.ts new file mode 100644 index 000000000..0f8c9ef66 --- /dev/null +++ b/typescript/cli/src/utils/input.ts @@ -0,0 +1,55 @@ +import { confirm, input } from '@inquirer/prompts'; + +import { logGray } from '../logger.js'; + +import { indentYamlOrJson } from './files.js'; + +export async function detectAndConfirmOrPrompt( + detect: () => Promise, + prompt: string, + label: string, + source?: string, +): Promise { + let detectedValue: string | undefined; + try { + detectedValue = await detect(); + if (detectedValue) { + const confirmed = await confirm({ + message: `Detected ${label} as ${detectedValue}${ + source ? ` from ${source}` : '' + }, is this correct?`, + }); + if (confirmed) { + return detectedValue; + } + } + // eslint-disable-next-line no-empty + } catch (e) {} + return input({ message: `${prompt} ${label}:`, default: detectedValue }); +} + +const INFO_COMMAND: string = 'i'; +const DOCS_NOTICE: string = + 'For more information, please visit https://docs.hyperlane.xyz.'; + +export async function inputWithInfo({ + message, + info = 'No additional information available.', + defaultAnswer, +}: { + message: string; + info?: string; + defaultAnswer?: string; +}): Promise { + let answer: string = ''; + do { + answer = await input({ + message: message.concat(` [enter '${INFO_COMMAND}' for more info]`), + default: defaultAnswer, + }); + answer = answer.trim().toLowerCase(); + const indentedInfo = indentYamlOrJson(`${info}\n${DOCS_NOTICE}\n`, 4); + if (answer === INFO_COMMAND) logGray(indentedInfo); + } while (answer === INFO_COMMAND); + return answer; +} diff --git a/typescript/cli/src/utils/keys.ts b/typescript/cli/src/utils/keys.ts index a7d2abb26..552b8d53d 100644 --- a/typescript/cli/src/utils/keys.ts +++ b/typescript/cli/src/utils/keys.ts @@ -68,7 +68,7 @@ async function addressToImpersonatedSigner( if (address.length != ETHEREUM_ADDRESS_LENGTH) throw new Error('Invalid address length.'); else if (ethers.utils.isHexString(ensure0x(formattedKey))) - return await impersonateAccount(address); + return impersonateAccount(address); else throw new Error('Invalid address format'); } @@ -93,7 +93,7 @@ async function retrieveKey( ): Promise { if (skipConfirmation) throw new Error(`No private key provided`); else - return await input({ + return input({ message: `Please enter private key or use the HYP_KEY environment variable.`, }); } diff --git a/typescript/cli/src/utils/tokens.ts b/typescript/cli/src/utils/tokens.ts index ed876238c..b31a2ddf3 100644 --- a/typescript/cli/src/utils/tokens.ts +++ b/typescript/cli/src/utils/tokens.ts @@ -1,6 +1,9 @@ import select from '@inquirer/select'; -import { Token } from '@hyperlane-xyz/sdk'; +import { IRegistry } from '@hyperlane-xyz/registry'; +import { Token, WarpCoreConfig } from '@hyperlane-xyz/sdk'; + +import { logGreen, logRed } from '../logger.js'; export async function runTokenSelectionStep( tokens: Token[], @@ -17,3 +20,32 @@ export async function runTokenSelectionStep( })) as string; return routerAddress; } + +export async function selectRegistryWarpRoute( + registry: IRegistry, + symbol: string, +): Promise { + const matching = await registry.getWarpRoutes({ + symbol, + }); + const routes = Object.entries(matching); + + let warpCoreConfig: WarpCoreConfig; + if (routes.length === 0) { + logRed(`No warp routes found for symbol ${symbol}`); + process.exit(0); + } else if (routes.length === 1) { + warpCoreConfig = routes[0][1]; + } else { + logGreen(`Multiple warp routes found for symbol ${symbol}`); + const chosenRouteId = await select({ + message: 'Select from matching warp routes', + choices: routes.map(([routeId, _]) => ({ + value: routeId, + })), + }); + warpCoreConfig = matching[chosenRouteId]; + } + + return warpCoreConfig; +} diff --git a/typescript/cli/src/validator/address.ts b/typescript/cli/src/validator/address.ts index d816fcb1f..cc13738db 100644 --- a/typescript/cli/src/validator/address.ts +++ b/typescript/cli/src/validator/address.ts @@ -24,7 +24,7 @@ export async function getValidatorAddress({ region?: string; bucket?: string; keyId?: string; -}) { +}): Promise { if (!bucket && !keyId) { throw new Error('Must provide either an S3 bucket or a KMS Key ID.'); } @@ -38,7 +38,7 @@ export async function getValidatorAddress({ assert(secretKey, 'No secret access key set.'); assert(region, 'No AWS region set.'); - let validatorAddress; + let validatorAddress: string; if (bucket) { validatorAddress = await getAddressFromBucket( bucket, @@ -68,7 +68,7 @@ async function getAddressFromBucket( accessKeyId: string, secretAccessKey: string, region: string, -) { +): Promise { const s3Client = new S3Client({ region: region, credentials: { @@ -101,7 +101,7 @@ async function getAddressFromKey( accessKeyId: string, secretAccessKey: string, region: string, -) { +): Promise { const client = new KMSClient({ region: region, credentials: { @@ -138,28 +138,28 @@ function getEthereumAddress(publicKey: Buffer): string { return `0x${address.slice(-40)}`; // take last 20 bytes as ethereum address } -async function getAccessKeyId(skipConfirmation: boolean) { +async function getAccessKeyId(skipConfirmation: boolean): Promise { if (skipConfirmation) throw new Error('No AWS access key ID set.'); else - return await input({ + return input({ message: 'Please enter AWS access key ID or use the AWS_ACCESS_KEY_ID environment variable.', }); } -async function getSecretAccessKey(skipConfirmation: boolean) { +async function getSecretAccessKey(skipConfirmation: boolean): Promise { if (skipConfirmation) throw new Error('No AWS secret access key set.'); else - return await input({ + return input({ message: 'Please enter AWS secret access key or use the AWS_SECRET_ACCESS_KEY environment variable.', }); } -async function getRegion(skipConfirmation: boolean) { +async function getRegion(skipConfirmation: boolean): Promise { if (skipConfirmation) throw new Error('No AWS region set.'); else - return await input({ + return input({ message: 'Please enter AWS region or use the AWS_REGION environment variable.', }); diff --git a/typescript/cli/src/validator/preFlightCheck.ts b/typescript/cli/src/validator/preFlightCheck.ts new file mode 100644 index 000000000..ca0c4d850 --- /dev/null +++ b/typescript/cli/src/validator/preFlightCheck.ts @@ -0,0 +1,117 @@ +import { MerkleTreeHook__factory } from '@hyperlane-xyz/core'; +import { HyperlaneCore, S3Validator } from '@hyperlane-xyz/sdk'; +import { Address } from '@hyperlane-xyz/utils'; + +import { CommandContext } from '../context/types.js'; +import { errorRed, logBlue, logGreen, warnYellow } from '../logger.js'; + +export const checkValidatorSetup = async ( + context: CommandContext, + chain: string, + validators: Set
, +) => { + const { multiProvider, registry } = context; + + const addresses = await registry.getAddresses(); + + const core = HyperlaneCore.fromAddressesMap(addresses, multiProvider); + + const validatorAnnounce = core.getContracts(chain).validatorAnnounce; + const merkleTreeHook = MerkleTreeHook__factory.connect( + addresses[chain].merkleTreeHook, + multiProvider.getProvider(chain), + ); + + let merkleTreeLatestCheckpointIndex: number | undefined; + try { + const [_, latestCheckpointIndex] = await merkleTreeHook.latestCheckpoint(); + + merkleTreeLatestCheckpointIndex = latestCheckpointIndex; + logBlue( + `\nLatest checkpoint index of incremental merkle tree: ${merkleTreeLatestCheckpointIndex}\n`, + ); + } catch (err) { + warnYellow( + `❗️ Failed to fetch latest checkpoint index of merkleTreeHook on ${chain}: ${err} \n`, + ); + } + + const errorSet = new Set(); + + const validatorsArray = Array.from(validators); + let validatorStorageLocations: string[][] | undefined; + + try { + validatorStorageLocations = + await validatorAnnounce.getAnnouncedStorageLocations(validatorsArray); + } catch (e) { + errorSet.add('Failed to read announced storage locations on chain.'); + } + + if (validatorStorageLocations) { + for (let i = 0; i < validatorsArray.length; i++) { + const validator = validatorsArray[i]; + const storageLocations = validatorStorageLocations[i]; + + if (storageLocations.length === 0) { + errorRed(`❌ Validator ${validator} has not been announced\n`); + errorSet.add('Some validators have not been announced.'); + continue; + } + + const s3StorageLocation = storageLocations[0]; + + let s3Validator: S3Validator; + try { + s3Validator = await S3Validator.fromStorageLocation(s3StorageLocation); + } catch (e) { + errorRed( + `❌ Failed to fetch storage locations for validator ${validator}, this may be due to the storage location not being an S3 bucket\n\n`, + ); + errorSet.add('Failed to fetch storage locations for some validators.'); + continue; + } + + const latestCheckpointIndex = + await s3Validator.getLatestCheckpointIndex(); + + logBlue( + `✅ Validator ${validator} announced\nstorage location: ${s3StorageLocation}\nlatest checkpoint index: ${latestCheckpointIndex}`, + ); + + // check is latestCheckpointIndex is within 1% of the merkleTreeLatestCheckpointIndex + if (merkleTreeLatestCheckpointIndex) { + const diff = Math.abs( + latestCheckpointIndex - merkleTreeLatestCheckpointIndex, + ); + if (diff > merkleTreeLatestCheckpointIndex / 100) { + errorRed( + `❌ Validator is not signing the latest available checkpoint\n\n`, + ); + errorSet.add( + `Some validators are not signing the latest available checkpoint`, + ); + } else { + logBlue( + `✅ Validator is signing the latest available checkpoint\n\n`, + ); + } + } else { + warnYellow( + `❗️ Cannot compare validator checkpoint signatures to latest checkpoint in the incremental merkletree, merkletree checkpoint could not be read\n`, + ); + } + } + } + + if (errorSet.size > 0) { + errorRed( + `\n❌ Validator pre flight check failed:\n${Array.from(errorSet).join( + '\n', + )}`, + ); + process.exit(1); + } else { + logGreen(`\n✅ Validator pre flight check passed`); + } +}; diff --git a/typescript/cli/src/validator/utils.ts b/typescript/cli/src/validator/utils.ts new file mode 100644 index 000000000..c96aa6612 --- /dev/null +++ b/typescript/cli/src/validator/utils.ts @@ -0,0 +1,69 @@ +import { MerkleTreeHook, ValidatorAnnounce } from '@hyperlane-xyz/core'; +import { S3Validator } from '@hyperlane-xyz/sdk'; + +import { logDebug } from '../logger.js'; + +export const getLatestMerkleTreeCheckpointIndex = async ( + merkleTreeHook: MerkleTreeHook, + chainName?: string, +): Promise => { + try { + const [_, latestCheckpointIndex] = await merkleTreeHook.latestCheckpoint(); + return latestCheckpointIndex; + } catch (err) { + const debugMessage = `Failed to get latest checkpoint index from merkleTreeHook contract ${ + chainName ? `on ${chainName}` : '' + } : ${err}`; + logDebug(debugMessage); + return undefined; + } +}; + +export const getValidatorStorageLocations = async ( + validatorAnnounce: ValidatorAnnounce, + validators: string[], + chainName?: string, +): Promise => { + try { + return await validatorAnnounce.getAnnouncedStorageLocations(validators); + } catch (err) { + const debugMessage = `Failed to get announced storage locations from validatorAnnounce contract ${ + chainName ? `on ${chainName}` : '' + } : ${err}`; + logDebug(debugMessage); + return undefined; + } +}; + +export const getLatestValidatorCheckpointIndexAndUrl = async ( + s3StorageLocation: string, +): Promise<[number, string] | undefined> => { + let s3Validator: S3Validator; + try { + s3Validator = await S3Validator.fromStorageLocation(s3StorageLocation); + } catch (err) { + logDebug( + `Failed to instantiate S3Validator at location ${s3StorageLocation}: ${err}`, + ); + return undefined; + } + try { + const latestCheckpointIndex = await s3Validator.getLatestCheckpointIndex(); + return [latestCheckpointIndex, s3Validator.getLatestCheckpointUrl()]; + } catch (err) { + logDebug( + `Failed to get latest checkpoint index from S3Validator at location ${s3StorageLocation}: ${err}`, + ); + return undefined; + } +}; + +export const isValidatorSigningLatestCheckpoint = ( + latestValidatorCheckpointIndex: number, + latestMerkleTreeCheckpointIndex: number, +): boolean => { + const diff = Math.abs( + latestValidatorCheckpointIndex - latestMerkleTreeCheckpointIndex, + ); + return diff < latestMerkleTreeCheckpointIndex / 100; +}; diff --git a/typescript/cli/src/version.ts b/typescript/cli/src/version.ts index d244ae529..afb51c559 100644 --- a/typescript/cli/src/version.ts +++ b/typescript/cli/src/version.ts @@ -1 +1 @@ -export const VERSION = '3.16.0'; +export const VERSION = '4.0.0'; diff --git a/typescript/helloworld/CHANGELOG.md b/typescript/helloworld/CHANGELOG.md index e4fa352eb..5666e1d0e 100644 --- a/typescript/helloworld/CHANGELOG.md +++ b/typescript/helloworld/CHANGELOG.md @@ -1,5 +1,25 @@ # @hyperlane-xyz/helloworld +## 4.0.0 + +### Minor Changes + +- 6398aab72: Upgrade registry to 2.1.1 +- bf7ad09da: feat(cli): add `warp --symbol` flag + +### Patch Changes + +- Updated dependencies [44cc9bf6b] +- Updated dependencies [b05ae38ac] +- Updated dependencies [9304fe241] +- Updated dependencies [bdcbe1d16] +- Updated dependencies [6b63c5d82] +- Updated dependencies [e38d31685] +- Updated dependencies [e0f226806] +- Updated dependencies [6db9fa9ad] + - @hyperlane-xyz/core@4.0.0 + - @hyperlane-xyz/sdk@4.0.0 + ## 3.16.0 ### Patch Changes diff --git a/typescript/helloworld/package.json b/typescript/helloworld/package.json index 7d8520976..33745c2ca 100644 --- a/typescript/helloworld/package.json +++ b/typescript/helloworld/package.json @@ -1,11 +1,11 @@ { "name": "@hyperlane-xyz/helloworld", "description": "A basic skeleton of an Hyperlane app", - "version": "3.16.0", + "version": "4.0.0", "dependencies": { - "@hyperlane-xyz/core": "3.16.0", + "@hyperlane-xyz/core": "4.0.0", "@hyperlane-xyz/registry": "2.1.1", - "@hyperlane-xyz/sdk": "3.16.0", + "@hyperlane-xyz/sdk": "4.0.0", "@openzeppelin/contracts-upgradeable": "^4.9.3", "ethers": "^5.7.2" }, diff --git a/typescript/helloworld/src/deploy/deploy.ts b/typescript/helloworld/src/deploy/deploy.ts index b7dccecda..8fcbc6185 100644 --- a/typescript/helloworld/src/deploy/deploy.ts +++ b/typescript/helloworld/src/deploy/deploy.ts @@ -38,8 +38,9 @@ export class HelloWorldDeployer extends HyperlaneRouterDeployer< async deployContracts(chain: ChainName, config: HelloWorldConfig) { const router = await this.deployContract(chain, 'router', [ config.mailbox, - config.hook ?? ethers.constants.AddressZero, + ethers.constants.AddressZero, ]); + await super.configureClient(chain, router, config); return { router, }; diff --git a/typescript/infra/CHANGELOG.md b/typescript/infra/CHANGELOG.md index b21d74c4f..5a9ac5e9a 100644 --- a/typescript/infra/CHANGELOG.md +++ b/typescript/infra/CHANGELOG.md @@ -1,5 +1,27 @@ # @hyperlane-xyz/infra +## 4.0.0 + +### Minor Changes + +- 6398aab72: Upgrade registry to 2.1.1 +- bf7ad09da: feat(cli): add `warp --symbol` flag + +### Patch Changes + +- Updated dependencies [b05ae38ac] +- Updated dependencies [9304fe241] +- Updated dependencies [6398aab72] +- Updated dependencies [bdcbe1d16] +- Updated dependencies [6b63c5d82] +- Updated dependencies [bf7ad09da] +- Updated dependencies [e38d31685] +- Updated dependencies [e0f226806] +- Updated dependencies [6db9fa9ad] + - @hyperlane-xyz/sdk@4.0.0 + - @hyperlane-xyz/helloworld@4.0.0 + - @hyperlane-xyz/utils@4.0.0 + ## 3.16.0 ### Minor Changes diff --git a/typescript/infra/config/environments/mainnet3/core.ts b/typescript/infra/config/environments/mainnet3/core.ts index 258b37c1c..e2a218662 100644 --- a/typescript/infra/config/environments/mainnet3/core.ts +++ b/typescript/infra/config/environments/mainnet3/core.ts @@ -7,7 +7,6 @@ import { CoreConfig, FallbackRoutingHookConfig, HookType, - IgpHookConfig, IsmType, MerkleTreeHookConfig, MultisigConfig, @@ -59,6 +58,7 @@ export const core: ChainMap = objMap(owners, (local, owner) => { const pausableIsm: PausableIsmConfig = { type: IsmType.PAUSABLE, + paused: false, owner: DEPLOYER, // keep pausable hot }; @@ -72,13 +72,11 @@ export const core: ChainMap = objMap(owners, (local, owner) => { type: HookType.MERKLE_TREE, }; - const igpHook: IgpHookConfig = { - type: HookType.INTERCHAIN_GAS_PAYMASTER, - ...igp[local], - }; + const igpHook = igp[local]; const pausableHook: PausableHookConfig = { type: HookType.PAUSABLE, + paused: false, owner: DEPLOYER, // keep pausable hot }; const aggregationHooks = objMap( diff --git a/typescript/infra/config/environments/mainnet3/igp.ts b/typescript/infra/config/environments/mainnet3/igp.ts index cc7fa27ee..89f083ee8 100644 --- a/typescript/infra/config/environments/mainnet3/igp.ts +++ b/typescript/infra/config/environments/mainnet3/igp.ts @@ -3,6 +3,7 @@ import { BigNumber, ethers } from 'ethers'; import { ChainMap, ChainName, + HookType, IgpConfig, TOKEN_EXCHANGE_RATE_DECIMALS, defaultMultisigConfigs, @@ -57,20 +58,24 @@ const storageGasOracleConfig: AllStorageGasOracleConfigs = (local) => remoteOverhead(local), ); -export const igp: ChainMap = objMap(owners, (local, owner) => ({ - ...owner, - ownerOverrides: { - ...owner.ownerOverrides, - interchainGasPaymaster: DEPLOYER, - storageGasOracle: DEPLOYER, - }, - oracleKey: DEPLOYER, - beneficiary: DEPLOYER, - overhead: Object.fromEntries( - exclude(local, supportedChainNames).map((remote) => [ - remote, - remoteOverhead(remote), - ]), - ), - oracleConfig: storageGasOracleConfig[local], -})); +export const igp: ChainMap = objMap( + owners, + (local, owner): IgpConfig => ({ + type: HookType.INTERCHAIN_GAS_PAYMASTER, + ...owner, + ownerOverrides: { + ...owner.ownerOverrides, + interchainGasPaymaster: DEPLOYER, + storageGasOracle: DEPLOYER, + }, + oracleKey: DEPLOYER, + beneficiary: DEPLOYER, + overhead: Object.fromEntries( + exclude(local, supportedChainNames).map((remote) => [ + remote, + remoteOverhead(remote), + ]), + ), + oracleConfig: storageGasOracleConfig[local], + }), +); diff --git a/typescript/infra/config/environments/test/core.ts b/typescript/infra/config/environments/test/core.ts index 6063d71c0..4e5988b80 100644 --- a/typescript/infra/config/environments/test/core.ts +++ b/typescript/infra/config/environments/test/core.ts @@ -6,7 +6,6 @@ import { CoreConfig, FallbackRoutingHookConfig, HookType, - IgpHookConfig, IsmType, MerkleTreeHookConfig, ProtocolFeeHookConfig, @@ -34,10 +33,7 @@ export const core: ChainMap = objMap(owners, (local, owner) => { type: HookType.MERKLE_TREE, }; - const igpHook: IgpHookConfig = { - type: HookType.INTERCHAIN_GAS_PAYMASTER, - ...igp[local], - }; + const igpHook = igp[local]; const aggregationHook: AggregationHookConfig = { type: HookType.AGGREGATION, diff --git a/typescript/infra/config/environments/test/igp.ts b/typescript/infra/config/environments/test/igp.ts index b47be48e6..e63e3de59 100644 --- a/typescript/infra/config/environments/test/igp.ts +++ b/typescript/infra/config/environments/test/igp.ts @@ -1,7 +1,6 @@ import { ChainMap, - ChainName, - GasOracleContractType, + HookType, IgpConfig, multisigIsmVerificationCost, } from '@hyperlane-xyz/sdk'; @@ -11,30 +10,25 @@ import { testChainNames } from './chains.js'; import { multisigIsm } from './multisigIsm.js'; import { owners } from './owners.js'; -function getGasOracles(local: ChainName) { - return Object.fromEntries( - exclude(local, testChainNames).map((name) => [ - name, - GasOracleContractType.StorageGasOracle, - ]), - ); -} - -export const igp: ChainMap = objMap(owners, (chain, ownerConfig) => { - const overhead = Object.fromEntries( - exclude(chain, testChainNames).map((remote) => [ - remote, - multisigIsmVerificationCost( - multisigIsm[remote].threshold, - multisigIsm[remote].validators.length, - ), - ]), - ); - return { - oracleKey: ownerConfig.owner as Address, // owner can be AccountConfig - beneficiary: ownerConfig.owner as Address, // same as above - gasOracleType: getGasOracles(chain), - overhead, - ...ownerConfig, - }; -}); +export const igp: ChainMap = objMap( + owners, + (chain, ownerConfig): IgpConfig => { + const overhead = Object.fromEntries( + exclude(chain, testChainNames).map((remote) => [ + remote, + multisigIsmVerificationCost( + multisigIsm[remote].threshold, + multisigIsm[remote].validators.length, + ), + ]), + ); + return { + type: HookType.INTERCHAIN_GAS_PAYMASTER, + oracleKey: ownerConfig.owner as Address, // owner can be AccountConfig + beneficiary: ownerConfig.owner as Address, // same as above + overhead, + oracleConfig: {}, + ...ownerConfig, + }; + }, +); diff --git a/typescript/infra/config/environments/test/multisigIsm.ts b/typescript/infra/config/environments/test/multisigIsm.ts index afa7916c9..733f5907d 100644 --- a/typescript/infra/config/environments/test/multisigIsm.ts +++ b/typescript/infra/config/environments/test/multisigIsm.ts @@ -1,11 +1,18 @@ -import { ChainMap, IsmType, MultisigIsmConfig } from '@hyperlane-xyz/sdk'; +import { + ChainMap, + IsmType, + MultisigIsmConfig, + TestChainName, +} from '@hyperlane-xyz/sdk'; +import { Address } from '@hyperlane-xyz/utils'; // the addresses here must line up with the e2e test's validator addresses -// Validators are anvil accounts 4-6 -export const chainToValidator: Record = { +// Validators are anvil accounts 4-7 +export const chainToValidator: Record = { test1: '0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65', test2: '0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc', test3: '0x976EA74026E726554dB657fA54763abd0C3a0aa9', + test4: '0x14dC79964da2C08b23698B3D3cc7Ca32193d9955', }; export const merkleRootMultisig = (validatorKey: string): MultisigIsmConfig => { @@ -26,8 +33,9 @@ export const messageIdMultisig = (validatorKey: string): MultisigIsmConfig => { // the addresses here must line up with the e2e test's validator addresses export const multisigIsm: ChainMap = { - // Validators are anvil accounts 4-6 + // Validators are anvil accounts 4-7 test1: messageIdMultisig(chainToValidator['test1']), test2: merkleRootMultisig(chainToValidator['test2']), test3: messageIdMultisig(chainToValidator['test3']), + test4: messageIdMultisig(chainToValidator['test4']), }; diff --git a/typescript/infra/config/environments/testnet4/core.ts b/typescript/infra/config/environments/testnet4/core.ts index df52631f3..121bcc90e 100644 --- a/typescript/infra/config/environments/testnet4/core.ts +++ b/typescript/infra/config/environments/testnet4/core.ts @@ -7,7 +7,6 @@ import { CoreConfig, FallbackRoutingHookConfig, HookType, - IgpHookConfig, IsmType, MerkleTreeHookConfig, MultisigConfig, @@ -61,6 +60,7 @@ export const core: ChainMap = objMap( const pausableIsm: PausableIsmConfig = { type: IsmType.PAUSABLE, + paused: false, ...ownerConfig, }; @@ -74,13 +74,11 @@ export const core: ChainMap = objMap( type: HookType.MERKLE_TREE, }; - const igpHook: IgpHookConfig = { - type: HookType.INTERCHAIN_GAS_PAYMASTER, - ...igp[local], - }; + const igpHook = igp[local]; const pausableHook: PausableHookConfig = { type: HookType.PAUSABLE, + paused: false, ...ownerConfig, }; diff --git a/typescript/infra/config/environments/testnet4/igp.ts b/typescript/infra/config/environments/testnet4/igp.ts index f0736d46e..4c388b4f0 100644 --- a/typescript/infra/config/environments/testnet4/igp.ts +++ b/typescript/infra/config/environments/testnet4/igp.ts @@ -1,5 +1,6 @@ import { ChainMap, + HookType, IgpConfig, defaultMultisigConfigs, multisigIsmVerificationCost, @@ -10,21 +11,25 @@ import { storageGasOracleConfig } from './gas-oracle.js'; import { owners } from './owners.js'; import { supportedChainNames } from './supportedChainNames.js'; -export const igp: ChainMap = objMap(owners, (chain, ownerConfig) => { - return { - ...ownerConfig, - oracleKey: ownerConfig.owner as Address, - beneficiary: ownerConfig.owner as Address, - oracleConfig: storageGasOracleConfig[chain], - overhead: Object.fromEntries( - exclude(chain, supportedChainNames).map((remote) => [ - remote, - multisigIsmVerificationCost( - // TODO: parameterize this - defaultMultisigConfigs[remote].threshold, - defaultMultisigConfigs[remote].validators.length, - ), - ]), - ), - }; -}); +export const igp: ChainMap = objMap( + owners, + (chain, ownerConfig): IgpConfig => { + return { + type: HookType.INTERCHAIN_GAS_PAYMASTER, + ...ownerConfig, + oracleKey: ownerConfig.owner as Address, + beneficiary: ownerConfig.owner as Address, + oracleConfig: storageGasOracleConfig[chain], + overhead: Object.fromEntries( + exclude(chain, supportedChainNames).map((remote) => [ + remote, + multisigIsmVerificationCost( + // TODO: parameterize this + defaultMultisigConfigs[remote].threshold, + defaultMultisigConfigs[remote].validators.length, + ), + ]), + ), + }; + }, +); diff --git a/typescript/infra/package.json b/typescript/infra/package.json index 6b6188e63..276604123 100644 --- a/typescript/infra/package.json +++ b/typescript/infra/package.json @@ -1,7 +1,7 @@ { "name": "@hyperlane-xyz/infra", "description": "Infrastructure utilities for the Hyperlane Network", - "version": "3.16.0", + "version": "4.0.0", "dependencies": { "@arbitrum/sdk": "^3.0.0", "@aws-sdk/client-iam": "^3.74.0", @@ -13,10 +13,10 @@ "@ethersproject/hardware-wallets": "^5.7.0", "@ethersproject/providers": "^5.7.2", "@google-cloud/secret-manager": "^5.5.0", - "@hyperlane-xyz/helloworld": "3.16.0", + "@hyperlane-xyz/helloworld": "4.0.0", "@hyperlane-xyz/registry": "2.1.1", - "@hyperlane-xyz/sdk": "3.16.0", - "@hyperlane-xyz/utils": "3.16.0", + "@hyperlane-xyz/sdk": "4.0.0", + "@hyperlane-xyz/utils": "4.0.0", "@nomiclabs/hardhat-etherscan": "^3.0.3", "@solana/web3.js": "^1.78.0", "asn1.js": "5.4.1", @@ -25,6 +25,7 @@ "json-stable-stringify": "^1.1.1", "prom-client": "^14.0.1", "prompts": "^2.4.2", + "yaml": "^2.4.5", "yargs": "^17.7.2" }, "devDependencies": { diff --git a/typescript/infra/scripts/check-deploy.ts b/typescript/infra/scripts/check-deploy.ts index bc14e0aee..5a0d9c5b7 100644 --- a/typescript/infra/scripts/check-deploy.ts +++ b/typescript/infra/scripts/check-deploy.ts @@ -10,7 +10,6 @@ import { InterchainAccountChecker, InterchainQuery, InterchainQueryChecker, - resolveOrDeployAccountOwner, } from '@hyperlane-xyz/sdk'; import { Contexts } from '../config/contexts.js'; @@ -36,15 +35,15 @@ import { getHelloWorldApp } from './helloworld/utils.js'; function getArgs() { return withChain(withModuleAndFork(withContext(getRootArgs()))) - .boolean('asdeployer') - .default('asdeployer', false) + .boolean('asDeployer') + .default('asDeployer', false) .boolean('govern') .default('govern', false) .alias('g', 'govern').argv; } async function check() { - const { fork, govern, module, environment, context, chain, asdeployer } = + const { fork, govern, module, environment, context, chain, asDeployer } = await getArgs(); const envConfig = getEnvironmentConfig(environment); let multiProvider = await envConfig.getMultiProvider(); @@ -58,13 +57,7 @@ async function check() { [fork]: { blocks: { confirmations: 0 } }, }); - const owner = asdeployer - ? DEPLOYER - : await resolveOrDeployAccountOwner( - multiProvider, - fork, - envConfig.core[fork].owner, - ); + const owner = asDeployer ? DEPLOYER : envConfig.core[fork].owner; const signer = await impersonateAccount(owner, 1e18); multiProvider.setSigner(fork, signer); diff --git a/typescript/infra/scripts/deploy.ts b/typescript/infra/scripts/deploy.ts index 16a4c3017..e55c1b3e0 100644 --- a/typescript/infra/scripts/deploy.ts +++ b/typescript/infra/scripts/deploy.ts @@ -8,7 +8,6 @@ import { ChainMap, ContractVerifier, ExplorerLicenseType, - FallbackRoutingHookConfig, HypERC20Deployer, HyperlaneCoreDeployer, HyperlaneDeployer, @@ -26,7 +25,6 @@ import { objFilter, objMap } from '@hyperlane-xyz/utils'; import { Contexts } from '../config/contexts.js'; import { core as coreConfig } from '../config/environments/mainnet3/core.js'; -import { DEPLOYER } from '../config/environments/mainnet3/owners.js'; import { getEnvAddresses } from '../config/registry.js'; import { getWarpConfig } from '../config/warp.js'; import { deployWithArtifacts } from '../src/deployment/deploy.js'; @@ -200,11 +198,7 @@ async function main() { ); // Config is intended to be changed for ad-hoc use cases: config = { - ethereum: { - ...(coreConfig.ethereum.defaultHook as FallbackRoutingHookConfig) - .domains.ancient8, - owner: DEPLOYER, - }, + ethereum: coreConfig.ethereum.defaultHook, }; } else { console.log(`Skipping ${module}, deployer unimplemented`); diff --git a/typescript/infra/scripts/generate-renzo-warp-route-config.ts b/typescript/infra/scripts/generate-renzo-warp-route-config.ts new file mode 100644 index 000000000..e41b7c32e --- /dev/null +++ b/typescript/infra/scripts/generate-renzo-warp-route-config.ts @@ -0,0 +1,150 @@ +import { writeFileSync } from 'fs'; +import { stringify as yamlStringify } from 'yaml'; + +import { GithubRegistry } from '@hyperlane-xyz/registry'; +import { + IsmType, + TokenRouterConfig, + TokenType, + WarpRouteDeployConfig, + WarpRouteDeployConfigSchema, + buildAggregationIsmConfigs, +} from '@hyperlane-xyz/sdk'; + +const lockbox = '0xC8140dA31E6bCa19b287cC35531c2212763C2059'; +const xERC20 = '0x2416092f143378750bb29b79eD961ab195CcEea5'; +const lockboxChain = 'ethereum'; + +const chainsToDeploy = [ + 'arbitrum', + 'optimism', + 'base', + 'blast', + 'bsc', + 'mode', + 'linea', + 'ethereum', +]; + +const ezEthValidators = { + arbitrum: { + threshold: 1, + validators: [ + '0xc27032c6bbd48c20005f552af3aaa0dbf14260f3', // Renzo + '0x9bCcFAd3BD12Ef0Ee8aE839dD9ED7835BcCaDc9D', // Everclear + ], + }, + optimism: { + threshold: 1, + validators: [ + '0xe2593D205F5E7F74A50fA900824501084E092eBd', // Renzo + '0x6f4cb8e96db5d44422a4495faa73fffb9d30e9e2', // Everclear + ], + }, + base: { + threshold: 1, + validators: [ + '0x25BA4eE5268CbfB8D69BAc531Aa10368778702BD', // Renzo + '0x9ec803b503e9c7d2611e231521ef3fde73f7a21c', // Everclear + ], + }, + blast: { + threshold: 1, + validators: [ + '0x54Bb0036F777202371429e062FE6AEE0d59442F9', // Renzo + '0x1652d8ba766821cf01aeea34306dfc1cab964a32', // Everclear + ], + }, + bsc: { + threshold: 1, + validators: [ + '0x3156Db97a3B3e2dcc3D69FdDfD3e12dc7c937b6D', // Renzo + '0x9a0326c43e4713ae2477f09e0f28ffedc24d8266', // Everclear + ], + }, + mode: { + threshold: 1, + validators: [ + '0x7e29608C6E5792bBf9128599ca309Be0728af7B4', // Renzo + '0x456fbbe05484fc9f2f38ea09648424f54d6872be', // Everclear + ], + }, + linea: { + threshold: 1, + validators: [ + '0xcb3e44EdD2229860bDBaA58Ba2c3817D111bEE9A', // Renzo + '0x06a5a2a429560034d38bf62ca6d470942535947e', // Everclear + ], + }, + ethereum: { + threshold: 1, + validators: [ + '0xc7f7b94a6BaF2FFFa54DfE1dDE6E5Fcbb749e04f', // Renzo + '0x1fd889337F60986aa57166bc5AC121eFD13e4fdd', // Everclear + ], + }, +}; +const zeroAddress = '0x0000000000000000000000000000000000000001'; + +async function main() { + const registry = new GithubRegistry(); + + const tokenConfig: WarpRouteDeployConfig = + Object.fromEntries( + await Promise.all( + chainsToDeploy.map( + async (chain): Promise<[string, TokenRouterConfig]> => { + const ret: [string, TokenRouterConfig] = [ + chain, + { + isNft: false, + type: + chain === lockboxChain + ? TokenType.XERC20Lockbox + : TokenType.XERC20, + token: chain === lockboxChain ? lockbox : xERC20, + owner: zeroAddress, + mailbox: (await registry.getChainAddresses(chain))!.mailbox, + interchainSecurityModule: { + type: IsmType.AGGREGATION, + threshold: 2, + modules: [ + { + type: IsmType.ROUTING, + owner: zeroAddress, + domains: buildAggregationIsmConfigs( + chain, + chainsToDeploy, + ezEthValidators, + ), + }, + { + type: IsmType.FALLBACK_ROUTING, + domains: {}, + owner: zeroAddress, + }, + ], + }, + }, + ]; + + return ret; + }, + ), + ), + ); + + const parsed = WarpRouteDeployConfigSchema.safeParse(tokenConfig); + + if (!parsed.success) { + console.dir(parsed.error.format(), { depth: null }); + return; + } + + writeFileSync( + 'renzo-warp-route-config.yaml', + yamlStringify(parsed.data, null, 2), + ); +} + +main().catch(console.error).then(console.log); diff --git a/typescript/infra/scripts/helloworld/kathy.ts b/typescript/infra/scripts/helloworld/kathy.ts index f3cbb1a49..cea226d4f 100644 --- a/typescript/infra/scripts/helloworld/kathy.ts +++ b/typescript/infra/scripts/helloworld/kathy.ts @@ -12,7 +12,6 @@ import { MultiProvider, ProviderType, TypedTransactionReceipt, - resolveOrDeployAccountOwner, } from '@hyperlane-xyz/sdk'; import { Address, @@ -234,12 +233,11 @@ async function main(): Promise { } chains.map(async (chain) => { - const owner = await resolveOrDeployAccountOwner( - multiProvider, + return updateWalletBalanceMetricFor( + app, chain, coreConfig.owners[chain].owner, ); - return updateWalletBalanceMetricFor(app, chain, owner); }); // Incremented each time an entire cycle has occurred @@ -358,11 +356,7 @@ async function main(): Promise { messagesSendCount.labels({ ...labels, status: 'failure' }).inc(); errorOccurred = true; } - const owner = await resolveOrDeployAccountOwner( - multiProvider, - origin, - coreConfig.owners[origin].owner, - ); + const owner = coreConfig.owners[origin].owner; updateWalletBalanceMetricFor(app, origin, owner).catch((e) => { logger.warn('Failed to update wallet balance for chain', { chain: origin, diff --git a/typescript/infra/scripts/send-test-messages.ts b/typescript/infra/scripts/send-test-messages.ts index f602f4b30..d02826398 100644 --- a/typescript/infra/scripts/send-test-messages.ts +++ b/typescript/infra/scripts/send-test-messages.ts @@ -1,3 +1,4 @@ +import { Provider } from '@ethersproject/providers'; import { Wallet } from 'ethers'; import fs from 'fs'; import yargs from 'yargs'; @@ -100,15 +101,28 @@ async function main() { const { timeout, defaultHook, requiredHook, mineforever } = args; let messages = args.messages; + // Limit the test chains to a subset of the known chains + // E2E in Rust only knows about test1, test2 and test3 + const kathyTestChains = [ + TestChainName.test1, + TestChainName.test2, + TestChainName.test3, + ]; + + // Create a multi-provider with a signer const signer = new Wallet(ANVIL_KEY); const multiProvider = MultiProvider.createTestMultiProvider({ signer }); + + // Get the provider for the first chain const provider = multiProvider.getProvider(TestChainName.test1); + // Create core from addresses const addresses = JSON.parse( fs.readFileSync('./config/environments/test/core/addresses.json', 'utf8'), ); const core = HyperlaneCore.fromAddressesMap(addresses, multiProvider); + // helper function to get a random element from a list const randomElement = (list: T[]) => list[Math.floor(Math.random() * list.length)]; @@ -121,9 +135,11 @@ async function main() { const run_forever = messages === 0; while (run_forever || messages-- > 0) { // Round robin origin chain - const local = core.chains()[messages % core.chains().length]; + const local = kathyTestChains[messages % kathyTestChains.length]; // Random remote chain - const remote: ChainName = randomElement(await core.remoteChains(local)); + const remote: ChainName = randomElement( + kathyTestChains.filter((c) => c !== local), + ); const remoteId = multiProvider.getDomainId(remote); const contracts = core.getContracts(local); const mailbox = contracts.mailbox; diff --git a/typescript/infra/src/govern/HyperlaneAppGovernor.ts b/typescript/infra/src/govern/HyperlaneAppGovernor.ts index 7225bfb46..5e901ee19 100644 --- a/typescript/infra/src/govern/HyperlaneAppGovernor.ts +++ b/typescript/infra/src/govern/HyperlaneAppGovernor.ts @@ -200,12 +200,18 @@ export abstract class HyperlaneAppGovernor< accountConfig.owner, )} on ${origin}`, ); - const callRemote = await this.interchainAccount.getCallRemote( - origin, - chain, - [call], - accountConfig, - ); + const callRemote = await this.interchainAccount.getCallRemote({ + chain: origin, + destination: chain, + innerCalls: [ + { + to: call.to, + data: call.data, + value: call.value?.toString() || '0', + }, + ], + config: accountConfig, + }); if (!callRemote.to || !callRemote.data) { return SubmissionType.MANUAL; } diff --git a/typescript/infra/test/govern.hardhat-test.ts b/typescript/infra/test/govern.hardhat-test.ts index e5852d1f3..876e8ddd0 100644 --- a/typescript/infra/test/govern.hardhat-test.ts +++ b/typescript/infra/test/govern.hardhat-test.ts @@ -26,7 +26,6 @@ import { TestChainName, TestCoreApp, TestCoreDeployer, - resolveOrDeployAccountOwner, } from '@hyperlane-xyz/sdk'; import { Address, CallData, eqAddress } from '@hyperlane-xyz/utils'; @@ -134,11 +133,7 @@ describe('ICA governance', async () => { localRouter: remote.address, }; - accountOwner = await resolveOrDeployAccountOwner( - multiProvider, - remoteChain, - accountConfig, - ); + accountOwner = await icaApp.deployAccount(remoteChain, accountConfig); const recipientF = new TestRecipient__factory(signer); recipient = await recipientF.deploy(); diff --git a/typescript/sdk/CHANGELOG.md b/typescript/sdk/CHANGELOG.md index 59a76d8e5..53ba50fa9 100644 --- a/typescript/sdk/CHANGELOG.md +++ b/typescript/sdk/CHANGELOG.md @@ -1,5 +1,24 @@ # @hyperlane-xyz/sdk +## 4.0.0 + +### Minor Changes + +- b05ae38ac: Gracefully handle RPC failures during warp send & fix deriving hook error that prevents warp and core test messages on the cli. +- 9304fe241: Use metadata builders in message relaying +- bdcbe1d16: Add EvmWarpModule with create() +- e38d31685: Add logic to set smart provider log level to disable provider logs during Warp TokenType derive +- e0f226806: - Enables creation of new Hooks through the `EvmHookModule`. + - Introduces an `EvmModuleDeployer` to perform the barebones tasks of deploying contracts/proxies. +- 6db9fa9ad: Implement hyperlane warp deploy + +### Patch Changes + +- 6b63c5d82: Adds deployment support for IsmConfig within a WarpRouteConfig +- Updated dependencies [44cc9bf6b] + - @hyperlane-xyz/core@4.0.0 + - @hyperlane-xyz/utils@4.0.0 + ## 3.16.0 ### Minor Changes diff --git a/typescript/sdk/package.json b/typescript/sdk/package.json index 326415d70..d29d7a689 100644 --- a/typescript/sdk/package.json +++ b/typescript/sdk/package.json @@ -1,13 +1,13 @@ { "name": "@hyperlane-xyz/sdk", "description": "The official SDK for the Hyperlane Network", - "version": "3.16.0", + "version": "4.0.0", "dependencies": { "@aws-sdk/client-s3": "^3.74.0", "@cosmjs/cosmwasm-stargate": "^0.31.3", "@cosmjs/stargate": "^0.31.3", - "@hyperlane-xyz/core": "3.16.0", - "@hyperlane-xyz/utils": "3.16.0", + "@hyperlane-xyz/core": "4.0.0", + "@hyperlane-xyz/utils": "4.0.0", "@safe-global/api-kit": "1.3.0", "@safe-global/protocol-kit": "1.3.0", "@solana/spl-token": "^0.3.8", diff --git a/typescript/sdk/src/aws/s3.ts b/typescript/sdk/src/aws/s3.ts index a222617b3..462aa26fe 100644 --- a/typescript/sdk/src/aws/s3.ts +++ b/typescript/sdk/src/aws/s3.ts @@ -75,6 +75,6 @@ export class S3Wrapper { url(key: string): string { const Key = this.formatKey(key); - return `https://${this.config.bucket}.${this.config.region}.s3.amazonaws.com/${Key}`; + return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${Key}`; } } diff --git a/typescript/sdk/src/aws/validator.ts b/typescript/sdk/src/aws/validator.ts index 11f91c576..3bb142feb 100644 --- a/typescript/sdk/src/aws/validator.ts +++ b/typescript/sdk/src/aws/validator.ts @@ -103,4 +103,8 @@ export class S3Validator extends BaseValidator { storageLocation(): string { return `${LOCATION_PREFIX}/${this.s3Bucket.config.bucket}/${this.s3Bucket.config.region}`; } + + getLatestCheckpointUrl(): string { + return this.s3Bucket.url(LATEST_KEY); + } } diff --git a/typescript/sdk/src/consts/testChains.ts b/typescript/sdk/src/consts/testChains.ts index 273796726..a5e8b91ee 100644 --- a/typescript/sdk/src/consts/testChains.ts +++ b/typescript/sdk/src/consts/testChains.ts @@ -10,6 +10,7 @@ export enum TestChainName { test1 = 'test1', test2 = 'test2', test3 = 'test3', + test4 = 'test4', } export const testChains: Array = Object.values(TestChainName); @@ -65,10 +66,19 @@ export const test3: ChainMetadata = { name: 'test3', }; +export const test4: ChainMetadata = { + ...test1, + chainId: 31337, + displayName: 'Test 4', + domainId: 31337, + name: 'test4', +}; + export const testChainMetadata: ChainMap = { test1, test2, test3, + test4, }; export const testCosmosChain: ChainMetadata = { diff --git a/typescript/sdk/src/contracts/contracts.ts b/typescript/sdk/src/contracts/contracts.ts index 557e68d17..7a58b3bfd 100644 --- a/typescript/sdk/src/contracts/contracts.ts +++ b/typescript/sdk/src/contracts/contracts.ts @@ -146,6 +146,15 @@ export function attachContractsMapAndGetForeignDeployments< }; } +export function attachAndConnectContracts( + addresses: HyperlaneAddresses, + factories: F, + connection: Connection, +): HyperlaneContracts { + const contracts = attachContracts(addresses, factories); + return connectContracts(contracts, connection); +} + export function connectContracts( contracts: HyperlaneContracts, connection: Connection, diff --git a/typescript/sdk/src/core/AbstractHyperlaneModule.ts b/typescript/sdk/src/core/AbstractHyperlaneModule.ts index a5159ac85..b4a42ec30 100644 --- a/typescript/sdk/src/core/AbstractHyperlaneModule.ts +++ b/typescript/sdk/src/core/AbstractHyperlaneModule.ts @@ -5,7 +5,7 @@ import { Annotated, ProtocolType } from '@hyperlane-xyz/utils'; import { ProtocolTypedTransaction } from '../providers/ProviderType.js'; import { ChainNameOrId } from '../types.js'; -export type HyperlaneModuleArgs< +export type HyperlaneModuleParams< TConfig, TAddressMap extends Record, > = { @@ -22,7 +22,7 @@ export abstract class HyperlaneModule< protected abstract readonly logger: Logger; protected constructor( - protected readonly args: HyperlaneModuleArgs, + protected readonly args: HyperlaneModuleParams, ) {} public serialize(): TAddressMap { @@ -32,7 +32,7 @@ export abstract class HyperlaneModule< public abstract read(): Promise; public abstract update( config: TConfig, - ): Promise[]>>; + ): Promise['transaction'][]>>; // /* // Types and static methods can be challenging. Ensure each implementation includes a static create function. diff --git a/typescript/sdk/src/core/CoreDeployer.hardhat-test.ts b/typescript/sdk/src/core/CoreDeployer.hardhat-test.ts index 58bf38f27..2664d0ee2 100644 --- a/typescript/sdk/src/core/CoreDeployer.hardhat-test.ts +++ b/typescript/sdk/src/core/CoreDeployer.hardhat-test.ts @@ -8,7 +8,7 @@ import { objMap, promiseObjAll } from '@hyperlane-xyz/utils'; import { TestChainName, testChains } from '../consts/testChains.js'; import { HyperlaneContractsMap } from '../contracts/types.js'; import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js'; -import { HookConfig } from '../hook/types.js'; +import { DerivedHookConfig } from '../hook/EvmHookReader.js'; import { DerivedIsmConfig } from '../ism/EvmIsmReader.js'; import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; import { AggregationIsmConfig, IsmType } from '../ism/types.js'; @@ -127,7 +127,7 @@ describe('core', async () => { }); async function deriveCoreConfig(chainName: string, mailboxAddress: string) { - return await new EvmCoreReader(multiProvider, chainName).deriveCoreConfig( + return new EvmCoreReader(multiProvider, chainName).deriveCoreConfig( mailboxAddress, ); } @@ -140,12 +140,12 @@ describe('core', async () => { ); // Cast because we don't expect the 'string' type - const defaultIsmOnchain = + const { address: _, ...defaultIsmOnchain } = coreConfigOnChain.defaultIsm as DerivedIsmConfig; const defaultIsmTest = coreConfig[chainName] .defaultIsm as DerivedIsmConfig; - expect(defaultIsmOnchain.type).to.be.equal(defaultIsmTest.type); + expect(defaultIsmOnchain).to.deep.equal(defaultIsmTest); }), ); }); @@ -158,12 +158,12 @@ describe('core', async () => { ); // Cast because we don't expect the 'string' type - const defaultHookOnchain = - coreConfigOnChain.defaultHook as HookConfig; + const { address: _, ...defaultHookOnchain } = + coreConfigOnChain.defaultHook as DerivedHookConfig; const defaultHookTest = coreConfig[chainName] - .defaultHook as HookConfig; + .defaultHook as DerivedHookConfig; - expect(defaultHookOnchain.type).to.be.equal(defaultHookTest.type); + expect(defaultHookOnchain).to.deep.equal(defaultHookTest); }), ); }); @@ -174,13 +174,11 @@ describe('core', async () => { chainName, contract.mailbox.address, ); - const requiredHookOnchain = coreConfigOnChain.requiredHook; + const { address: _, ...requiredHookOnchain } = + coreConfigOnChain.requiredHook as DerivedHookConfig; const requiredHookTest = coreConfig[chainName].requiredHook; - // Test all the fields - objMap(requiredHookTest, (key, value) => { - expect(requiredHookOnchain[key]).to.be.equal(value); - }); + expect(requiredHookOnchain).to.deep.equal(requiredHookTest); }), ); }); diff --git a/typescript/sdk/src/core/EvmCoreModule.hardhat-test.ts b/typescript/sdk/src/core/EvmCoreModule.hardhat-test.ts new file mode 100644 index 000000000..7189718f1 --- /dev/null +++ b/typescript/sdk/src/core/EvmCoreModule.hardhat-test.ts @@ -0,0 +1,164 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import hre from 'hardhat'; + +import { + Mailbox__factory, + ProxyAdmin__factory, + TestRecipient__factory, + TimelockController__factory, + ValidatorAnnounce__factory, +} from '@hyperlane-xyz/core'; +import { objMap } from '@hyperlane-xyz/utils'; + +import { TestChainName } from '../consts/testChains.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { testCoreConfig } from '../test/testUtils.js'; + +import { EvmCoreModule } from './EvmCoreModule.js'; +import { CoreConfig } from './types.js'; + +describe('EvmCoreModule', async () => { + const CHAIN = TestChainName.test1; + const DELAY = 1892391283182; + let config: CoreConfig; + let signer: SignerWithAddress; + let multiProvider: MultiProvider; + let evmCoreModule: EvmCoreModule; + let proxyAdminContract: any; + let mailboxContract: any; + let validatorAnnounceContract: any; + let testRecipientContract: any; + let timelockControllerContract: any; + + before(async () => { + [signer] = await hre.ethers.getSigners(); + multiProvider = MultiProvider.createTestMultiProvider({ signer }); + config = { + ...testCoreConfig([CHAIN])[CHAIN], + upgrade: { + timelock: { + delay: DELAY, + roles: { + executor: signer.address, + proposer: signer.address, + }, + }, + }, + }; + + evmCoreModule = await EvmCoreModule.create({ + chain: CHAIN, + config, + multiProvider, + }); + + const { + proxyAdmin, + mailbox, + validatorAnnounce, + testRecipient, + timelockController, + } = evmCoreModule.serialize(); + + proxyAdminContract = ProxyAdmin__factory.connect( + proxyAdmin!, + multiProvider.getProvider(CHAIN), + ); + + mailboxContract = Mailbox__factory.connect( + mailbox!, + multiProvider.getProvider(CHAIN), + ); + + validatorAnnounceContract = ValidatorAnnounce__factory.connect( + validatorAnnounce!, + multiProvider.getProvider(CHAIN), + ); + + testRecipientContract = TestRecipient__factory.connect( + testRecipient!, + multiProvider.getProvider(CHAIN), + ); + + timelockControllerContract = TimelockController__factory.connect( + timelockController!, + multiProvider.getProvider(CHAIN), + ); + }); + + describe('Create', async () => { + it('should create deploy an ICA', () => { + const { interchainAccountRouter, interchainAccountIsm } = + evmCoreModule.serialize(); + expect(interchainAccountIsm).to.exist; + expect(interchainAccountRouter).to.exist; + }); + + it('should deploy ISM factories', () => { + // Each ISM factory + const deployedContracts = evmCoreModule.serialize(); + + objMap(deployedContracts as any, (_, address) => { + expect(address).to.exist; + expect(address).to.not.equal(constants.AddressZero); + }); + }); + + it('should deploy proxyAdmin', () => { + expect(evmCoreModule.serialize().proxyAdmin).to.exist; + }); + + it('should set proxyAdmin owner to deployer', async () => { + expect(await proxyAdminContract.owner()).to.equal(signer.address); + }); + + it('should deploy mailbox', async () => { + const mailboxAddress = evmCoreModule.serialize().mailbox; + expect(mailboxAddress).to.exist; + + // Check that it's actually a mailbox by calling one of it's methods + expect(await mailboxContract.localDomain()).to.equal( + multiProvider.getChainId(CHAIN), + ); + }); + + it('should set mailbox owner to config owner', async () => { + expect(await mailboxContract.owner()).to.equal(config.owner); + }); + + it('should deploy mailbox default Ism', async () => { + expect(await mailboxContract.defaultIsm()).to.not.equal( + constants.AddressZero, + ); + }); + + it('should deploy mailbox default hook', async () => { + expect(await mailboxContract.defaultHook()).to.not.equal( + constants.AddressZero, + ); + }); + + it('should deploy mailbox required hook', async () => { + expect(await mailboxContract.requiredHook()).to.not.equal( + constants.AddressZero, + ); + }); + + it('should deploy validatorAnnounce', async () => { + expect(evmCoreModule.serialize().validatorAnnounce).to.exist; + expect(await validatorAnnounceContract.owner()).to.equal(signer.address); + }); + + it('should deploy testRecipient', async () => { + expect(evmCoreModule.serialize().testRecipient).to.exist; + expect(await testRecipientContract.owner()).to.equal(signer.address); + }); + + it('should deploy timelock if upgrade is set', async () => { + expect(evmCoreModule.serialize().timelockController).to.exist; + expect(await timelockControllerContract.getMinDelay()).to.equal(DELAY); + }); + }); +}); diff --git a/typescript/sdk/src/core/EvmCoreModule.ts b/typescript/sdk/src/core/EvmCoreModule.ts new file mode 100644 index 000000000..2fbfe4bd6 --- /dev/null +++ b/typescript/sdk/src/core/EvmCoreModule.ts @@ -0,0 +1,275 @@ +import { Mailbox } from '@hyperlane-xyz/core'; +import { Address, ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; + +import { + attachContractsMap, + serializeContractsMap, +} from '../contracts/contracts.js'; +import { HyperlaneAddresses } from '../contracts/types.js'; +import { CoreConfig } from '../core/types.js'; +import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js'; +import { + ProxyFactoryFactories, + proxyFactoryFactories, +} from '../deploy/contracts.js'; +import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { AnnotatedEV5Transaction } from '../providers/ProviderType.js'; +import { ChainNameOrId } from '../types.js'; + +import { + HyperlaneModule, + HyperlaneModuleParams, +} from './AbstractHyperlaneModule.js'; +import { EvmCoreReader } from './EvmCoreReader.js'; +import { EvmIcaModule } from './EvmIcaModule.js'; +import { HyperlaneCoreDeployer } from './HyperlaneCoreDeployer.js'; +import { CoreFactories } from './contracts.js'; + +export type DeployedCoreAdresses = HyperlaneAddresses & { + testRecipient: Address; + timelockController?: Address; // Can be optional because it is only deployed if config.upgrade = true + interchainAccountRouter: Address; + interchainAccountIsm: Address; +} & HyperlaneAddresses; + +export class EvmCoreModule extends HyperlaneModule< + ProtocolType.Ethereum, + CoreConfig, + DeployedCoreAdresses +> { + protected logger = rootLogger.child({ module: 'EvmCoreModule' }); + protected coreReader: EvmCoreReader; + public readonly chainName: string; + + protected constructor( + protected readonly multiProvider: MultiProvider, + args: HyperlaneModuleParams, + ) { + super(args); + this.coreReader = new EvmCoreReader(multiProvider, this.args.chain); + this.chainName = this.multiProvider.getChainName(this.args.chain); + } + + /** + * Reads the core configuration from the mailbox address specified in the SDK arguments. + * @returns The core config. + */ + public async read(): Promise { + return this.coreReader.deriveCoreConfig(this.args.addresses.mailbox); + } + + public async update(_config: CoreConfig): Promise { + throw new Error('Method not implemented.'); + } + + /** + * Deploys the Core contracts. + * @remark Most of the contract owners is the Deployer with some being the Proxy Admin. + * @returns The created EvmCoreModule instance. + */ + public static async create(params: { + chain: ChainNameOrId; + config: CoreConfig; + multiProvider: MultiProvider; + }): Promise { + const { chain, config, multiProvider } = params; + const addresses = await EvmCoreModule.deploy({ + config, + multiProvider, + chain, + }); + + // Create CoreModule and deploy the Core contracts + const module = new EvmCoreModule(multiProvider, { + addresses, + chain, + config, + }); + + return module; + } + + /** + * Deploys the core Hyperlane contracts. + * @returns The deployed core contract addresses. + */ + static async deploy(params: { + config: CoreConfig; + multiProvider: MultiProvider; + chain: ChainNameOrId; + }): Promise { + const { config, multiProvider, chain } = params; + const chainName = multiProvider.getChainName(chain); + + // Deploy Ism Factories + const ismFactoryFactories = await EvmCoreModule.deployIsmFactories({ + chainName, + config, + multiProvider, + }); + + // Deploy IsmFactory to be used in CoreDeployer + const ismFactory = new HyperlaneIsmFactory( + attachContractsMap( + { [chainName]: ismFactoryFactories }, + proxyFactoryFactories, + ), + multiProvider, + ); + + // Initialize Deployer + const coreDeployer = new HyperlaneCoreDeployer(multiProvider, ismFactory); + + // Deploy proxyAdmin + const proxyAdmin = ( + await coreDeployer.deployContract(chainName, 'proxyAdmin', []) + ).address; + + // Deploy Mailbox + const mailbox = await this.deployMailbox({ + config, + coreDeployer, + proxyAdmin, + multiProvider, + chain, + }); + + // Deploy ICA ISM and Router + const { interchainAccountRouter, interchainAccountIsm } = ( + await EvmIcaModule.create({ + chain: chainName, + multiProvider: multiProvider, + config: { + mailbox: mailbox.address, + owner: await multiProvider.getSigner(chain).getAddress(), + }, + }) + ).serialize(); + + // Deploy Validator announce + const validatorAnnounce = ( + await coreDeployer.deployValidatorAnnounce(chainName, mailbox.address) + ).address; + + // Deploy timelock controller if config.upgrade is set + let timelockController; + if (config.upgrade) { + timelockController = ( + await coreDeployer.deployTimelock(chainName, config.upgrade.timelock) + ).address; + } + + // Deploy Test Receipient + const testRecipient = ( + await coreDeployer.deployTestRecipient( + chainName, + await mailbox.defaultIsm(), + ) + ).address; + + // Set Core & extra addresses + return { + ...ismFactoryFactories, + proxyAdmin, + mailbox: mailbox.address, + interchainAccountRouter, + interchainAccountIsm, + validatorAnnounce, + timelockController, + testRecipient, + }; + } + + /** + * Deploys the ISM factories for a given chain. + * @returns The deployed ISM factories addresses. + */ + static async deployIsmFactories(params: { + chainName: string; + config: CoreConfig; + multiProvider: MultiProvider; + }): Promise> { + const { chainName, config, multiProvider } = params; + + // ChainMap is still needed for HyperlaneIsmFactory + const proxyFactoryDeployer = new HyperlaneProxyFactoryDeployer( + multiProvider, + ); + const ismFactoriesFactory = await proxyFactoryDeployer.deploy({ + [chainName]: config, + }); + + return serializeContractsMap(ismFactoriesFactory)[chainName]; + } + + /** + * Deploys a Mailbox and its default ISM, hook, and required hook contracts with a given configuration. + * @returns The deployed Mailbox contract instance. + */ + static async deployMailbox(params: { + config: CoreConfig; + proxyAdmin: Address; + coreDeployer: HyperlaneCoreDeployer; + multiProvider: MultiProvider; + chain: ChainNameOrId; + }): Promise { + const { + config, + proxyAdmin, + coreDeployer: deployer, + multiProvider, + chain, + } = params; + const chainName = multiProvider.getChainName(chain); + + const domain = multiProvider.getDomainId(chainName); + const mailbox = await deployer.deployProxiedContract( + chainName, + 'mailbox', + 'mailbox', + proxyAdmin, + [domain], + ); + + // @todo refactor when 1) IsmModule is ready + const deployedDefaultIsm = await deployer.deployIsm( + chainName, + config.defaultIsm, + mailbox.address, + ); + + // @todo refactor when 1) HookModule is ready, and 2) Hooks Config can handle strings + const deployedDefaultHook = await deployer.deployHook( + chainName, + config.defaultHook, + { + mailbox: mailbox.address, + proxyAdmin, + }, + ); + + // @todo refactor when 1) HookModule is ready, and 2) Hooks Config can handle strings + const deployedRequiredHook = await deployer.deployHook( + chainName, + config.requiredHook, + { + mailbox: mailbox.address, + proxyAdmin, + }, + ); + + // Initialize Mailbox + await multiProvider.handleTx( + chain, + mailbox.initialize( + config.owner, + deployedDefaultIsm, + deployedDefaultHook.address, + deployedRequiredHook.address, + multiProvider.getTransactionOverrides(chain), + ), + ); + return mailbox; + } +} diff --git a/typescript/sdk/src/core/EvmIcaModule.hardhat-test.ts b/typescript/sdk/src/core/EvmIcaModule.hardhat-test.ts new file mode 100644 index 000000000..cbbb9cbe0 --- /dev/null +++ b/typescript/sdk/src/core/EvmIcaModule.hardhat-test.ts @@ -0,0 +1,44 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; +import { expect } from 'chai'; +import { ethers } from 'ethers'; +import hre from 'hardhat'; + +import { Mailbox, Mailbox__factory } from '@hyperlane-xyz/core'; + +import { TestChainName } from '../consts/testChains.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; + +import { EvmIcaModule } from './EvmIcaModule.js'; + +describe('EvmIcaModule', async () => { + const LOCAL_DOMAIN = 1; + let signer: SignerWithAddress; + let multiProvider: MultiProvider; + let mailbox: Mailbox; + + before(async () => { + [signer] = await hre.ethers.getSigners(); + multiProvider = MultiProvider.createTestMultiProvider({ signer }); + const Mailbox = new Mailbox__factory(signer); + mailbox = await Mailbox.deploy(LOCAL_DOMAIN); + }); + describe('Create', async () => { + it('should deploy an ICA with ISM', async () => { + const evmIcaModule = await EvmIcaModule.create({ + chain: TestChainName.test1, + config: { + mailbox: mailbox.address, + owner: signer.address, + }, + multiProvider, + }); + + const { interchainAccountRouter, interchainAccountIsm } = + evmIcaModule.serialize(); + expect(interchainAccountIsm).to.not.equal(ethers.constants.AddressZero); + expect(interchainAccountRouter).to.not.equal( + ethers.constants.AddressZero, + ); + }); + }); +}); diff --git a/typescript/sdk/src/core/EvmIcaModule.ts b/typescript/sdk/src/core/EvmIcaModule.ts new file mode 100644 index 000000000..c94e289e9 --- /dev/null +++ b/typescript/sdk/src/core/EvmIcaModule.ts @@ -0,0 +1,77 @@ +import { ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; + +import { serializeContracts } from '../contracts/contracts.js'; +import { HyperlaneAddresses } from '../contracts/types.js'; +import { InterchainAccountDeployer } from '../middleware/account/InterchainAccountDeployer.js'; +import { InterchainAccountFactories } from '../middleware/account/contracts.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { AnnotatedEV5Transaction } from '../providers/ProviderType.js'; +import { ProxiedRouterConfig } from '../router/types.js'; +import { ChainNameOrId } from '../types.js'; + +import { + HyperlaneModule, + HyperlaneModuleParams, +} from './AbstractHyperlaneModule.js'; + +export type InterchainAccountConfig = ProxiedRouterConfig; + +export class EvmIcaModule extends HyperlaneModule< + ProtocolType.Ethereum, + InterchainAccountConfig, + HyperlaneAddresses +> { + protected logger = rootLogger.child({ module: 'EvmIcaModule' }); + + protected constructor( + protected readonly multiProvider: MultiProvider, + args: HyperlaneModuleParams< + InterchainAccountConfig, + HyperlaneAddresses + >, + ) { + super(args); + } + + public async read(): Promise { + throw new Error('Method not implemented.'); + } + + public async update( + _config: InterchainAccountConfig, + ): Promise { + throw new Error('Method not implemented.'); + } + + /** + * Creates a new EvmIcaModule instance by deploying an ICA with an ICA ISM. + * + * @param chain - The chain on which to deploy the ICA. + * @param config - The configuration for the ICA. + * @param multiProvider - The MultiProvider instance to use for deployment. + * @returns {Promise} - A new EvmIcaModule instance. + */ + public static async create({ + chain, + config, + multiProvider, + }: { + chain: ChainNameOrId; + config: InterchainAccountConfig; + multiProvider: MultiProvider; + }): Promise { + const interchainAccountDeployer = new InterchainAccountDeployer( + multiProvider, + ); + const deployedContracts = await interchainAccountDeployer.deployContracts( + multiProvider.getChainName(chain), + config, + ); + + return new EvmIcaModule(multiProvider, { + addresses: serializeContracts(deployedContracts), + chain, + config, + }); + } +} diff --git a/typescript/sdk/src/core/HyperlaneCore.ts b/typescript/sdk/src/core/HyperlaneCore.ts index 92f0e3023..fbe3ea498 100644 --- a/typescript/sdk/src/core/HyperlaneCore.ts +++ b/typescript/sdk/src/core/HyperlaneCore.ts @@ -8,8 +8,8 @@ import { AddressBytes32, ProtocolType, addressToBytes32, + assert, bytes32ToAddress, - eqAddress, messageId, objFilter, objMap, @@ -21,11 +21,13 @@ import { HyperlaneApp } from '../app/HyperlaneApp.js'; import { appFromAddressesMapHelper } from '../contracts/contracts.js'; import { HyperlaneAddressesMap } from '../contracts/types.js'; import { OwnableConfig } from '../deploy/types.js'; +import { DerivedHookConfig, EvmHookReader } from '../hook/EvmHookReader.js'; import { DerivedIsmConfig, EvmIsmReader } from '../ism/EvmIsmReader.js'; -import { IsmType, ModuleType, ismTypeToModuleType } from '../ism/types.js'; +import { BaseMetadataBuilder } from '../ism/metadata/builder.js'; import { MultiProvider } from '../providers/MultiProvider.js'; import { RouterConfig } from '../router/types.js'; import { ChainMap, ChainName } from '../types.js'; +import { findMatchingLogEvents } from '../utils/logUtils.js'; import { CoreFactories, coreFactories } from './contracts.js'; import { DispatchedMessage } from './types.js'; @@ -86,12 +88,22 @@ export class HyperlaneCore extends HyperlaneApp { return this.multiProvider.getChainName(message.parsed.destination); } - getRecipientIsmAddress(message: DispatchedMessage): Promise
{ + protected getOrigin(message: DispatchedMessage): ChainName { + return this.multiProvider.getChainName(message.parsed.origin); + } + + async getRecipientIsmAddress(message: DispatchedMessage): Promise
{ const destinationMailbox = this.contractsMap[this.getDestination(message)]; const ethAddress = bytes32ToAddress(message.parsed.recipient); return destinationMailbox.mailbox.recipientIsm(ethAddress); } + async getHookAddress(message: DispatchedMessage): Promise
{ + const destinationMailbox = this.contractsMap[this.getOrigin(message)]; + /* TODO: requiredHook() account for here: https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/3693 */ + return destinationMailbox.mailbox.defaultHook(); + } + async getRecipientIsmConfig( message: DispatchedMessage, ): Promise { @@ -101,31 +113,30 @@ export class HyperlaneCore extends HyperlaneApp { return ismReader.deriveIsmConfig(address); } - async buildMetadata(message: DispatchedMessage): Promise { + async getHookConfig(message: DispatchedMessage): Promise { + const originChain = this.getOrigin(message); + const hookReader = new EvmHookReader(this.multiProvider, originChain); + const address = await this.getHookAddress(message); + const hookConfig = await hookReader.deriveHookConfig(address); + assert(hookConfig, `No hook config found for ${address}.`); + return hookConfig; + } + + async buildMetadata( + message: DispatchedMessage, + dispatchTx: TransactionReceipt, + ): Promise { const ismConfig = await this.getRecipientIsmConfig(message); - const destinationChain = this.getDestination(message); + const hookConfig = await this.getHookConfig(message); - switch (ismConfig.type) { - case IsmType.TRUSTED_RELAYER: - // eslint-disable-next-line no-case-declarations - const destinationSigner = await this.multiProvider.getSignerAddress( - destinationChain, - ); - if (!eqAddress(destinationSigner, ismConfig.relayer)) { - this.logger.warn( - `${destinationChain} signer ${destinationSigner} does not match trusted relayer ${ismConfig.relayer}`, - ); - } - } + const baseMetadataBuilder = new BaseMetadataBuilder(this); - // TODO: implement metadata builders for other module types - const moduleType = ismTypeToModuleType(ismConfig.type); - switch (moduleType) { - case ModuleType.NULL: - return '0x'; - default: - throw new Error(`Unsupported module type ${moduleType}`); - } + return baseMetadataBuilder.build({ + ism: ismConfig, + hook: hookConfig, + message, + dispatchTx, + }); } async sendMessage( @@ -166,8 +177,9 @@ export class HyperlaneCore extends HyperlaneApp { async relayMessage( message: DispatchedMessage, + dispatchTx: ethers.ContractReceipt, ): Promise { - const metadata = await this.buildMetadata(message); + const metadata = await this.buildMetadata(message, dispatchTx); const destinationChain = this.getDestination(message); const mailbox = this.contractsMap[destinationChain].mailbox; @@ -268,7 +280,7 @@ export class HyperlaneCore extends HyperlaneApp { async getDispatchTx( originChain: ChainName, messageId: string, - ): Promise { + ): Promise { const mailbox = this.contractsMap[originChain].mailbox; const filter = mailbox.filters.DispatchId(messageId); const matchingEvents = await mailbox.queryFilter(filter); @@ -283,18 +295,11 @@ export class HyperlaneCore extends HyperlaneApp { sourceTx: ethers.ContractReceipt | ViemTxReceipt, ): DispatchedMessage[] { const mailbox = Mailbox__factory.createInterface(); - const dispatchLogs = sourceTx.logs - .map((log) => { - try { - return mailbox.parseLog(log); - } catch (e) { - return undefined; - } - }) - .filter( - (log): log is ethers.utils.LogDescription => - !!log && log.name === 'Dispatch', - ); + const dispatchLogs = findMatchingLogEvents( + sourceTx.logs, + mailbox, + 'Dispatch', + ); return dispatchLogs.map((log) => { const message = log.args['message']; const parsed = parseMessage(message); diff --git a/typescript/sdk/src/core/HyperlaneCoreDeployer.ts b/typescript/sdk/src/core/HyperlaneCoreDeployer.ts index 6a06fa88f..380bcff71 100644 --- a/typescript/sdk/src/core/HyperlaneCoreDeployer.ts +++ b/typescript/sdk/src/core/HyperlaneCoreDeployer.ts @@ -1,5 +1,6 @@ import { IPostDispatchHook, + IPostDispatchHook__factory, Mailbox, TestRecipient, ValidatorAnnounce, @@ -139,7 +140,7 @@ export class HyperlaneCoreDeployer extends HyperlaneDeployer< await this.configureHook( chain, mailbox, - defaultHook, + defaultHook.address, (_mailbox) => _mailbox.defaultHook(), (_mailbox, _hook) => _mailbox.populateTransaction.setDefaultHook(_hook, { ...overrides }), @@ -148,7 +149,7 @@ export class HyperlaneCoreDeployer extends HyperlaneDeployer< await this.configureHook( chain, mailbox, - requiredHook, + requiredHook.address, (_mailbox) => _mailbox.requiredHook(), (_mailbox, _hook) => _mailbox.populateTransaction.setRequiredHook(_hook, { ...overrides }), @@ -184,6 +185,13 @@ export class HyperlaneCoreDeployer extends HyperlaneDeployer< config: HookConfig, coreAddresses: Partial, ): Promise { + if (typeof config === 'string') { + return IPostDispatchHook__factory.connect( + config, + this.multiProvider.getProvider(chain), + ); + } + const hooks = await this.hookDeployer.deployContracts( chain, config, diff --git a/typescript/sdk/src/core/schemas.ts b/typescript/sdk/src/core/schemas.ts new file mode 100644 index 000000000..22c422326 --- /dev/null +++ b/typescript/sdk/src/core/schemas.ts @@ -0,0 +1,9 @@ +import { HookConfigSchema } from '../hook/schemas.js'; +import { IsmConfigSchema } from '../ism/schemas.js'; +import { OwnableSchema } from '../schemas.js'; + +export const CoreConfigSchema = OwnableSchema.extend({ + defaultIsm: IsmConfigSchema, + defaultHook: HookConfigSchema, + requiredHook: HookConfigSchema, +}); diff --git a/typescript/sdk/src/core/types.ts b/typescript/sdk/src/core/types.ts index fa86a50a6..b21290648 100644 --- a/typescript/sdk/src/core/types.ts +++ b/typescript/sdk/src/core/types.ts @@ -1,18 +1,16 @@ +import { z } from 'zod'; + import type { Mailbox } from '@hyperlane-xyz/core'; import type { Address, ParsedMessage } from '@hyperlane-xyz/utils'; import type { UpgradeConfig } from '../deploy/proxy.js'; -import type { CheckerViolation, OwnableConfig } from '../deploy/types.js'; -import { HookConfig } from '../hook/types.js'; +import type { CheckerViolation } from '../deploy/types.js'; import type { IsmConfig } from '../ism/types.js'; import type { ChainName } from '../types.js'; -import { CoreFactories } from './contracts.js'; +import { CoreConfigSchema } from './schemas.js'; -export type CoreConfig = OwnableConfig & { - defaultIsm: IsmConfig; - defaultHook: HookConfig; - requiredHook: HookConfig; +export type CoreConfig = z.infer & { remove?: boolean; upgrade?: UpgradeConfig; }; diff --git a/typescript/sdk/src/deploy/EvmModuleDeployer.ts b/typescript/sdk/src/deploy/EvmModuleDeployer.ts new file mode 100644 index 000000000..fca9e4d4a --- /dev/null +++ b/typescript/sdk/src/deploy/EvmModuleDeployer.ts @@ -0,0 +1,296 @@ +import { ethers } from 'ethers'; +import { Logger } from 'pino'; + +import { + StaticAddressSetFactory, + StaticThresholdAddressSetFactory, + TransparentUpgradeableProxy__factory, +} from '@hyperlane-xyz/core'; +import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; +import { Address, rootLogger } from '@hyperlane-xyz/utils'; + +import { HyperlaneContracts, HyperlaneFactories } from '../contracts/types.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { ChainMap, ChainName } from '../types.js'; + +import { isProxy, proxyConstructorArgs } from './proxy.js'; +import { ContractVerifier } from './verify/ContractVerifier.js'; +import { + ContractVerificationInput, + ExplorerLicenseType, +} from './verify/types.js'; +import { getContractVerificationInput } from './verify/utils.js'; + +export class EvmModuleDeployer { + public verificationInputs: ChainMap = {}; + + constructor( + protected readonly multiProvider: MultiProvider, + protected readonly factories: Factories, + protected readonly logger = rootLogger.child({ + module: 'EvmModuleDeployer', + }), + protected readonly contractVerifier = new ContractVerifier( + multiProvider, + {}, + coreBuildArtifact, + ExplorerLicenseType.MIT, + ), + ) {} + + // Deploys a contract from a factory + public async deployContractFromFactory({ + chain, + factory, + contractName, + constructorArgs, + initializeArgs, + }: { + chain: ChainName; + factory: F; + contractName: string; + constructorArgs: Parameters; + initializeArgs?: Parameters>['initialize']>; + }): Promise> { + this.logger.info( + `Deploy ${contractName} on ${chain} with constructor args (${constructorArgs.join( + ', ', + )})`, + ); + const contract = await this.multiProvider.handleDeploy( + chain, + factory, + constructorArgs, + ); + + if (initializeArgs) { + this.logger.debug(`Initialize ${contractName} on ${chain}`); + const overrides = this.multiProvider.getTransactionOverrides(chain); + const initTx = await contract.initialize(...initializeArgs, overrides); + await this.multiProvider.handleTx(chain, initTx); + } + + const verificationInput = getContractVerificationInput( + contractName, + contract, + factory.bytecode, + ); + this.addVerificationArtifacts({ chain, artifacts: [verificationInput] }); + + // try verifying contract + try { + await this.contractVerifier?.verifyContract(chain, verificationInput); + } catch (error) { + // log error but keep deploying, can also verify post-deployment if needed + this.logger.debug(`Error verifying contract: ${error}`); + } + + return contract; + } + + /** + * Deploys a contract with a specified name. + * + * This function is capable of deploying any contract type defined within the `Factories` type to a specified chain. + * + * @param {ChainName} chain - The name of the chain on which the contract is to be deployed. + * @param {K} contractKey - The key identifying the factory to use for deployment. + * @param {string} contractName - The name of the contract to deploy. This must match the contract source code. + * @param {Parameters} constructorArgs - Arguments for the contract's constructor. + * @param {Parameters>['initialize']>?} initializeArgs - Optional arguments for the contract's initialization function. + * @returns {Promise[K]>} A promise that resolves to the deployed contract instance. + */ + public async deployContractWithName({ + chain, + contractKey, + contractName, + constructorArgs, + initializeArgs, + }: { + chain: ChainName; + contractKey: K; + contractName: string; + constructorArgs: Parameters; + initializeArgs?: Parameters< + Awaited>['initialize'] + >; + }): Promise[K]> { + const contract = await this.deployContractFromFactory({ + chain, + factory: this.factories[contractKey], + contractName, + constructorArgs, + initializeArgs, + }); + return contract; + } + + // Deploys a contract with the same name as the contract key + public async deployContract({ + chain, + contractKey, + constructorArgs, + initializeArgs, + }: { + chain: ChainName; + contractKey: K; + constructorArgs: Parameters; + initializeArgs?: Parameters< + Awaited>['initialize'] + >; + }): Promise[K]> { + return this.deployContractWithName({ + chain, + contractKey, + contractName: contractKey.toString(), + constructorArgs, + initializeArgs, + }); + } + + // Deploys the Implementation and Proxy for a given contract + public async deployProxiedContract({ + chain, + contractKey, + contractName, + proxyAdmin, + constructorArgs, + initializeArgs, + }: { + chain: ChainName; + contractKey: K; + contractName: string; + proxyAdmin: string; + constructorArgs: Parameters; + initializeArgs?: Parameters[K]['initialize']>; + }): Promise[K]> { + // Try to initialize the implementation even though it may not be necessary + const implementation = await this.deployContractWithName({ + chain, + contractKey, + contractName, + constructorArgs, + initializeArgs, + }); + + // Initialize the proxy the same way + return this.deployProxy({ + chain, + implementation, + proxyAdmin, + initializeArgs, + }); + } + + // Deploys a proxy for a given implementation contract + protected async deployProxy({ + chain, + implementation, + proxyAdmin, + initializeArgs, + }: { + chain: ChainName; + implementation: C; + proxyAdmin: string; + initializeArgs?: Parameters; + }): Promise { + const isProxied = await isProxy( + this.multiProvider.getProvider(chain), + implementation.address, + ); + if (isProxied) { + // if the implementation is already a proxy, do not deploy a new proxy + return implementation; + } + + const constructorArgs = proxyConstructorArgs( + implementation, + proxyAdmin, + initializeArgs, + ); + const proxy = await this.deployContractFromFactory({ + chain, + factory: new TransparentUpgradeableProxy__factory(), + contractName: 'TransparentUpgradeableProxy', + constructorArgs, + }); + + return implementation.attach(proxy.address) as C; + } + + // Adds verification artifacts to the verificationInputs map + protected addVerificationArtifacts({ + chain, + artifacts, + }: { + chain: ChainName; + artifacts: ContractVerificationInput[]; + }): void { + this.verificationInputs[chain] = this.verificationInputs[chain] || []; + artifacts.forEach((artifact) => { + this.verificationInputs[chain].push(artifact); + }); + } + + // Static deploy function used by Hook and ISM modules. + public static async deployStaticAddressSet({ + chain, + factory, + values, + logger, + threshold = values.length, + multiProvider, + }: { + chain: ChainName; + factory: StaticThresholdAddressSetFactory | StaticAddressSetFactory; + values: Address[]; + logger: Logger; + threshold?: number; + multiProvider: MultiProvider; + }): Promise
{ + const address = await factory['getAddress(address[],uint8)']( + values, + threshold, + ); + const code = await multiProvider.getProvider(chain).getCode(address); + if (code === '0x') { + logger.debug( + `Deploying new ${threshold} of ${values.length} address set to ${chain}`, + ); + const overrides = multiProvider.getTransactionOverrides(chain); + + // estimate gas + const estimatedGas = await factory.estimateGas['deploy(address[],uint8)']( + values, + threshold, + overrides, + ); + + // add 10% buffer + const hash = await factory['deploy(address[],uint8)'](values, threshold, { + ...overrides, + gasLimit: estimatedGas.add(estimatedGas.div(10)), // 10% buffer + }); + + await multiProvider.handleTx(chain, hash); + } else { + logger.debug( + `Recovered ${threshold} of ${values.length} address set on ${chain}: ${address}`, + ); + } + + // TODO: figure out how to get the constructor arguments for manual deploy TXs + // const verificationInput = buildVerificationInput( + // NAME, + // ADDRESS, + // CONSTRUCTOR_ARGS, + // ); + // await this.deployer.verifyContract( + // this.chainName, + // verificationInput, + // logger, + // ); + + return address; + } +} diff --git a/typescript/sdk/src/deploy/HyperlaneAppChecker.ts b/typescript/sdk/src/deploy/HyperlaneAppChecker.ts index fdebf57ab..41419e088 100644 --- a/typescript/sdk/src/deploy/HyperlaneAppChecker.ts +++ b/typescript/sdk/src/deploy/HyperlaneAppChecker.ts @@ -21,12 +21,10 @@ import { AccessControlViolation, BytecodeMismatchViolation, CheckerViolation, - Owner, OwnerViolation, ProxyAdminViolation, TimelockControllerViolation, ViolationType, - resolveOrDeployAccountOwner, } from './types.js'; export abstract class HyperlaneAppChecker< @@ -208,19 +206,12 @@ export abstract class HyperlaneAppChecker< protected async checkOwnership( chain: ChainName, - owner: Owner, + owner: Address, ownableOverrides?: Record, ): Promise { const ownableContracts = await this.ownables(chain); for (const [name, contract] of Object.entries(ownableContracts)) { - let expectedOwner = ownableOverrides?.[name] ?? owner; - if (typeof expectedOwner !== 'string') { - expectedOwner = await resolveOrDeployAccountOwner( - this.multiProvider, - chain, - expectedOwner, - ); - } + const expectedOwner = ownableOverrides?.[name] ?? owner; const actual = await contract.owner(); if (!eqAddress(actual, expectedOwner)) { const violation: OwnerViolation = { diff --git a/typescript/sdk/src/deploy/HyperlaneDeployer.ts b/typescript/sdk/src/deploy/HyperlaneDeployer.ts index e6845dda4..0fda4962a 100644 --- a/typescript/sdk/src/deploy/HyperlaneDeployer.ts +++ b/typescript/sdk/src/deploy/HyperlaneDeployer.ts @@ -2,8 +2,6 @@ import { Contract, PopulatedTransaction, ethers } from 'ethers'; import { Logger } from 'pino'; import { - IPostDispatchHook, - IPostDispatchHook__factory, ITransparentUpgradeableProxy, MailboxClient, Ownable, @@ -28,6 +26,7 @@ import { HyperlaneContractsMap, HyperlaneFactories, } from '../contracts/types.js'; +import { HookConfig } from '../hook/types.js'; import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; import { IsmConfig } from '../ism/types.js'; import { moduleMatchesConfig } from '../ism/utils.js'; @@ -65,7 +64,7 @@ export interface DeployerOptions { } export abstract class HyperlaneDeployer< - Config extends object, + Config, Factories extends HyperlaneFactories, > { public verificationInputs: ChainMap = {}; @@ -106,6 +105,14 @@ export abstract class HyperlaneDeployer< this.cachedAddresses = addressesMap; } + async verifyContract( + chain: ChainName, + input: ContractVerificationInput, + logger = this.logger, + ): Promise { + return this.options.contractVerifier?.verifyContract(chain, input, logger); + } + abstract deployContracts( chain: ChainName, config: Config, @@ -143,7 +150,7 @@ export abstract class HyperlaneDeployer< const deployPromise = runWithTimeout(this.chainTimeoutMs, async () => { const contracts = await this.deployContracts(chain, configMap[chain]); this.addDeployedContracts(chain, contracts); - this.logger.info({ chain }, 'Successfully deployed contracts'); + this.logger.info(`Successfully deployed contracts on ${chain}`); }); if (this.options.concurrentDeploy) { deployPromises.push(deployPromise); @@ -295,32 +302,31 @@ export abstract class HyperlaneDeployer< protected async configureHook( chain: ChainName, contract: C, - targetHook: IPostDispatchHook, + config: HookConfig, getHook: (contract: C) => Promise
, setHook: (contract: C, hook: Address) => Promise, ): Promise { + if (typeof config !== 'string') { + throw new Error('Legacy deployer does not support hook objects'); + } + const configuredHook = await getHook(contract); - if (!eqAddress(targetHook.address, configuredHook)) { - const result = await this.runIfOwner(chain, contract, async () => { + if (!eqAddress(config, configuredHook)) { + await this.runIfOwner(chain, contract, async () => { this.logger.debug( - `Set hook on ${chain} to ${targetHook.address}, currently is ${configuredHook}`, + `Set hook on ${chain} to ${config}, currently is ${configuredHook}`, ); await this.multiProvider.sendTransaction( chain, - setHook(contract, targetHook.address), + setHook(contract, config), ); const actualHook = await getHook(contract); - if (!eqAddress(targetHook.address, actualHook)) { + if (!eqAddress(config, actualHook)) { throw new Error( - `Set hook failed on ${chain}, wanted ${targetHook.address}, got ${actualHook}`, + `Set hook failed on ${chain}, wanted ${config}, got ${actualHook}`, ); } - return true; }); - // if the signer is not the owner, saving the hook address in the artifacts for later use for sending test messages, etc - if (!result) { - this.addDeployedContracts(chain, { customHook: targetHook }); - } } } @@ -336,10 +342,7 @@ export abstract class HyperlaneDeployer< await this.configureHook( local, client, - IPostDispatchHook__factory.connect( - config.hook, - this.multiProvider.getSignerOrProvider(local), - ), + config.hook, (_client) => _client.hook(), (_client, _hook) => _client.populateTransaction.setHook(_hook), ); @@ -723,10 +726,10 @@ export abstract class HyperlaneDeployer< return ret; } - async transferOwnershipOfContracts( + async transferOwnershipOfContracts( chain: ChainName, - config: OwnableConfig, - ownables: Partial>, + config: OwnableConfig, + ownables: Partial>, ): Promise { const receipts: ethers.ContractReceipt[] = []; for (const [contractName, ownable] of Object.entries( @@ -736,7 +739,7 @@ export abstract class HyperlaneDeployer< continue; } const current = await ownable.owner(); - const owner = config.ownerOverrides?.[contractName as K] ?? config.owner; + const owner = config.ownerOverrides?.[contractName] ?? config.owner; if (!eqAddress(current, owner)) { this.logger.debug( { contractName, current, desiredOwner: owner }, diff --git a/typescript/sdk/src/deploy/types.ts b/typescript/sdk/src/deploy/types.ts index 5c74845a4..ad78b97b1 100644 --- a/typescript/sdk/src/deploy/types.ts +++ b/typescript/sdk/src/deploy/types.ts @@ -8,44 +8,10 @@ import type { } from '@hyperlane-xyz/core'; import { Address } from '@hyperlane-xyz/utils'; -import { deployInterchainAccount } from '../middleware/account/InterchainAccount.js'; -import { AccountConfig } from '../middleware/account/types.js'; -import { MultiProvider } from '../providers/MultiProvider.js'; +import { OwnableSchema } from '../schemas.js'; import type { ChainName } from '../types.js'; -import { OwnableConfigSchema } from './schemas.js'; - -export type Owner = Address | AccountConfig; - -/** - * @remarks ownerOverrides is added outside of the Schema because zod handle generics in a weird way (uses functions) - * @see https://stackoverflow.com/questions/74907523/creating-zod-schema-for-generic-interface - */ -export type OwnableConfig = z.infer< - typeof OwnableConfigSchema -> & { - ownerOverrides?: Partial>; -}; - -export async function resolveOrDeployAccountOwner( - multiProvider: MultiProvider, - chain: ChainName, - owner: Owner, -): Promise
{ - if (typeof owner === 'string') { - return owner; - } else { - if (!owner.localRouter) { - throw new Error('localRouter is required for AccountConfig'); - } - // submits a transaction to deploy an interchain account if the owner is an AccountConfig and the ICA isn't not deployed yet - return deployInterchainAccount(multiProvider, chain, owner); - } -} - -export function isOwnableConfig(config: object): config is OwnableConfig { - return 'owner' in config; -} +export type OwnableConfig = z.infer; export interface CheckerViolation { chain: ChainName; diff --git a/typescript/sdk/src/gas/HyperlaneIgpDeployer.ts b/typescript/sdk/src/gas/HyperlaneIgpDeployer.ts index 0c0cbfc34..6283d87fa 100644 --- a/typescript/sdk/src/gas/HyperlaneIgpDeployer.ts +++ b/typescript/sdk/src/gas/HyperlaneIgpDeployer.ts @@ -15,7 +15,10 @@ import { MultiProvider } from '../providers/MultiProvider.js'; import { ChainName } from '../types.js'; import { IgpFactories, igpFactories } from './contracts.js'; -import { serializeDifference } from './oracle/types.js'; +import { + oracleConfigToOracleData, + serializeDifference, +} from './oracle/types.js'; import { IgpConfig } from './types.js'; export class HyperlaneIgpDeployer extends HyperlaneDeployer< @@ -122,22 +125,24 @@ export class HyperlaneIgpDeployer extends HyperlaneDeployer< const actual = await gasOracle.remoteGasData(remoteDomain); + const desiredData = oracleConfigToOracleData(desired); + if ( !actual.gasPrice.eq(desired.gasPrice) || !actual.tokenExchangeRate.eq(desired.tokenExchangeRate) ) { this.logger.info( - `${chain} -> ${remote}: ${serializeDifference(actual, desired)}`, + `${chain} -> ${remote}: ${serializeDifference(actual, desiredData)}`, ); configsToSet.push({ remoteDomain, - ...desired, + ...desiredData, }); } const exampleRemoteGas = (config.overhead[remote] ?? 200_000) + 50_000; - const exampleRemoteGasCost = desired.tokenExchangeRate - .mul(desired.gasPrice) + const exampleRemoteGasCost = desiredData.tokenExchangeRate + .mul(desiredData.gasPrice) .mul(exampleRemoteGas) .div(TOKEN_EXCHANGE_RATE_SCALE); this.logger.info( diff --git a/typescript/sdk/src/gas/oracle/configure-gas-oracles.hardhat-test.ts b/typescript/sdk/src/gas/oracle/configure-gas-oracles.hardhat-test.ts index 129ea852d..8eb66a6ed 100644 --- a/typescript/sdk/src/gas/oracle/configure-gas-oracles.hardhat-test.ts +++ b/typescript/sdk/src/gas/oracle/configure-gas-oracles.hardhat-test.ts @@ -11,6 +11,8 @@ import { ChainMap } from '../../types.js'; import { HyperlaneIgpDeployer } from '../HyperlaneIgpDeployer.js'; import { IgpConfig } from '../types.js'; +import { oracleConfigToOracleData } from './types.js'; + describe('HyperlaneIgpDeployer', () => { const local = TestChainName.test1; const remote = TestChainName.test2; @@ -36,13 +38,15 @@ describe('HyperlaneIgpDeployer', () => { expect({ gasPrice: deployedConfig.gasPrice, tokenExchangeRate: deployedConfig.tokenExchangeRate, - }).to.deep.equal(testConfig[local].oracleConfig![remote]); + }).to.deep.equal( + oracleConfigToOracleData(testConfig[local].oracleConfig![remote]), + ); }); it('should configure new oracle config', async () => { testConfig[local].oracleConfig![remote] = { - tokenExchangeRate: utils.parseUnits('2', 'gwei'), - gasPrice: utils.parseUnits('3', 'gwei'), + tokenExchangeRate: utils.parseUnits('2', 'gwei').toString(), + gasPrice: utils.parseUnits('3', 'gwei').toString(), }; const localContracts = await deployer.deployContracts( @@ -55,6 +59,8 @@ describe('HyperlaneIgpDeployer', () => { expect({ gasPrice: modifiedConfig.gasPrice, tokenExchangeRate: modifiedConfig.tokenExchangeRate, - }).to.deep.equal(testConfig[local].oracleConfig![remote]); + }).to.deep.equal( + oracleConfigToOracleData(testConfig[local].oracleConfig![remote]), + ); }); }); diff --git a/typescript/sdk/src/gas/oracle/types.ts b/typescript/sdk/src/gas/oracle/types.ts index c4ba2fb45..99669b7ea 100644 --- a/typescript/sdk/src/gas/oracle/types.ts +++ b/typescript/sdk/src/gas/oracle/types.ts @@ -1,21 +1,25 @@ import { ethers } from 'ethers'; - -import { StorageGasOracle } from '@hyperlane-xyz/core'; +import { z } from 'zod'; import { TOKEN_EXCHANGE_RATE_DECIMALS } from '../../consts/igp.js'; -export enum GasOracleContractType { - StorageGasOracle = 'StorageGasOracle', -} +export const StorageGasOracleConfigSchema = z.object({ + gasPrice: z.string(), + tokenExchangeRate: z.string(), +}); // Gas data to configure on a single destination chain. -export type StorageGasOracleConfig = Pick< - StorageGasOracle.RemoteGasDataConfigStructOutput, - 'gasPrice' | 'tokenExchangeRate' +export type StorageGasOracleConfig = z.output< + typeof StorageGasOracleConfigSchema >; +export type OracleData = { + tokenExchangeRate: ethers.BigNumber; + gasPrice: ethers.BigNumber; +}; + export const formatGasOracleConfig = ( - config: StorageGasOracleConfig, + config: OracleData, ): { tokenExchangeRate: string; gasPrice: string; @@ -43,9 +47,17 @@ const serializePercentDifference = ( return diff.isNegative() ? `${diff.toString()}%` : `+${diff.toString()}%`; }; +// TODO: replace once #3771 is fixed +export const oracleConfigToOracleData = ( + config: StorageGasOracleConfig, +): OracleData => ({ + gasPrice: ethers.BigNumber.from(config.gasPrice), + tokenExchangeRate: ethers.BigNumber.from(config.tokenExchangeRate), +}); + export const serializeDifference = ( - actual: StorageGasOracleConfig, - expected: StorageGasOracleConfig, + actual: OracleData, + expected: OracleData, ): string => { const gasPriceDiff = serializePercentDifference( actual.gasPrice, diff --git a/typescript/sdk/src/gas/types.ts b/typescript/sdk/src/gas/types.ts index dacebc2f7..55114478d 100644 --- a/typescript/sdk/src/gas/types.ts +++ b/typescript/sdk/src/gas/types.ts @@ -1,26 +1,14 @@ import { BigNumber } from 'ethers'; +import { z } from 'zod'; import { InterchainGasPaymaster } from '@hyperlane-xyz/core'; import type { Address } from '@hyperlane-xyz/utils'; -import type { CheckerViolation, OwnableConfig } from '../deploy/types.js'; +import type { CheckerViolation } from '../deploy/types.js'; +import { IgpSchema } from '../hook/schemas.js'; import { ChainMap } from '../types.js'; -import { IgpFactories } from './contracts.js'; -import { - GasOracleContractType, - StorageGasOracleConfig, -} from './oracle/types.js'; - -export type IgpConfig = OwnableConfig & { - beneficiary: Address; - oracleKey: Address; - overhead: ChainMap; - // TODO: require this - oracleConfig?: ChainMap; - // DEPRECATED - gasOracleType?: ChainMap; -}; +export type IgpConfig = z.infer; export enum IgpViolationType { Beneficiary = 'Beneficiary', diff --git a/typescript/sdk/src/hook/EvmHookModule.hardhat-test.ts b/typescript/sdk/src/hook/EvmHookModule.hardhat-test.ts new file mode 100644 index 000000000..3a8094eb6 --- /dev/null +++ b/typescript/sdk/src/hook/EvmHookModule.hardhat-test.ts @@ -0,0 +1,487 @@ +/* eslint-disable no-console */ +import { expect } from 'chai'; +import hre from 'hardhat'; + +import { + Address, + configDeepEquals, + normalizeConfig, + stringifyObject, +} from '@hyperlane-xyz/utils'; + +import { TestChainName, testChains } from '../consts/testChains.js'; +import { HyperlaneAddresses, HyperlaneContracts } from '../contracts/types.js'; +import { TestCoreDeployer } from '../core/TestCoreDeployer.js'; +import { CoreAddresses } from '../core/contracts.js'; +import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js'; +import { ProxyFactoryFactories } from '../deploy/contracts.js'; +import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { randomAddress, randomInt } from '../test/testUtils.js'; + +import { EvmHookModule } from './EvmHookModule.js'; +import { + AggregationHookConfig, + DomainRoutingHookConfig, + FallbackRoutingHookConfig, + HookConfig, + HookType, + IgpHookConfig, + MerkleTreeHookConfig, + PausableHookConfig, + ProtocolFeeHookConfig, +} from './types.js'; + +const hookTypes = Object.values(HookType); + +function randomHookType(): HookType { + // OP_STACK filtering is temporary until we have a way to deploy the required contracts + const filteredHookTypes = hookTypes.filter( + (type) => type !== HookType.OP_STACK && type !== HookType.CUSTOM, + ); + return filteredHookTypes[ + Math.floor(Math.random() * filteredHookTypes.length) + ]; +} + +function randomProtocolFee(): { maxProtocolFee: string; protocolFee: string } { + const maxProtocolFee = Math.random() * 100000000000000; + const protocolFee = (Math.random() * maxProtocolFee) / 1000; + return { + maxProtocolFee: Math.floor(maxProtocolFee).toString(), + protocolFee: Math.floor(protocolFee).toString(), + }; +} + +function randomHookConfig( + depth = 0, + maxDepth = 2, + providedHookType?: HookType, +): HookConfig { + const hookType: HookType = providedHookType ?? randomHookType(); + + if (depth >= maxDepth) { + if ( + hookType === HookType.AGGREGATION || + hookType === HookType.ROUTING || + hookType === HookType.FALLBACK_ROUTING + ) { + return { type: HookType.MERKLE_TREE }; + } + } + + switch (hookType) { + case HookType.MERKLE_TREE: + return { type: hookType }; + + case HookType.AGGREGATION: + return { + type: hookType, + hooks: [ + randomHookConfig(depth + 1, maxDepth), + randomHookConfig(depth + 1, maxDepth), + ], + }; + + case HookType.INTERCHAIN_GAS_PAYMASTER: { + const owner = randomAddress(); + return { + owner, + type: hookType, + beneficiary: randomAddress(), + oracleKey: owner, + overhead: Object.fromEntries( + testChains.map((c) => [c, Math.floor(Math.random() * 100)]), + ), + oracleConfig: Object.fromEntries( + testChains.map((c) => [ + c, + { + tokenExchangeRate: randomInt(1234567891234).toString(), + gasPrice: randomInt(1234567891234).toString(), + }, + ]), + ), + }; + } + + case HookType.PROTOCOL_FEE: { + const { maxProtocolFee, protocolFee } = randomProtocolFee(); + return { + owner: randomAddress(), + type: hookType, + maxProtocolFee, + protocolFee, + beneficiary: randomAddress(), + }; + } + + case HookType.OP_STACK: + return { + owner: randomAddress(), + type: hookType, + nativeBridge: randomAddress(), + destinationChain: 'testChain', + }; + + case HookType.ROUTING: + return { + owner: randomAddress(), + type: hookType, + domains: Object.fromEntries( + testChains.map((c) => [c, randomHookConfig(depth + 1, maxDepth)]), + ), + }; + + case HookType.FALLBACK_ROUTING: + return { + owner: randomAddress(), + type: hookType, + fallback: randomHookConfig(depth + 1, maxDepth), + domains: Object.fromEntries( + testChains.map((c) => [c, randomHookConfig(depth + 1, maxDepth)]), + ), + }; + + case HookType.PAUSABLE: + return { + owner: randomAddress(), + type: hookType, + paused: false, + }; + + default: + throw new Error(`Unsupported Hook type: ${hookType}`); + } +} + +describe('EvmHookModule', async () => { + let multiProvider: MultiProvider; + let coreAddresses: CoreAddresses; + + const chain = TestChainName.test4; + let proxyFactoryAddresses: HyperlaneAddresses; + let factoryContracts: HyperlaneContracts; + + beforeEach(async () => { + const [signer] = await hre.ethers.getSigners(); + multiProvider = MultiProvider.createTestMultiProvider({ signer }); + + const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); + const contractsMap = await ismFactoryDeployer.deploy( + multiProvider.mapKnownChains(() => ({})), + ); + + // get addresses of factories for the chain + factoryContracts = contractsMap[chain]; + proxyFactoryAddresses = Object.keys(factoryContracts).reduce((acc, key) => { + acc[key] = + contractsMap[chain][key as keyof ProxyFactoryFactories].address; + return acc; + }, {} as Record) as HyperlaneAddresses; + + // legacy HyperlaneIsmFactory is required to do a core deploy + const legacyIsmFactory = new HyperlaneIsmFactory( + contractsMap, + multiProvider, + ); + + // core deployer for tests + const testCoreDeployer = new TestCoreDeployer( + multiProvider, + legacyIsmFactory, + ); + + // mailbox and proxy admin for the core deploy + const { mailbox, proxyAdmin, validatorAnnounce } = ( + await testCoreDeployer.deployApp() + ).getContracts(chain); + + coreAddresses = { + mailbox: mailbox.address, + proxyAdmin: proxyAdmin.address, + validatorAnnounce: validatorAnnounce.address, + }; + }); + + // Helper method for checking whether Hook module matches a given config + async function hookModuleMatchesConfig({ + hook, + config, + }: { + hook: EvmHookModule; + config: HookConfig; + }): Promise { + const normalizedDerivedConfig = normalizeConfig(await hook.read()); + const normalizedConfig = normalizeConfig(config); + const matches = configDeepEquals(normalizedDerivedConfig, normalizedConfig); + if (!matches) { + console.error( + 'Derived config:\n', + stringifyObject(normalizedDerivedConfig), + ); + console.error('Expected config:\n', stringifyObject(normalizedConfig)); + } + return matches; + } + + // hook module and config for testing + let testHook: EvmHookModule; + let testConfig: HookConfig; + + // expect that the hook matches the config after all tests + afterEach(async () => { + expect( + await hookModuleMatchesConfig({ hook: testHook, config: testConfig }), + ).to.be.true; + }); + + // create a new Hook and verify that it matches the config + async function createHook( + config: HookConfig, + ): Promise<{ hook: EvmHookModule; initialHookAddress: Address }> { + const hook = await EvmHookModule.create({ + chain, + config, + proxyFactoryFactories: proxyFactoryAddresses, + coreAddresses, + multiProvider, + }); + testConfig = config; + testHook = hook; + return { hook, initialHookAddress: hook.serialize().deployedHook }; + } + + describe('create', async () => { + it('deploys a hook of type CUSTOM', async () => { + const config: HookConfig = randomAddress(); + await createHook(config); + }); + + it('deploys a hook of type MERKLE_TREE', async () => { + const config: MerkleTreeHookConfig = { + type: HookType.MERKLE_TREE, + }; + await createHook(config); + }); + + it('deploys a hook of type INTERCHAIN_GAS_PAYMASTER', async () => { + const owner = randomAddress(); + const config: IgpHookConfig = { + owner, + type: HookType.INTERCHAIN_GAS_PAYMASTER, + beneficiary: randomAddress(), + oracleKey: owner, + overhead: Object.fromEntries( + testChains.map((c) => [c, Math.floor(Math.random() * 100)]), + ), + oracleConfig: Object.fromEntries( + testChains.map((c) => [ + c, + { + tokenExchangeRate: randomInt(1234567891234).toString(), + gasPrice: randomInt(1234567891234).toString(), + }, + ]), + ), + }; + await createHook(config); + }); + + it('deploys a hook of type PROTOCOL_FEE', async () => { + const { maxProtocolFee, protocolFee } = randomProtocolFee(); + const config: ProtocolFeeHookConfig = { + owner: randomAddress(), + type: HookType.PROTOCOL_FEE, + maxProtocolFee, + protocolFee, + beneficiary: randomAddress(), + }; + await createHook(config); + }); + + it('deploys a hook of type ROUTING', async () => { + const config: DomainRoutingHookConfig = { + owner: randomAddress(), + type: HookType.ROUTING, + domains: Object.fromEntries( + testChains + .filter((c) => c !== TestChainName.test4) + .map((c) => [ + c, + { + type: HookType.MERKLE_TREE, + }, + ]), + ), + }; + await createHook(config); + }); + + it('deploys a hook of type FALLBACK_ROUTING', async () => { + const config: FallbackRoutingHookConfig = { + owner: randomAddress(), + type: HookType.FALLBACK_ROUTING, + fallback: { type: HookType.MERKLE_TREE }, + domains: Object.fromEntries( + testChains + .filter((c) => c !== TestChainName.test4) + .map((c) => [ + c, + { + type: HookType.MERKLE_TREE, + }, + ]), + ), + }; + await createHook(config); + }); + + it('deploys a hook of type AGGREGATION', async () => { + const config: AggregationHookConfig = { + type: HookType.AGGREGATION, + hooks: [{ type: HookType.MERKLE_TREE }, { type: HookType.MERKLE_TREE }], + }; + await createHook(config); + }); + + it('deploys a hook of type PAUSABLE', async () => { + const config: PausableHookConfig = { + owner: randomAddress(), + type: HookType.PAUSABLE, + paused: false, + }; + await createHook(config); + }); + + // it('deploys a hook of type OP_STACK', async () => { + // need to setup deploying/mocking IL1CrossDomainMessenger before this test can be enabled + // const config: OpStackHookConfig = { + // owner: randomAddress(), + // type: HookType.OP_STACK, + // nativeBridge: randomAddress(), + // destinationChain: 'testChain', + // }; + // await createHook(config); + // }); + + for (let i = 0; i < 16; i++) { + it(`deploys a random hook config #${i}`, async () => { + // random config with depth 0-2 + const config = randomHookConfig(); + await createHook(config); + }); + } + + it('regression test #1', async () => { + const config: HookConfig = { + type: HookType.AGGREGATION, + hooks: [ + { + owner: '0xebe67f0a423fd1c4af21debac756e3238897c665', + type: HookType.INTERCHAIN_GAS_PAYMASTER, + beneficiary: '0xfe3be5940327305aded56f20359761ef85317554', + oracleKey: '0xebe67f0a423fd1c4af21debac756e3238897c665', + overhead: { + test1: 18, + test2: 85, + test3: 23, + test4: 69, + }, + oracleConfig: { + test1: { + tokenExchangeRate: '1032586497157', + gasPrice: '1026942205817', + }, + test2: { + tokenExchangeRate: '81451154935', + gasPrice: '1231220057593', + }, + test3: { + tokenExchangeRate: '31347320275', + gasPrice: '21944956734', + }, + test4: { + tokenExchangeRate: '1018619796544', + gasPrice: '1124484183261', + }, + }, + }, + { + owner: '0xcc803fc9e6551b9eaaebfabbdd5af3eccea252ff', + type: HookType.ROUTING, + domains: { + test1: { + type: HookType.MERKLE_TREE, + }, + test2: { + owner: '0x7e43dfa88c4a5d29a8fcd69883b7f6843d465ca3', + type: HookType.INTERCHAIN_GAS_PAYMASTER, + beneficiary: '0x762e71a849a3825613cf5cbe70bfff27d0fe7766', + oracleKey: '0x7e43dfa88c4a5d29a8fcd69883b7f6843d465ca3', + overhead: { + test1: 46, + test2: 34, + test3: 47, + test4: 24, + }, + oracleConfig: { + test1: { + tokenExchangeRate: '1132883204938', + gasPrice: '1219466305935', + }, + test2: { + tokenExchangeRate: '938422264723', + gasPrice: '229134538568', + }, + test3: { + tokenExchangeRate: '69699594189', + gasPrice: '475781234236', + }, + test4: { + tokenExchangeRate: '1027245678936', + gasPrice: '502686418976', + }, + }, + }, + test3: { + type: HookType.MERKLE_TREE, + }, + test4: { + owner: '0xa1ce72b70566f2cba6000bfe6af50f0f358f49d7', + type: HookType.INTERCHAIN_GAS_PAYMASTER, + beneficiary: '0x9796c0c49c61fe01eb1a8ba56d09b831f6da8603', + oracleKey: '0xa1ce72b70566f2cba6000bfe6af50f0f358f49d7', + overhead: { + test1: 71, + test2: 16, + test3: 37, + test4: 13, + }, + oracleConfig: { + test1: { + tokenExchangeRate: '443874625350', + gasPrice: '799154764503', + }, + test2: { + tokenExchangeRate: '915348561750', + gasPrice: '1124345797215', + }, + test3: { + tokenExchangeRate: '930832717805', + gasPrice: '621743941770', + }, + test4: { + tokenExchangeRate: '147394981623', + gasPrice: '766494385983', + }, + }, + }, + }, + }, + ], + }; + await createHook(config); + }); + }); +}); diff --git a/typescript/sdk/src/hook/EvmHookModule.ts b/typescript/sdk/src/hook/EvmHookModule.ts index c6e01bc87..612043dd4 100644 --- a/typescript/sdk/src/hook/EvmHookModule.ts +++ b/typescript/sdk/src/hook/EvmHookModule.ts @@ -1,53 +1,654 @@ -import { Address, ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; +import { BigNumber, ethers } from 'ethers'; +import { + DomainRoutingHook, + DomainRoutingHook__factory, + FallbackDomainRoutingHook, + IL1CrossDomainMessenger__factory, + IPostDispatchHook__factory, + InterchainGasPaymaster, + OPStackHook, + OPStackIsm__factory, + PausableHook, + ProtocolFee, + StaticAggregationHook, + StaticAggregationHookFactory__factory, + StaticAggregationHook__factory, + StorageGasOracle, +} from '@hyperlane-xyz/core'; +import { + Address, + ProtocolType, + addressToBytes32, + assert, + configDeepEquals, + rootLogger, +} from '@hyperlane-xyz/utils'; + +import { TOKEN_EXCHANGE_RATE_SCALE } from '../consts/igp.js'; import { HyperlaneAddresses } from '../contracts/types.js'; import { HyperlaneModule, - HyperlaneModuleArgs, + HyperlaneModuleParams, } from '../core/AbstractHyperlaneModule.js'; -import { HyperlaneDeployer } from '../deploy/HyperlaneDeployer.js'; +import { CoreAddresses } from '../core/contracts.js'; +import { EvmModuleDeployer } from '../deploy/EvmModuleDeployer.js'; +import { ProxyFactoryFactories } from '../deploy/contracts.js'; +import { ContractVerifier } from '../deploy/verify/ContractVerifier.js'; +import { IgpFactories, igpFactories } from '../gas/contracts.js'; +import { IgpConfig } from '../gas/types.js'; +import { EvmIsmModule } from '../ism/EvmIsmModule.js'; +import { IsmType, OpStackIsmConfig } from '../ism/types.js'; import { MultiProvider } from '../providers/MultiProvider.js'; -import { EthersV5Transaction } from '../providers/ProviderType.js'; +import { AnnotatedEV5Transaction } from '../providers/ProviderType.js'; +import { ChainNameOrId } from '../types.js'; import { EvmHookReader } from './EvmHookReader.js'; -import { HookFactories } from './contracts.js'; -import { HookConfig } from './types.js'; +import { DeployedHook, HookFactories, hookFactories } from './contracts.js'; +import { + AggregationHookConfig, + DomainRoutingHookConfig, + FallbackRoutingHookConfig, + HookConfig, + HookType, + IgpHookConfig, + OpStackHookConfig, + PausableHookConfig, + ProtocolFeeHookConfig, +} from './types.js'; + +type HookModuleAddresses = { + deployedHook: Address; + mailbox: Address; + proxyAdmin: Address; +}; -// WIP example implementation of EvmHookModule export class EvmHookModule extends HyperlaneModule< ProtocolType.Ethereum, HookConfig, - HyperlaneAddresses & { - deployedHook: Address; - } + HyperlaneAddresses & HookModuleAddresses > { - protected logger = rootLogger.child({ module: 'EvmHookModule' }); - protected reader: EvmHookReader; + protected readonly logger = rootLogger.child({ module: 'EvmHookModule' }); + protected readonly reader: EvmHookReader; + protected readonly deployer: EvmModuleDeployer; + + // Adding these to reduce how often we need to grab from MultiProvider. + public readonly chain: string; + // We use domainId here because MultiProvider.getDomainId() will always + // return a number, and EVM the domainId and chainId are the same. + public readonly domainId: number; + + // Transaction overrides for the chain + protected readonly txOverrides: Partial; protected constructor( protected readonly multiProvider: MultiProvider, - protected readonly deployer: HyperlaneDeployer, - args: HyperlaneModuleArgs< + args: HyperlaneModuleParams< HookConfig, - HyperlaneAddresses & { - deployedHook: Address; - } + HyperlaneAddresses & HookModuleAddresses >, + contractVerifier?: ContractVerifier, ) { super(args); - this.reader = new EvmHookReader(multiProvider, args.chain); + + this.reader = new EvmHookReader(multiProvider, this.args.chain); + this.deployer = new EvmModuleDeployer( + multiProvider, + { + ...hookFactories, + ...igpFactories, + }, + this.logger, + contractVerifier, + ); + + this.chain = this.multiProvider.getChainName(this.args.chain); + this.domainId = this.multiProvider.getDomainId(this.chain); + + this.txOverrides = this.multiProvider.getTransactionOverrides(this.chain); } public async read(): Promise { - return await this.reader.deriveHookConfig(this.args.addresses.deployedHook); + if (typeof this.args.config === 'string') { + return this.args.addresses.deployedHook; + } else { + const hookConfig = await this.reader.deriveHookConfig( + this.args.addresses.deployedHook, + ); + assert( + hookConfig, + `No hook config found for ${this.args.addresses.deployedHook}`, + ); + return hookConfig; + } } - public async update(_config: HookConfig): Promise { + public async update(_config: HookConfig): Promise { throw new Error('Method not implemented.'); } // manually write static create function - public static create(_config: HookConfig): Promise { - throw new Error('not implemented'); + public static async create({ + chain, + config, + proxyFactoryFactories, + coreAddresses, + multiProvider, + }: { + chain: ChainNameOrId; + config: HookConfig; + proxyFactoryFactories: HyperlaneAddresses; + coreAddresses: CoreAddresses; + multiProvider: MultiProvider; + }): Promise { + // instantiate new EvmHookModule + const module = new EvmHookModule(multiProvider, { + addresses: { + ...proxyFactoryFactories, + ...coreAddresses, + deployedHook: ethers.constants.AddressZero, + }, + chain, + config, + }); + + // deploy hook and assign address to module + const deployedHook = await module.deploy({ config }); + module.args.addresses.deployedHook = deployedHook.address; + + return module; + } + + // Compute delta between current and target domain configurations + protected async computeRoutingHooksToSet({ + currentDomains, + targetDomains, + }: { + currentDomains: DomainRoutingHookConfig['domains']; + targetDomains: DomainRoutingHookConfig['domains']; + }): Promise { + const routingHookUpdates: DomainRoutingHook.HookConfigStruct[] = []; + + // Iterate over the target domains and compare with the current configuration + for (const [dest, targetDomainConfig] of Object.entries(targetDomains)) { + const destDomain = this.multiProvider.tryGetDomainId(dest); + if (!destDomain) { + this.logger.warn(`Domain not found in MultiProvider: ${dest}`); + continue; + } + + // If the domain is not in the current config or the config has changed, deploy a new hook + // TODO: in-place updates per domain as a future optimization + if (!configDeepEquals(currentDomains[dest], targetDomainConfig)) { + const domainHook = await this.deploy({ + config: targetDomainConfig, + }); + + routingHookUpdates.push({ + destination: destDomain, + hook: domainHook.address, + }); + } + } + + return routingHookUpdates; + } + + // Updates a routing hook + protected async updateRoutingHook({ + current, + target, + }: { + current: DomainRoutingHookConfig | FallbackRoutingHookConfig; + target: DomainRoutingHookConfig | FallbackRoutingHookConfig; + }): Promise { + // Deploy a new fallback hook if the fallback config has changed + if ( + target.type === HookType.FALLBACK_ROUTING && + !configDeepEquals( + target.fallback, + (current as FallbackRoutingHookConfig).fallback, + ) + ) { + const hook = await this.deploy({ config: target }); + this.args.addresses.deployedHook = hook.address; + } + + const routingUpdates = await this.computeRoutingHooksToSet({ + currentDomains: current.domains, + targetDomains: target.domains, + }); + + // Return if no updates are required + if (routingUpdates.length === 0) { + return []; + } + + // Create tx for setting hooks + return [ + { + annotation: 'Updating routing hooks...', + chainId: this.domainId, + to: this.args.addresses.deployedHook, + data: DomainRoutingHook__factory.createInterface().encodeFunctionData( + 'setHooks', + [routingUpdates], + ), + }, + ]; + } + + protected async deploy({ + config, + }: { + config: HookConfig; + }): Promise { + // If it's an address, just return a base Hook + if (typeof config === 'string') { + // TODO: https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3773 + // we can remove the ts-ignore once we have a proper type for address Hooks + // @ts-ignore + return IPostDispatchHook__factory.connect( + config, + this.multiProvider.getSignerOrProvider(this.args.chain), + ); + } + + switch (config.type) { + case HookType.MERKLE_TREE: + return this.deployer.deployContract({ + chain: this.chain, + contractKey: HookType.MERKLE_TREE, + constructorArgs: [this.args.addresses.mailbox], + }); + case HookType.INTERCHAIN_GAS_PAYMASTER: + return this.deployIgpHook({ config }); + case HookType.AGGREGATION: + return this.deployAggregationHook({ config }); + case HookType.PROTOCOL_FEE: + return this.deployProtocolFeeHook({ config }); + case HookType.OP_STACK: + return this.deployOpStackHook({ config }); + case HookType.ROUTING: + case HookType.FALLBACK_ROUTING: + return this.deployRoutingHook({ config }); + case HookType.PAUSABLE: { + return this.deployPausableHook({ config }); + } + default: + throw new Error(`Unsupported hook config: ${config}`); + } + } + + protected async deployProtocolFeeHook({ + config, + }: { + config: ProtocolFeeHookConfig; + }): Promise { + this.logger.debug('Deploying ProtocolFeeHook...'); + return this.deployer.deployContract({ + chain: this.chain, + contractKey: HookType.PROTOCOL_FEE, + constructorArgs: [ + config.maxProtocolFee, + config.protocolFee, + config.beneficiary, + config.owner, + ], + }); + } + + protected async deployPausableHook({ + config, + }: { + config: PausableHookConfig; + }): Promise { + this.logger.debug('Deploying PausableHook...'); + const hook = await this.deployer.deployContract({ + chain: this.chain, + contractKey: HookType.PAUSABLE, + constructorArgs: [], + }); + + // transfer ownership + await this.multiProvider.handleTx( + this.chain, + hook.transferOwnership(config.owner, this.txOverrides), + ); + + return hook; + } + + protected async deployAggregationHook({ + config, + }: { + config: AggregationHookConfig; + }): Promise { + this.logger.debug('Deploying AggregationHook...'); + + // deploy subhooks + const aggregatedHooks = []; + for (const hookConfig of config.hooks) { + const { address } = await this.deploy({ config: hookConfig }); + aggregatedHooks.push(address); + } + + // deploy aggregation hook + this.logger.debug( + `Deploying aggregation hook of type ${config.hooks.map((h) => + typeof h === 'string' ? h : h.type, + )}...`, + ); + const signer = this.multiProvider.getSigner(this.chain); + const factory = StaticAggregationHookFactory__factory.connect( + this.args.addresses.staticAggregationHookFactory, + signer, + ); + const address = await EvmModuleDeployer.deployStaticAddressSet({ + chain: this.chain, + factory, + values: aggregatedHooks, + logger: this.logger, + multiProvider: this.multiProvider, + }); + + // return aggregation hook + return StaticAggregationHook__factory.connect(address, signer); + } + + protected async deployOpStackHook({ + config, + }: { + config: OpStackHookConfig; + }): Promise { + const chain = this.chain; + const mailbox = this.args.addresses.mailbox; + this.logger.debug( + 'Deploying OPStackHook for %s to %s...', + chain, + config.destinationChain, + ); + + // fetch l2 messenger address from l1 messenger + const l1Messenger = IL1CrossDomainMessenger__factory.connect( + config.nativeBridge, + this.multiProvider.getSignerOrProvider(chain), + ); + const l2Messenger: Address = await l1Messenger.OTHER_MESSENGER(); + // deploy opstack ism + const ismConfig: OpStackIsmConfig = { + type: IsmType.OP_STACK, + origin: chain, + nativeBridge: l2Messenger, + }; + + // deploy opstack ism + const opStackIsmAddress = ( + await EvmIsmModule.create({ + chain: config.destinationChain, + config: ismConfig, + proxyFactoryFactories: this.args.addresses, + mailbox: mailbox, + multiProvider: this.multiProvider, + }) + ).serialize().deployedIsm; + + // connect to ISM + const opstackIsm = OPStackIsm__factory.connect( + opStackIsmAddress, + this.multiProvider.getSignerOrProvider(config.destinationChain), + ); + + // deploy opstack hook + const hook = await this.deployer.deployContract({ + chain, + contractKey: HookType.OP_STACK, + constructorArgs: [ + mailbox, + this.multiProvider.getDomainId(config.destinationChain), + addressToBytes32(opstackIsm.address), + config.nativeBridge, + ], + }); + + // set authorized hook on opstack ism + const authorizedHook = await opstackIsm.authorizedHook(); + if (authorizedHook === addressToBytes32(hook.address)) { + this.logger.debug( + 'Authorized hook already set on ism %s', + opstackIsm.address, + ); + return hook; + } else if ( + authorizedHook !== addressToBytes32(ethers.constants.AddressZero) + ) { + this.logger.debug( + 'Authorized hook mismatch on ism %s, expected %s, got %s', + opstackIsm.address, + addressToBytes32(hook.address), + authorizedHook, + ); + throw new Error('Authorized hook mismatch'); + } + + // check if mismatch and redeploy hook + this.logger.debug( + 'Setting authorized hook %s on ism % on destination %s', + hook.address, + opstackIsm.address, + config.destinationChain, + ); + await this.multiProvider.handleTx( + config.destinationChain, + opstackIsm.setAuthorizedHook( + addressToBytes32(hook.address), + this.multiProvider.getTransactionOverrides(config.destinationChain), + ), + ); + + return hook; + } + + protected async deployRoutingHook({ + config, + }: { + config: DomainRoutingHookConfig | FallbackRoutingHookConfig; + }): Promise { + // originally set owner to deployer so we can set hooks + const deployerAddress = await this.multiProvider.getSignerAddress( + this.chain, + ); + + let routingHook: DomainRoutingHook | FallbackDomainRoutingHook; + if (config.type === HookType.FALLBACK_ROUTING) { + // deploy fallback hook + const fallbackHook = await this.deploy({ config: config.fallback }); + // deploy routing hook with fallback + routingHook = await this.deployer.deployContract({ + chain: this.chain, + contractKey: HookType.FALLBACK_ROUTING, + constructorArgs: [ + this.args.addresses.mailbox, + deployerAddress, + fallbackHook.address, + ], + }); + } else { + // deploy routing hook + routingHook = await this.deployer.deployContract({ + chain: this.chain, + contractKey: HookType.ROUTING, + constructorArgs: [this.args.addresses.mailbox, deployerAddress], + }); + } + + // compute the hooks that need to be set + const hooksToSet = await this.computeRoutingHooksToSet({ + currentDomains: {}, + targetDomains: config.domains, + }); + + // set hooks + await this.multiProvider.handleTx( + this.chain, + routingHook.setHooks(hooksToSet, this.txOverrides), + ); + + // transfer ownership + await this.multiProvider.handleTx( + this.chain, + routingHook.transferOwnership(config.owner, this.txOverrides), + ); + + // return a fully configured routing hook + return routingHook; + } + + protected async deployIgpHook({ + config, + }: { + config: IgpHookConfig; + }): Promise { + this.logger.debug('Deploying IGP as hook...'); + + // Deploy the StorageGasOracle + const storageGasOracle = await this.deployStorageGasOracle({ + config, + }); + + // Deploy the InterchainGasPaymaster + const interchainGasPaymaster = await this.deployInterchainGasPaymaster({ + storageGasOracle, + config, + }); + + return interchainGasPaymaster; + } + + protected async deployInterchainGasPaymaster({ + storageGasOracle, + config, + }: { + storageGasOracle: StorageGasOracle; + config: IgpConfig; + }): Promise { + const deployerAddress = await this.multiProvider.getSignerAddress( + this.chain, + ); + + const igp = await this.deployer.deployProxiedContract({ + chain: this.chain, + contractKey: HookType.INTERCHAIN_GAS_PAYMASTER, + contractName: HookType.INTERCHAIN_GAS_PAYMASTER, + proxyAdmin: this.args.addresses.proxyAdmin, + constructorArgs: [], + initializeArgs: [deployerAddress, config.beneficiary], + }); + + const gasParamsToSet: InterchainGasPaymaster.GasParamStruct[] = []; + for (const [remote, gasOverhead] of Object.entries(config.overhead)) { + // Note: non-EVM remotes actually *are* supported, provided that the remote domain is in the MultiProvider. + // Previously would check core metadata for non EVMs and fallback to multiprovider for custom EVMs + const remoteDomain = this.multiProvider.tryGetDomainId(remote); + if (!remoteDomain) { + this.logger.warn( + `Skipping overhead ${this.chain} -> ${remote}. Expected if the remote is a non-EVM chain.`, + ); + continue; + } + + this.logger.debug( + `Setting gas params for ${this.chain} -> ${remote}: gasOverhead = ${gasOverhead} gasOracle = ${storageGasOracle.address}`, + ); + gasParamsToSet.push({ + remoteDomain, + config: { + gasOverhead, + gasOracle: storageGasOracle.address, + }, + }); + } + + if (gasParamsToSet.length > 0) { + await this.multiProvider.handleTx( + this.chain, + igp.setDestinationGasConfigs(gasParamsToSet, this.txOverrides), + ); + } + + // Transfer igp to the configured owner + await this.multiProvider.handleTx( + this.chain, + igp.transferOwnership(config.owner, this.txOverrides), + ); + + return igp; + } + + protected async deployStorageGasOracle({ + config, + }: { + config: IgpConfig; + }): Promise { + const gasOracle = await this.deployer.deployContract({ + chain: this.chain, + contractKey: 'storageGasOracle', + constructorArgs: [], + }); + + if (!config.oracleConfig) { + this.logger.debug('No oracle config provided, skipping...'); + return gasOracle; + } + + this.logger.info(`Configuring gas oracle from ${this.chain}...`); + const configsToSet: Array = []; + + for (const [remote, desired] of Object.entries(config.oracleConfig)) { + // Note: non-EVM remotes actually *are* supported, provided that the remote domain is in the MultiProvider. + // Previously would check core metadata for non EVMs and fallback to multiprovider for custom EVMs + const remoteDomain = this.multiProvider.tryGetDomainId(remote); + if (!remoteDomain) { + this.logger.warn( + `Skipping gas oracle ${this.chain} -> ${remote}.` + + ' Expected if the remote is a non-EVM chain or the remote domain is not the in the MultiProvider.', + ); + continue; + } + + configsToSet.push({ + remoteDomain, + ...desired, + }); + + // Log an example remote gas cost + const exampleRemoteGas = (config.overhead[remote] ?? 200_000) + 50_000; + const exampleRemoteGasCost = BigNumber.from(desired.tokenExchangeRate) + .mul(desired.gasPrice) + .mul(exampleRemoteGas) + .div(TOKEN_EXCHANGE_RATE_SCALE); + this.logger.info( + `${ + this.chain + } -> ${remote}: ${exampleRemoteGas} remote gas cost: ${ethers.utils.formatEther( + exampleRemoteGasCost, + )}`, + ); + } + + if (configsToSet.length > 0) { + await this.multiProvider.handleTx( + this.chain, + gasOracle.setRemoteGasDataConfigs(configsToSet, this.txOverrides), + ); + } + + // Transfer gas oracle to the configured owner + await this.multiProvider.handleTx( + this.chain, + gasOracle.transferOwnership(config.oracleKey, this.txOverrides), + ); + + return gasOracle; } } diff --git a/typescript/sdk/src/hook/EvmHookReader.test.ts b/typescript/sdk/src/hook/EvmHookReader.test.ts index 77af11b99..5650b12ac 100644 --- a/typescript/sdk/src/hook/EvmHookReader.test.ts +++ b/typescript/sdk/src/hook/EvmHookReader.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { ethers } from 'ethers'; +import { randomBytes } from 'ethers/lib/utils.js'; import sinon from 'sinon'; import { @@ -117,11 +118,13 @@ describe('EvmHookReader', () => { it('should derive pausable config correctly', async () => { const mockAddress = generateRandomAddress(); const mockOwner = generateRandomAddress(); + const mockPaused = randomBytes(1)[0] % 2 === 0; // Mocking the connect method + returned what we need from contract object const mockContract = { hookType: sandbox.stub().resolves(OnchainHookType.PAUSABLE), owner: sandbox.stub().resolves(mockOwner), + paused: sandbox.stub().resolves(mockPaused), }; sandbox .stub(PausableHook__factory, 'connect') @@ -132,6 +135,7 @@ describe('EvmHookReader', () => { const expectedConfig: WithAddress = { owner: mockOwner, + paused: mockPaused, address: mockAddress, type: HookType.PAUSABLE, }; @@ -182,6 +186,27 @@ describe('EvmHookReader', () => { expect(config).to.deep.equal(hookConfig); }); + it('should return an empty config if deriving fails', async () => { + const mockAddress = generateRandomAddress(); + const mockOwner = generateRandomAddress(); + + // Mocking the connect method + returned what we need from contract object + const mockContract = { + // No type + owner: sandbox.stub().resolves(mockOwner), + }; + sandbox + .stub(MerkleTreeHook__factory, 'connect') + .returns(mockContract as unknown as MerkleTreeHook); + sandbox + .stub(IPostDispatchHook__factory, 'connect') + .returns(mockContract as unknown as IPostDispatchHook); + + // top-level method infers hook type + const hookConfig = await evmHookReader.deriveHookConfig(mockAddress); + expect(hookConfig).to.be.undefined; + }); + /* Testing for more nested hook types can be done manually by reading from existing contracts onchain. Examples of nested hook types include: diff --git a/typescript/sdk/src/hook/EvmHookReader.ts b/typescript/sdk/src/hook/EvmHookReader.ts index c5db5165c..b57fa8ede 100644 --- a/typescript/sdk/src/hook/EvmHookReader.ts +++ b/typescript/sdk/src/hook/EvmHookReader.ts @@ -20,6 +20,7 @@ import { assert, concurrentMap, eqAddress, + getLogLevel, rootLogger, } from '@hyperlane-xyz/utils'; @@ -42,10 +43,12 @@ import { RoutingHookConfig, } from './types.js'; -export type DerivedHookConfig = WithAddress; +export type DerivedHookConfig = WithAddress>; export interface HookReader { - deriveHookConfig(address: Address): Promise>; + deriveHookConfig( + address: Address, + ): Promise | undefined>; deriveMerkleTreeConfig( address: Address, ): Promise>; @@ -84,35 +87,51 @@ export class EvmHookReader implements HookReader { this.provider = multiProvider.getProvider(chain); } - async deriveHookConfig(address: Address): Promise { - const hook = IPostDispatchHook__factory.connect(address, this.provider); - const onchainHookType: OnchainHookType = await hook.hookType(); - this.logger.debug('Deriving HookConfig', { address, onchainHookType }); - - switch (onchainHookType) { - case OnchainHookType.ROUTING: - return this.deriveDomainRoutingConfig(address); - case OnchainHookType.AGGREGATION: - return this.deriveAggregationConfig(address); - case OnchainHookType.MERKLE_TREE: - return this.deriveMerkleTreeConfig(address); - case OnchainHookType.INTERCHAIN_GAS_PAYMASTER: - return this.deriveIgpConfig(address); - case OnchainHookType.FALLBACK_ROUTING: - return this.deriveFallbackRoutingConfig(address); - case OnchainHookType.PAUSABLE: - return this.derivePausableConfig(address); - case OnchainHookType.PROTOCOL_FEE: - return this.deriveProtocolFeeConfig(address); - // ID_AUTH_ISM could be OPStackHook, ERC5164Hook or LayerZeroV2Hook - // For now assume it's OP_STACK - case OnchainHookType.ID_AUTH_ISM: - return this.deriveOpStackConfig(address); - default: - throw new Error( - `Unsupported HookType: ${OnchainHookType[onchainHookType]}`, - ); + async deriveHookConfig( + address: Address, + ): Promise { + let onchainHookType = undefined; + try { + const hook = IPostDispatchHook__factory.connect(address, this.provider); + this.logger.debug('Deriving HookConfig', { address }); + + // Temporarily turn off SmartProvider logging + // Provider errors are expected because deriving will call methods that may not exist in the Bytecode + this.setSmartProviderLogLevel('silent'); + onchainHookType = await hook.hookType(); + + switch (onchainHookType) { + case OnchainHookType.ROUTING: + return this.deriveDomainRoutingConfig(address); + case OnchainHookType.AGGREGATION: + return this.deriveAggregationConfig(address); + case OnchainHookType.MERKLE_TREE: + return this.deriveMerkleTreeConfig(address); + case OnchainHookType.INTERCHAIN_GAS_PAYMASTER: + return this.deriveIgpConfig(address); + case OnchainHookType.FALLBACK_ROUTING: + return this.deriveFallbackRoutingConfig(address); + case OnchainHookType.PAUSABLE: + return this.derivePausableConfig(address); + case OnchainHookType.PROTOCOL_FEE: + return this.deriveProtocolFeeConfig(address); + // ID_AUTH_ISM could be OPStackHook, ERC5164Hook or LayerZeroV2Hook + // For now assume it's OP_STACK + case OnchainHookType.ID_AUTH_ISM: + return this.deriveOpStackConfig(address); + default: + throw new Error( + `Unsupported HookType: ${OnchainHookType[onchainHookType]}`, + ); + } + } catch (e) { + this.logger.debug( + `Failed to derive ${onchainHookType} hook (${address}): ${e}`, + ); + } finally { + this.setSmartProviderLogLevel(getLogLevel()); // returns to original level defined by rootLogger } + return undefined; } async deriveMerkleTreeConfig( @@ -134,10 +153,14 @@ export class EvmHookReader implements HookReader { assert((await hook.hookType()) === OnchainHookType.AGGREGATION); const hooks = await hook.hooks(ethers.constants.AddressZero); - const hookConfigs = await concurrentMap( + const hookConfigs: DerivedHookConfig[] = await concurrentMap( this.concurrency, hooks, - async (hook) => this.deriveHookConfig(hook), + async (hook) => { + const hookConfig = await this.deriveHookConfig(hook); + assert(hookConfig, `No hook config found for ${hook}.`); + return hookConfig; + }, ); return { @@ -177,7 +200,10 @@ export class EvmHookReader implements HookReader { const domainGasOverhead = await hook.destinationGasLimit(domainId, 0); overhead[chainName] = domainGasOverhead.toNumber(); - oracleConfig[chainName] = { tokenExchangeRate, gasPrice }; + oracleConfig[chainName] = { + tokenExchangeRate: tokenExchangeRate.toString(), + gasPrice: gasPrice.toString(), + }; const { gasOracle } = await hook.destinationGasConfigs(domainId); const oracle = StorageGasOracle__factory.connect( @@ -292,6 +318,10 @@ export class EvmHookReader implements HookReader { const fallbackHook = await hook.fallbackHook(); const fallbackHookConfig = await this.deriveHookConfig(fallbackHook); + assert( + fallbackHookConfig, + `No fallback hook config found for ${fallbackHook}.`, + ); return { owner, @@ -313,7 +343,9 @@ export class EvmHookReader implements HookReader { try { const domainHook = await hook.hooks(domainId); if (domainHook !== ethers.constants.AddressZero) { - domainHooks[chainName] = await this.deriveHookConfig(domainHook); + const hookConfig = await this.deriveHookConfig(domainHook); + assert(hookConfig, `No hook config found for ${domainHook}.`); + domainHooks[chainName] = hookConfig; } } catch (error) { this.logger.debug( @@ -334,10 +366,24 @@ export class EvmHookReader implements HookReader { assert((await hook.hookType()) === OnchainHookType.PAUSABLE); const owner = await hook.owner(); + const paused = await hook.paused(); return { owner, address, + paused, type: HookType.PAUSABLE, }; } + + /** + * Conditionally sets the log level for a smart provider. + * + * @param level - The log level to set, e.g. 'debug', 'info', 'warn', 'error'. + */ + protected setSmartProviderLogLevel(level: string) { + if ('setLogLevel' in this.provider) { + //@ts-ignore + this.provider.setLogLevel(level); + } + } } diff --git a/typescript/sdk/src/hook/HyperlaneHookDeployer.ts b/typescript/sdk/src/hook/HyperlaneHookDeployer.ts index d1877c930..7980d627a 100644 --- a/typescript/sdk/src/hook/HyperlaneHookDeployer.ts +++ b/typescript/sdk/src/hook/HyperlaneHookDeployer.ts @@ -64,6 +64,10 @@ export class HyperlaneHookDeployer extends HyperlaneDeployer< config: HookConfig, coreAddresses = this.core[chain], ): Promise> { + if (typeof config === 'string') { + throw new Error('Hook deployer should not receive address config'); + } + let hook: DeployedHook; if (config.type === HookType.MERKLE_TREE) { const mailbox = coreAddresses.mailbox; @@ -92,11 +96,9 @@ export class HyperlaneHookDeployer extends HyperlaneDeployer< hook = await this.deployRouting(chain, config, coreAddresses); } else if (config.type === HookType.PAUSABLE) { hook = await this.deployContract(chain, config.type, []); - await this.transferOwnershipOfContracts( - chain, - config, - { [HookType.PAUSABLE]: hook }, - ); + await this.transferOwnershipOfContracts(chain, config, { + [HookType.PAUSABLE]: hook, + }); } else { throw new Error(`Unsupported hook config: ${config}`); } @@ -151,6 +153,11 @@ export class HyperlaneHookDeployer extends HyperlaneDeployer< const aggregatedHooks: string[] = []; let hooks: any = {}; for (const hookConfig of config.hooks) { + if (typeof hookConfig === 'string') { + aggregatedHooks.push(hookConfig); + continue; + } + const subhooks = await this.deployContracts( chain, hookConfig, @@ -159,9 +166,7 @@ export class HyperlaneHookDeployer extends HyperlaneDeployer< aggregatedHooks.push(subhooks[hookConfig.type].address); hooks = { ...hooks, ...subhooks }; } - this.logger.debug( - `Deploying aggregation hook of ${config.hooks.map((h) => h.type)}`, - ); + this.logger.debug(`Deploying aggregation hook of ${config.hooks}`); const address = await this.ismFactory.deployStaticAddressSet( chain, this.ismFactory.getContracts(chain).staticAggregationHookFactory, @@ -275,15 +280,21 @@ export class HyperlaneHookDeployer extends HyperlaneDeployer< } case HookType.FALLBACK_ROUTING: { this.logger.debug('Deploying FallbackDomainRoutingHook for %s', chain); - const fallbackHook = await this.deployContracts( - chain, - config.fallback, - coreAddresses, - ); + let fallbackAddress: Address; + if (typeof config.fallback === 'string') { + fallbackAddress = config.fallback; + } else { + const fallbackHook = await this.deployContracts( + chain, + config.fallback, + coreAddresses, + ); + fallbackAddress = fallbackHook[config.fallback.type].address; + } routingHook = await this.deployContract( chain, HookType.FALLBACK_ROUTING, - [mailbox, deployer, fallbackHook[config.fallback.type].address], + [mailbox, deployer, fallbackAddress], ); break; } diff --git a/typescript/sdk/src/hook/schemas.ts b/typescript/sdk/src/hook/schemas.ts new file mode 100644 index 000000000..d09440b60 --- /dev/null +++ b/typescript/sdk/src/hook/schemas.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; + +import { StorageGasOracleConfigSchema } from '../gas/oracle/types.js'; +import { ZHash } from '../metadata/customZodTypes.js'; +import { OwnableSchema, PausableSchema } from '../schemas.js'; + +import { + AggregationHookConfig, + DomainRoutingHookConfig, + FallbackRoutingHookConfig, + HookType, +} from './types.js'; + +export const ProtocolFeeSchema = OwnableSchema.extend({ + type: z.literal(HookType.PROTOCOL_FEE), + beneficiary: z.string(), + maxProtocolFee: z.string(), + protocolFee: z.string(), +}); + +export const MerkleTreeSchema = z.object({ + type: z.literal(HookType.MERKLE_TREE), +}); + +export const PausableHookSchema = PausableSchema.extend({ + type: z.literal(HookType.PAUSABLE), +}); + +export const OpStackHookSchema = OwnableSchema.extend({ + type: z.literal(HookType.OP_STACK), + nativeBridge: z.string(), + destinationChain: z.string(), +}); + +export const IgpSchema = OwnableSchema.extend({ + type: z.literal(HookType.INTERCHAIN_GAS_PAYMASTER), + beneficiary: z.string(), + oracleKey: z.string(), + overhead: z.record(z.number()), + oracleConfig: z.record(StorageGasOracleConfigSchema), +}); + +export const DomainRoutingHookConfigSchema: z.ZodSchema = + z.lazy(() => + OwnableSchema.extend({ + type: z.literal(HookType.ROUTING), + domains: z.record(HookConfigSchema), + }), + ); + +export const FallbackRoutingHookConfigSchema: z.ZodSchema = + z.lazy(() => + OwnableSchema.extend({ + type: z.literal(HookType.FALLBACK_ROUTING), + domains: z.record(HookConfigSchema), + fallback: HookConfigSchema, + }), + ); + +export const AggregationHookConfigSchema: z.ZodSchema = + z.lazy(() => + z.object({ + type: z.literal(HookType.AGGREGATION), + hooks: z.array(HookConfigSchema), + }), + ); + +export const HookConfigSchema = z.union([ + ZHash, + ProtocolFeeSchema, + PausableHookSchema, + OpStackHookSchema, + MerkleTreeSchema, + IgpSchema, + DomainRoutingHookConfigSchema, + FallbackRoutingHookConfigSchema, + AggregationHookConfigSchema, +]); diff --git a/typescript/sdk/src/hook/types.ts b/typescript/sdk/src/hook/types.ts index 3b5e8f114..c0edaafce 100644 --- a/typescript/sdk/src/hook/types.ts +++ b/typescript/sdk/src/hook/types.ts @@ -1,8 +1,16 @@ -import { Address } from '@hyperlane-xyz/utils'; +import { z } from 'zod'; import { OwnableConfig } from '../deploy/types.js'; -import { IgpConfig } from '../gas/types.js'; -import { ChainMap, ChainName } from '../types.js'; +import { ChainMap } from '../types.js'; + +import { + HookConfigSchema, + IgpSchema, + MerkleTreeSchema, + OpStackHookSchema, + PausableHookSchema, + ProtocolFeeSchema, +} from './schemas.js'; // As found in IPostDispatchHook.sol export enum OnchainHookType { @@ -19,6 +27,7 @@ export enum OnchainHookType { } export enum HookType { + CUSTOM = 'custom', MERKLE_TREE = 'merkleTreeHook', INTERCHAIN_GAS_PAYMASTER = 'interchainGasPaymaster', AGGREGATION = 'aggregationHook', @@ -29,60 +38,26 @@ export enum HookType { PAUSABLE = 'pausableHook', } -export type MerkleTreeHookConfig = { - type: HookType.MERKLE_TREE; -}; +export type MerkleTreeHookConfig = z.infer; +export type IgpHookConfig = z.infer; +export type ProtocolFeeHookConfig = z.infer; +export type PausableHookConfig = z.infer; +export type OpStackHookConfig = z.infer; +// explicitly typed to avoid zod circular dependency export type AggregationHookConfig = { type: HookType.AGGREGATION; hooks: Array; }; - -export type IgpHookConfig = IgpConfig & { - type: HookType.INTERCHAIN_GAS_PAYMASTER; -}; - -export type ProtocolFeeHookConfig = OwnableConfig & { - type: HookType.PROTOCOL_FEE; - maxProtocolFee: string; - protocolFee: string; - beneficiary: Address; -}; - -export type PausableHookConfig = OwnableConfig & { - type: HookType.PAUSABLE; -}; - -export type OpStackHookConfig = OwnableConfig & { - type: HookType.OP_STACK; - nativeBridge: Address; - destinationChain: ChainName; -}; - export type RoutingHookConfig = OwnableConfig & { domains: ChainMap; }; - export type DomainRoutingHookConfig = RoutingHookConfig & { type: HookType.ROUTING; }; - export type FallbackRoutingHookConfig = RoutingHookConfig & { type: HookType.FALLBACK_ROUTING; fallback: HookConfig; }; -export type HookConfig = - | MerkleTreeHookConfig - | AggregationHookConfig - | IgpHookConfig - | ProtocolFeeHookConfig - | OpStackHookConfig - | DomainRoutingHookConfig - | FallbackRoutingHookConfig - | PausableHookConfig; - -export type HooksConfig = { - required: HookConfig; - default: HookConfig; -}; +export type HookConfig = z.infer; diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index 79f780df7..6e679b79b 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -24,6 +24,14 @@ export { testCosmosChain, testSealevelChain, } from './consts/testChains.js'; +export { + AddressesMap, + HyperlaneAddresses, + HyperlaneAddressesMap, + HyperlaneContracts, + HyperlaneContractsMap, + HyperlaneFactories, +} from './contracts/types.js'; export { attachContracts, attachContractsMap, @@ -38,13 +46,14 @@ export { serializeContractsMap, } from './contracts/contracts.js'; export { - AddressesMap, - HyperlaneAddresses, - HyperlaneAddressesMap, - HyperlaneContracts, - HyperlaneContractsMap, - HyperlaneFactories, -} from './contracts/types.js'; + CoreConfig, + CoreViolationType, + DispatchedMessage, + MailboxMultisigIsmViolation, + MailboxViolation, + MailboxViolationType, + ValidatorAnnounceViolation, +} from './core/types.js'; export { HyperlaneCore } from './core/HyperlaneCore.js'; export { HyperlaneCoreChecker } from './core/HyperlaneCoreChecker.js'; export { HyperlaneCoreDeployer } from './core/HyperlaneCoreDeployer.js'; @@ -55,10 +64,10 @@ export { TestRecipientConfig, TestRecipientDeployer, } from './core/TestRecipientDeployer.js'; +export { ICoreAdapter } from './core/adapters/types.js'; export { CosmWasmCoreAdapter } from './core/adapters/CosmWasmCoreAdapter.js'; export { EvmCoreAdapter } from './core/adapters/EvmCoreAdapter.js'; export { SealevelCoreAdapter } from './core/adapters/SealevelCoreAdapter.js'; -export { ICoreAdapter } from './core/adapters/types.js'; export { CoreAddresses, CoreFactories, @@ -66,28 +75,19 @@ export { } from './core/contracts.js'; export { HyperlaneLifecyleEvent } from './core/events.js'; export { EvmCoreReader } from './core/EvmCoreReader.js'; +export { CoreConfigSchema } from './core/schemas.js'; export { - CoreConfig, - CoreViolationType, - DispatchedMessage, - MailboxMultisigIsmViolation, - MailboxViolation, - MailboxViolationType, - ValidatorAnnounceViolation, -} from './core/types.js'; + CheckerViolation, + OwnableConfig, + OwnerViolation, + ViolationType, +} from './deploy/types.js'; export { HyperlaneAppChecker } from './deploy/HyperlaneAppChecker.js'; export { DeployerOptions, HyperlaneDeployer, } from './deploy/HyperlaneDeployer.js'; export { HyperlaneProxyFactoryDeployer } from './deploy/HyperlaneProxyFactoryDeployer.js'; -export { - CheckerViolation, - OwnableConfig, - OwnerViolation, - ViolationType, - resolveOrDeployAccountOwner, -} from './deploy/types.js'; export { ContractVerifier } from './deploy/verify/ContractVerifier.js'; export { PostDeploymentContractVerifier } from './deploy/verify/PostDeploymentContractVerifier.js'; export { @@ -98,6 +98,14 @@ export { VerificationInput, } from './deploy/verify/types.js'; export * as verificationUtils from './deploy/verify/utils.js'; +export { + IgpBeneficiaryViolation, + IgpConfig, + IgpGasOraclesViolation, + IgpOverheadViolation, + IgpViolation, + IgpViolationType, +} from './gas/types.js'; export { HyperlaneIgp } from './gas/HyperlaneIgp.js'; export { HyperlaneIgpChecker } from './gas/HyperlaneIgpChecker.js'; export { HyperlaneIgpDeployer } from './gas/HyperlaneIgpDeployer.js'; @@ -110,40 +118,23 @@ export { SealevelOverheadIgpDataSchema, } from './gas/adapters/serialization.js'; export { IgpFactories, igpFactories } from './gas/contracts.js'; -export { - GasOracleContractType, - StorageGasOracleConfig, -} from './gas/oracle/types.js'; +export { StorageGasOracleConfig } from './gas/oracle/types.js'; export { CoinGeckoTokenPriceGetter } from './gas/token-prices.js'; -export { - IgpBeneficiaryViolation, - IgpConfig, - IgpGasOraclesViolation, - IgpOverheadViolation, - IgpViolation, - IgpViolationType, -} from './gas/types.js'; -export { HyperlaneHookDeployer } from './hook/HyperlaneHookDeployer.js'; -export { EvmHookReader } from './hook/EvmHookReader.js'; export { AggregationHookConfig, DomainRoutingHookConfig, FallbackRoutingHookConfig, HookConfig, HookType, - HooksConfig, IgpHookConfig, MerkleTreeHookConfig, OpStackHookConfig, PausableHookConfig, ProtocolFeeHookConfig, } from './hook/types.js'; -export { HyperlaneIsmFactory } from './ism/HyperlaneIsmFactory.js'; -export { - buildAggregationIsmConfigs, - buildMultisigIsmConfigs, -} from './ism/multisig.js'; -export { EvmIsmReader } from './ism/EvmIsmReader.js'; +export { HookConfigSchema } from './hook/schemas.js'; +export { HyperlaneHookDeployer } from './hook/HyperlaneHookDeployer.js'; +export { EvmHookReader } from './hook/EvmHookReader.js'; export { AggregationIsmConfig, DeployedIsm, @@ -155,8 +146,32 @@ export { OpStackIsmConfig, PausableIsmConfig, RoutingIsmConfig, + TrustedRelayerIsmConfig, } from './ism/types.js'; +export { HyperlaneIsmFactory } from './ism/HyperlaneIsmFactory.js'; +export { + buildAggregationIsmConfigs, + buildMultisigIsmConfigs, +} from './ism/multisig.js'; +export { EvmIsmReader } from './ism/EvmIsmReader.js'; export { collectValidators, moduleCanCertainlyVerify } from './ism/utils.js'; +export { ZChainName, ZHash } from './metadata/customZodTypes.js'; +export { + BlockExplorer, + ChainMetadata, + ChainMetadataSchema, + ChainMetadataSchemaObject, + ChainTechnicalStack, + ExplorerFamily, + ExplorerFamilyValue, + NativeToken, + RpcUrl, + RpcUrlSchema, + getChainIdNumber, + getDomainId, + getReorgPeriod, + isValidChainMetadata, +} from './metadata/chainMetadataTypes.js'; export { ChainMetadataManager, ChainMetadataManagerOptions, @@ -182,23 +197,6 @@ export { ValidatorConfig, buildAgentConfig, } from './metadata/agentConfig.js'; -export { - BlockExplorer, - ChainMetadata, - ChainMetadataSchema, - ChainMetadataSchemaObject, - ChainTechnicalStack, - ExplorerFamily, - ExplorerFamilyValue, - NativeToken, - RpcUrl, - RpcUrlSchema, - getChainIdNumber, - getDomainId, - getReorgPeriod, - isValidChainMetadata, -} from './metadata/chainMetadataTypes.js'; -export { ZHash } from './metadata/customZodTypes.js'; export { HyperlaneDeploymentArtifacts, HyperlaneDeploymentArtifactsSchema, @@ -208,6 +206,14 @@ export { WarpRouteConfig, WarpRouteConfigSchema, } from './metadata/warpRouteConfig.js'; +export { + AccountConfigSchema, + GetCallRemoteSettingsSchema, +} from './middleware/account/schemas.js'; +export { + AccountConfig, + GetCallRemoteSettings, +} from './middleware/account/types.js'; export { InterchainAccount } from './middleware/account/InterchainAccount.js'; export { InterchainAccountChecker } from './middleware/account/InterchainAccountChecker.js'; export { @@ -218,7 +224,6 @@ export { InterchainAccountFactories, interchainAccountFactories, } from './middleware/account/contracts.js'; -export { AccountConfig } from './middleware/account/types.js'; export { LiquidityLayerApp } from './middleware/liquidity-layer/LiquidityLayerApp.js'; export { BridgeAdapterConfig, @@ -272,15 +277,6 @@ export { ViemTransaction, ViemTransactionReceipt, } from './providers/ProviderType.js'; -export { HyperlaneEtherscanProvider } from './providers/SmartProvider/HyperlaneEtherscanProvider.js'; -export { HyperlaneJsonRpcProvider } from './providers/SmartProvider/HyperlaneJsonRpcProvider.js'; -export { - AllProviderMethods, - IProviderMethods, - ProviderMethod, - excludeProviderMethods, -} from './providers/SmartProvider/ProviderMethods.js'; -export { HyperlaneSmartProvider } from './providers/SmartProvider/SmartProvider.js'; export { ChainMetadataWithRpcConnectionInfo, ProviderErrorResult, @@ -291,6 +287,15 @@ export { ProviderTimeoutResult, SmartProviderOptions, } from './providers/SmartProvider/types.js'; +export { HyperlaneEtherscanProvider } from './providers/SmartProvider/HyperlaneEtherscanProvider.js'; +export { HyperlaneJsonRpcProvider } from './providers/SmartProvider/HyperlaneJsonRpcProvider.js'; +export { + AllProviderMethods, + IProviderMethods, + ProviderMethod, + excludeProviderMethods, +} from './providers/SmartProvider/ProviderMethods.js'; +export { HyperlaneSmartProvider } from './providers/SmartProvider/SmartProvider.js'; export { ProviderBuilderFn, ProviderBuilderMap, @@ -303,22 +308,59 @@ export { defaultViemProviderBuilder, protocolToDefaultProviderBuilder, } from './providers/providerBuilders.js'; -export { TxSubmitterInterface } from './providers/transactions/submitter/TxSubmitterInterface.js'; +export { PopulatedTransactionSchema } from './providers/transactions/schemas.js'; +export { + CallData, + PopulatedTransaction, +} from './providers/transactions/types.js'; + export { TxSubmitterType } from './providers/transactions/submitter/TxSubmitterTypes.js'; +export { SubmitterMetadataSchema } from './providers/transactions/submitter/schemas.js'; +export { SubmitterMetadata } from './providers/transactions/submitter/types.js'; +export { TxSubmitterInterface } from './providers/transactions/submitter/TxSubmitterInterface.js'; + +export { + EV5GnosisSafeTxSubmitterPropsSchema, + EV5ImpersonatedAccountTxSubmitterPropsSchema, +} from './providers/transactions/submitter/ethersV5/schemas.js'; export { EV5GnosisSafeTxSubmitterProps, EV5ImpersonatedAccountTxSubmitterProps, -} from './providers/transactions/submitter/ethersV5/EV5TxSubmitterTypes.js'; +} from './providers/transactions/submitter/ethersV5/types.js'; + +export { SubmissionStrategySchema } from './providers/transactions/submitter/builder/schemas.js'; +export { SubmissionStrategy } from './providers/transactions/submitter/builder/types.js'; export { TxSubmitterBuilder } from './providers/transactions/submitter/builder/TxSubmitterBuilder.js'; + export { EV5GnosisSafeTxSubmitter } from './providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.js'; export { EV5ImpersonatedAccountTxSubmitter } from './providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.js'; export { EV5JsonRpcTxSubmitter } from './providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.js'; export { EV5TxSubmitterInterface } from './providers/transactions/submitter/ethersV5/EV5TxSubmitterInterface.js'; -export { TxTransformerInterface } from './providers/transactions/transformer/TxTransformerInterface.js'; + export { TxTransformerType } from './providers/transactions/transformer/TxTransformerTypes.js'; -export { EV5InterchainAccountTxTransformerProps } from './providers/transactions/transformer/ethersV5/EV5TxTransformerTypes.js'; +export { TransformerMetadataSchema } from './providers/transactions/transformer/schemas.js'; +export { TransformerMetadata } from './providers/transactions/transformer/types.js'; +export { TxTransformerInterface } from './providers/transactions/transformer/TxTransformerInterface.js'; + +export { EV5InterchainAccountTxTransformerPropsSchema } from './providers/transactions/transformer/ethersV5/schemas.js'; +export { EV5InterchainAccountTxTransformerProps } from './providers/transactions/transformer/ethersV5/types.js'; export { EV5InterchainAccountTxTransformer } from './providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.js'; export { EV5TxTransformerInterface } from './providers/transactions/transformer/ethersV5/EV5TxTransformerInterface.js'; + +export { + MailboxClientConfig as ConnectionClientConfig, + ClientViolation as ConnectionClientViolation, + ClientViolationType as ConnectionClientViolationType, + GasRouterConfig, + MailboxClientConfig, + ProxiedFactories, + ProxiedRouterConfig, + RouterAddress, + RouterConfig, + RouterViolation, + RouterViolationType, + proxiedFactories, +} from './router/types.js'; export { GasRouterDeployer } from './router/GasRouterDeployer.js'; export { HyperlaneRouterChecker } from './router/HyperlaneRouterChecker.js'; export { HyperlaneRouterDeployer } from './router/HyperlaneRouterDeployer.js'; @@ -327,6 +369,7 @@ export { MultiProtocolRouterApp, } from './router/MultiProtocolRouterApps.js'; export { GasRouterApp, RouterApp } from './router/RouterApps.js'; +export { IGasRouterAdapter, IRouterAdapter } from './router/adapters/types.js'; export { EvmGasRouterAdapter, EvmRouterAdapter, @@ -335,21 +378,6 @@ export { SealevelGasRouterAdapter, SealevelRouterAdapter, } from './router/adapters/SealevelRouterAdapter.js'; -export { IGasRouterAdapter, IRouterAdapter } from './router/adapters/types.js'; -export { - MailboxClientConfig as ConnectionClientConfig, - ClientViolation as ConnectionClientViolation, - ClientViolationType as ConnectionClientViolationType, - GasRouterConfig, - MailboxClientConfig, - ProxiedFactories, - ProxiedRouterConfig, - RouterAddress, - RouterConfig, - RouterViolation, - RouterViolationType, - proxiedFactories, -} from './router/types.js'; export { IToken, TokenArgs, TokenConfigSchema } from './token/IToken.js'; export { Token } from './token/Token.js'; export { TokenAmount } from './token/TokenAmount.js'; @@ -419,11 +447,13 @@ export { HypERC20App } from './token/app.js'; export { HypERC20Checker } from './token/checker.js'; export { TokenType } from './token/config.js'; export { + hypERC20factories, HypERC20Factories, HypERC721Factories, TokenFactories, } from './token/contracts.js'; export { HypERC20Deployer, HypERC721Deployer } from './token/deploy.js'; +export { EvmERC20WarpRouteReader } from './token/EvmERC20WarpRouteReader.js'; export { ChainMap, ChainName, ChainNameOrId, Connection } from './types.js'; export { MultiGeneric } from './utils/MultiGeneric.js'; export { getCosmosRegistryChain } from './utils/cosmos.js'; @@ -454,7 +484,7 @@ export { WarpTypedTransaction, } from './warp/types.js'; -export { AggregationIsmConfigSchema } from './ism/schemas.js'; +export { AggregationIsmConfigSchema, IsmConfigSchema } from './ism/schemas.js'; export { MailboxClientConfigSchema as mailboxClientConfigSchema } from './router/schemas.js'; export { WarpRouteDeployConfigSchema, @@ -474,3 +504,7 @@ export { S3Config, S3Wrapper, S3Receipt } from './aws/s3.js'; // prettier-ignore // @ts-ignore export { canProposeSafeTransactions, getSafe, getSafeDelegates, getSafeService } from './utils/gnosisSafe.js'; + +export { EvmCoreModule, DeployedCoreAdresses } from './core/EvmCoreModule.js'; +export { EvmERC20WarpModule } from './token/EvmERC20WarpModule.js'; +export { EvmIsmModule } from './ism/EvmIsmModule.js'; diff --git a/typescript/sdk/src/ism/EvmIsmCreator.ts b/typescript/sdk/src/ism/EvmIsmCreator.ts deleted file mode 100644 index 2b95e3c3d..000000000 --- a/typescript/sdk/src/ism/EvmIsmCreator.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { ethers } from 'ethers'; -import { Logger } from 'pino'; - -import { - DefaultFallbackRoutingIsm, - DefaultFallbackRoutingIsm__factory, - DomainRoutingIsm, - DomainRoutingIsm__factory, - IAggregationIsm, - IAggregationIsm__factory, - IInterchainSecurityModule__factory, - IMultisigIsm, - IMultisigIsm__factory, - IRoutingIsm, - OPStackIsm__factory, - PausableIsm__factory, - StaticAddressSetFactory, - StaticThresholdAddressSetFactory, - TestIsm__factory, - TrustedRelayerIsm__factory, -} from '@hyperlane-xyz/core'; -import { - Address, - Domain, - assert, - eqAddress, - objFilter, - rootLogger, -} from '@hyperlane-xyz/utils'; - -import { HyperlaneContracts } from '../contracts/types.js'; -import { HyperlaneDeployer } from '../deploy/HyperlaneDeployer.js'; -import { ProxyFactoryFactories } from '../deploy/contracts.js'; -import { resolveOrDeployAccountOwner } from '../deploy/types.js'; -import { MultiProvider } from '../providers/MultiProvider.js'; -import { ChainMap, ChainName } from '../types.js'; - -import { - AggregationIsmConfig, - DeployedIsm, - DeployedIsmType, - IsmConfig, - IsmType, - MultisigIsmConfig, - RoutingIsmConfig, - RoutingIsmDelta, -} from './types.js'; -import { routingModuleDelta } from './utils.js'; - -export class EvmIsmCreator { - protected readonly logger = rootLogger.child({ module: 'EvmIsmCreator' }); - - constructor( - protected readonly deployer: HyperlaneDeployer, - protected readonly multiProvider: MultiProvider, - protected readonly factories: HyperlaneContracts, - ) {} - - async update(params: { - destination: ChainName; - config: C; - origin?: ChainName; - mailbox?: Address; - existingIsmAddress: Address; - }): Promise { - const { destination, config, origin, mailbox, existingIsmAddress } = params; - if (typeof config === 'string') { - // @ts-ignore - return IInterchainSecurityModule__factory.connect( - config, - this.multiProvider.getSignerOrProvider(destination), - ); - } - - const ismType = config.type; - const logger = this.logger.child({ destination, ismType }); - - logger.debug( - `Updating ${ismType} on ${destination} ${ - origin ? `(for verifying ${origin})` : '' - }`, - ); - - let contract: DeployedIsmType[typeof ismType]; - switch (ismType) { - case IsmType.ROUTING: - case IsmType.FALLBACK_ROUTING: - contract = await this.updateRoutingIsm({ - destination, - config, - origin, - mailbox, - existingIsmAddress, - logger, - }); - break; - default: - return this.deploy(params); // TODO: tidy-up update in follow-up PR - } - - return contract; - } - - async deploy(params: { - destination: ChainName; - config: C; - origin?: ChainName; - mailbox?: Address; - }): Promise { - const { destination, config, origin, mailbox } = params; - if (typeof config === 'string') { - // @ts-ignore - return IInterchainSecurityModule__factory.connect( - config, - this.multiProvider.getSignerOrProvider(destination), - ); - } - - const ismType = config.type; - const logger = this.logger.child({ destination, ismType }); - - logger.debug( - `Deploying ${ismType} to ${destination} ${ - origin ? `(for verifying ${origin})` : '' - }`, - ); - - let contract: DeployedIsmType[typeof ismType]; - switch (ismType) { - case IsmType.MESSAGE_ID_MULTISIG: - case IsmType.MERKLE_ROOT_MULTISIG: - contract = await this.deployMultisigIsm(destination, config, logger); - break; - case IsmType.ROUTING: - case IsmType.FALLBACK_ROUTING: - contract = await this.deployRoutingIsm({ - destination, - config, - origin, - mailbox, - logger, - }); - break; - case IsmType.AGGREGATION: - contract = await this.deployAggregationIsm({ - destination, - config, - origin, - mailbox, - logger, - }); - break; - case IsmType.OP_STACK: - assert( - this.deployer, - `HyperlaneDeployer must be set to deploy ${ismType}`, - ); - contract = await this.deployer.deployContractFromFactory( - destination, - new OPStackIsm__factory(), - IsmType.OP_STACK, - [config.nativeBridge], - ); - break; - case IsmType.PAUSABLE: - assert( - this.deployer, - `HyperlaneDeployer must be set to deploy ${ismType}`, - ); - contract = await this.deployer.deployContractFromFactory( - destination, - new PausableIsm__factory(), - IsmType.PAUSABLE, - [ - await resolveOrDeployAccountOwner( - this.multiProvider, - destination, - config.owner, - ), - ], - ); - await this.deployer.transferOwnershipOfContracts(destination, config, { - [IsmType.PAUSABLE]: contract, - }); - break; - case IsmType.TRUSTED_RELAYER: - assert( - this.deployer, - `HyperlaneDeployer must be set to deploy ${ismType}`, - ); - assert(mailbox, `Mailbox address is required for deploying ${ismType}`); - contract = await this.deployer.deployContractFromFactory( - destination, - new TrustedRelayerIsm__factory(), - IsmType.TRUSTED_RELAYER, - [mailbox, config.relayer], - ); - break; - case IsmType.TEST_ISM: - if (!this.deployer) { - throw new Error(`HyperlaneDeployer must be set to deploy ${ismType}`); - } - contract = await this.deployer.deployContractFromFactory( - destination, - new TestIsm__factory(), - IsmType.TEST_ISM, - [], - ); - break; - default: - throw new Error(`Unsupported ISM type ${ismType}`); - } - - return contract; - } - - protected async deployMultisigIsm( - destination: ChainName, - config: MultisigIsmConfig, - logger: Logger, - ): Promise { - const signer = this.multiProvider.getSigner(destination); - const multisigIsmFactory = - config.type === IsmType.MERKLE_ROOT_MULTISIG - ? this.factories.staticMerkleRootMultisigIsmFactory - : this.factories.staticMessageIdMultisigIsmFactory; - - const address = await this.deployStaticAddressSet( - destination, - multisigIsmFactory, - config.validators, - logger, - config.threshold, - ); - - return IMultisigIsm__factory.connect(address, signer); - } - - protected async updateRoutingIsm(params: { - destination: ChainName; - config: RoutingIsmConfig; - origin?: ChainName; - mailbox?: Address; - existingIsmAddress: Address; - logger: Logger; - }): Promise { - const { destination, config, mailbox, existingIsmAddress, logger } = params; - const overrides = this.multiProvider.getTransactionOverrides(destination); - let routingIsm: DomainRoutingIsm | DefaultFallbackRoutingIsm; - - // filtering out domains which are not part of the multiprovider - config.domains = objFilter( - config.domains, - (domain, config): config is IsmConfig => { - const domainId = this.multiProvider.tryGetDomainId(domain); - if (domainId === null) { - logger.warn( - `Domain ${domain} doesn't have chain metadata provided, skipping ...`, - ); - } - return domainId !== null; - }, - ); - - const safeConfigDomains = Object.keys(config.domains).map((domain) => - this.multiProvider.getDomainId(domain), - ); - - const delta: RoutingIsmDelta = existingIsmAddress - ? await routingModuleDelta( - destination, - existingIsmAddress, - config, - this.multiProvider, - this.factories, - mailbox, - ) - : { - domainsToUnenroll: [], - domainsToEnroll: safeConfigDomains, - }; - - const signer = this.multiProvider.getSigner(destination); - const provider = this.multiProvider.getProvider(destination); - const owner = await DomainRoutingIsm__factory.connect( - existingIsmAddress, - provider, - ).owner(); - const isOwner = eqAddress(await signer.getAddress(), owner); - - // reconfiguring existing routing ISM - if (existingIsmAddress && isOwner && !delta.mailbox) { - const isms: Record = {}; - routingIsm = DomainRoutingIsm__factory.connect( - existingIsmAddress, - this.multiProvider.getSigner(destination), - ); - // deploying all the ISMs which have to be updated - for (const originDomain of delta.domainsToEnroll) { - const origin = this.multiProvider.getChainName(originDomain); // already filtered to only include domains in the multiprovider - logger.debug( - `Reconfiguring preexisting routing ISM at for origin ${origin}...`, - ); - const ism = await this.deploy({ - destination, - config: config.domains[origin], - origin, - mailbox, - }); - isms[originDomain] = ism.address; - const tx = await routingIsm.set( - originDomain, - isms[originDomain], - overrides, - ); - await this.multiProvider.handleTx(destination, tx); - } - // unenrolling domains if needed - for (const originDomain of delta.domainsToUnenroll) { - logger.debug( - `Unenrolling originDomain ${originDomain} from preexisting routing ISM at ${existingIsmAddress}...`, - ); - const tx = await routingIsm.remove(originDomain, overrides); - await this.multiProvider.handleTx(destination, tx); - } - // transfer ownership if needed - if (delta.owner) { - logger.debug(`Transferring ownership of routing ISM...`); - const tx = await routingIsm.transferOwnership(delta.owner, overrides); - await this.multiProvider.handleTx(destination, tx); - } - } else { - const isms: ChainMap
= {}; - const owner = await resolveOrDeployAccountOwner( - this.multiProvider, - destination, - config.owner, - ); - - for (const origin of Object.keys(config.domains)) { - const ism = await this.deploy({ - destination, - config: config.domains[origin], - origin, - mailbox, - }); - isms[origin] = ism.address; - } - const submoduleAddresses = Object.values(isms); - - if (config.type === IsmType.FALLBACK_ROUTING) { - // deploying new fallback routing ISM - if (!mailbox) { - throw new Error( - 'Mailbox address is required for deploying fallback routing ISM', - ); - } - - // connect to existing ISM - routingIsm = DefaultFallbackRoutingIsm__factory.connect( - existingIsmAddress, - signer, - ); - - // update ISM with config - logger.debug('Initialising fallback routing ISM ...'); - await this.multiProvider.handleTx( - destination, - routingIsm['initialize(address,uint32[],address[])']( - owner, - safeConfigDomains, - submoduleAddresses, - overrides, - ), - ); - } else { - routingIsm = await this.deployDomainRoutingIsm({ - destination, - owner, - safeConfigDomains, - submoduleAddresses, - overrides, - }); - } - } - return routingIsm; - } - - protected async deployRoutingIsm(params: { - destination: ChainName; - config: RoutingIsmConfig; - origin?: ChainName; - mailbox?: Address; - logger: Logger; - }): Promise { - const { destination, config, mailbox, logger } = params; - const overrides = this.multiProvider.getTransactionOverrides(destination); - let routingIsm: DomainRoutingIsm | DefaultFallbackRoutingIsm; - - // filtering out domains which are not part of the multiprovider - config.domains = objFilter( - config.domains, - (domain, config): config is IsmConfig => { - const domainId = this.multiProvider.tryGetDomainId(domain); - if (domainId === null) { - logger.warn( - `Domain ${domain} doesn't have chain metadata provided, skipping ...`, - ); - } - return domainId !== null; - }, - ); - - const safeConfigDomains = Object.keys(config.domains).map((domain) => - this.multiProvider.getDomainId(domain), - ); - - const isms: ChainMap
= {}; - const owner = await resolveOrDeployAccountOwner( - this.multiProvider, - destination, - config.owner, - ); - - for (const origin of Object.keys(config.domains)) { - const ism = await this.deploy({ - destination, - config: config.domains[origin], - origin, - mailbox, - }); - isms[origin] = ism.address; - } - - const submoduleAddresses = Object.values(isms); - - if (config.type === IsmType.FALLBACK_ROUTING) { - // deploying new fallback routing ISM - if (!mailbox) { - throw new Error( - 'Mailbox address is required for deploying fallback routing ISM', - ); - } - logger.debug('Deploying fallback routing ISM ...'); - routingIsm = await this.multiProvider.handleDeploy( - destination, - new DefaultFallbackRoutingIsm__factory(), - [mailbox], - ); - } else { - routingIsm = await this.deployDomainRoutingIsm({ - destination, - owner, - safeConfigDomains, - submoduleAddresses, - overrides, - }); - } - - return routingIsm; - } - - protected async deployDomainRoutingIsm(params: { - destination: ChainName; - owner: string; - safeConfigDomains: number[]; - submoduleAddresses: string[]; - overrides: ethers.Overrides; - }): Promise { - const { - destination, - owner, - safeConfigDomains, - submoduleAddresses, - overrides, - } = params; - - // deploying new domain routing ISM - const tx = await this.factories.domainRoutingIsmFactory.deploy( - owner, - safeConfigDomains, - submoduleAddresses, - overrides, - ); - - const receipt = await this.multiProvider.handleTx(destination, tx); - - // TODO: Break this out into a generalized function - const dispatchLogs = receipt.logs - .map((log) => { - try { - return this.factories.domainRoutingIsmFactory.interface.parseLog(log); - } catch (e) { - return undefined; - } - }) - .filter( - (log): log is ethers.utils.LogDescription => - !!log && log.name === 'ModuleDeployed', - ); - if (dispatchLogs.length === 0) { - throw new Error('No ModuleDeployed event found'); - } - const moduleAddress = dispatchLogs[0].args['module']; - return DomainRoutingIsm__factory.connect( - moduleAddress, - this.multiProvider.getSigner(destination), - ); - } - - protected async deployAggregationIsm(params: { - destination: ChainName; - config: AggregationIsmConfig; - origin?: ChainName; - mailbox?: Address; - logger: Logger; - }): Promise { - const { destination, config, origin, mailbox } = params; - const signer = this.multiProvider.getSigner(destination); - const staticAggregationIsmFactory = - this.factories.staticAggregationIsmFactory; - const addresses: Address[] = []; - for (const module of config.modules) { - const submodule = await this.deploy({ - destination, - config: module, - origin, - mailbox, - }); - addresses.push(submodule.address); - } - const address = await this.deployStaticAddressSet( - destination, - staticAggregationIsmFactory, - addresses, - params.logger, - config.threshold, - ); - return IAggregationIsm__factory.connect(address, signer); - } - - async deployStaticAddressSet( - chain: ChainName, - factory: StaticThresholdAddressSetFactory | StaticAddressSetFactory, - values: Address[], - logger: Logger, - threshold = values.length, - ): Promise
{ - const sorted = [...values].sort(); - - const address = await factory['getAddress(address[],uint8)']( - sorted, - threshold, - ); - const code = await this.multiProvider.getProvider(chain).getCode(address); - if (code === '0x') { - logger.debug( - `Deploying new ${threshold} of ${values.length} address set to ${chain}`, - ); - const overrides = this.multiProvider.getTransactionOverrides(chain); - const hash = await factory['deploy(address[],uint8)']( - sorted, - threshold, - overrides, - ); - await this.multiProvider.handleTx(chain, hash); - // TODO: add proxy verification artifact? - } else { - logger.debug( - `Recovered ${threshold} of ${values.length} address set on ${chain}: ${address}`, - ); - } - return address; - } -} diff --git a/typescript/sdk/src/ism/EvmIsmModule.hardhat-test.ts b/typescript/sdk/src/ism/EvmIsmModule.hardhat-test.ts new file mode 100644 index 000000000..2bb1dd27d --- /dev/null +++ b/typescript/sdk/src/ism/EvmIsmModule.hardhat-test.ts @@ -0,0 +1,436 @@ +/* eslint-disable no-console */ +import assert from 'assert'; +import { expect } from 'chai'; +import { Signer } from 'ethers'; +import hre from 'hardhat'; + +import { FallbackDomainRoutingHook__factory } from '@hyperlane-xyz/core'; +import { Address, eqAddress, normalizeConfig } from '@hyperlane-xyz/utils'; + +import { TestChainName, testChains } from '../consts/testChains.js'; +import { HyperlaneAddresses, HyperlaneContracts } from '../contracts/types.js'; +import { TestCoreDeployer } from '../core/TestCoreDeployer.js'; +import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js'; +import { ProxyFactoryFactories } from '../deploy/contracts.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { randomAddress, randomInt } from '../test/testUtils.js'; + +import { EvmIsmModule } from './EvmIsmModule.js'; +import { HyperlaneIsmFactory } from './HyperlaneIsmFactory.js'; +import { + AggregationIsmConfig, + IsmConfig, + IsmType, + ModuleType, + MultisigIsmConfig, + RoutingIsmConfig, + TrustedRelayerIsmConfig, +} from './types.js'; + +const randomMultisigIsmConfig = (m: number, n: number): MultisigIsmConfig => { + const emptyArray = new Array(n).fill(0); + const validators = emptyArray.map(() => randomAddress()); + return { + type: IsmType.MERKLE_ROOT_MULTISIG, + validators, + threshold: m, + }; +}; + +function randomModuleType(): ModuleType { + const choices = [ + ModuleType.AGGREGATION, + ModuleType.MERKLE_ROOT_MULTISIG, + ModuleType.ROUTING, + ModuleType.NULL, + ]; + return choices[randomInt(choices.length)]; +} + +const randomIsmConfig = (depth = 0, maxDepth = 2): IsmConfig => { + const moduleType = + depth == maxDepth ? ModuleType.MERKLE_ROOT_MULTISIG : randomModuleType(); + switch (moduleType) { + case ModuleType.MERKLE_ROOT_MULTISIG: { + const n = randomInt(5, 1); + return randomMultisigIsmConfig(randomInt(n, 1), n); + } + case ModuleType.ROUTING: { + const config: RoutingIsmConfig = { + type: IsmType.ROUTING, + owner: randomAddress(), + domains: Object.fromEntries( + testChains.map((c) => [c, randomIsmConfig(depth + 1)]), + ), + }; + return config; + } + case ModuleType.AGGREGATION: { + const n = randomInt(5, 1); + const modules = new Array(n) + .fill(0) + .map(() => randomIsmConfig(depth + 1)); + const config: AggregationIsmConfig = { + type: IsmType.AGGREGATION, + threshold: randomInt(n, 1), + modules, + }; + return config; + } + case ModuleType.NULL: { + const config: TrustedRelayerIsmConfig = { + type: IsmType.TRUSTED_RELAYER, + relayer: randomAddress(), + }; + return config; + } + default: + throw new Error(`Unsupported ISM type: ${moduleType}`); + } +}; + +describe('EvmIsmModule', async () => { + let multiProvider: MultiProvider; + let exampleRoutingConfig: RoutingIsmConfig; + let mailboxAddress: Address; + let newMailboxAddress: Address; + let fundingAccount: Signer; + + const chain = TestChainName.test4; + let factoryAddresses: HyperlaneAddresses; + let factoryContracts: HyperlaneContracts; + + beforeEach(async () => { + const [signer, funder] = await hre.ethers.getSigners(); + fundingAccount = funder; + multiProvider = MultiProvider.createTestMultiProvider({ signer }); + + const contractsMap = await new HyperlaneProxyFactoryDeployer( + multiProvider, + ).deploy(multiProvider.mapKnownChains(() => ({}))); + + // get addresses of factories for the chain + factoryContracts = contractsMap[chain]; + factoryAddresses = Object.keys(factoryContracts).reduce((acc, key) => { + acc[key] = + contractsMap[chain][key as keyof ProxyFactoryFactories].address; + return acc; + }, {} as Record) as HyperlaneAddresses; + + // legacy HyperlaneIsmFactory is required to do a core deploy + const legacyIsmFactory = new HyperlaneIsmFactory( + contractsMap, + multiProvider, + ); + + // mailbox + mailboxAddress = ( + await new TestCoreDeployer(multiProvider, legacyIsmFactory).deployApp() + ).getContracts(chain).mailbox.address; + + // new mailbox + newMailboxAddress = ( + await new TestCoreDeployer(multiProvider, legacyIsmFactory).deployApp() + ).getContracts(chain).mailbox.address; + + // example routing config + exampleRoutingConfig = { + type: IsmType.ROUTING, + owner: await multiProvider.getSignerAddress(chain), + domains: Object.fromEntries( + testChains + .filter((c) => c !== TestChainName.test4) + .map((c) => [c, randomMultisigIsmConfig(3, 5)]), + ), + }; + }); + + // Helper method for create a new multiprovider with an impersonated account + async function impersonateAccount(account: Address): Promise { + await hre.ethers.provider.send('hardhat_impersonateAccount', [account]); + await fundingAccount.sendTransaction({ + to: account, + value: hre.ethers.utils.parseEther('1.0'), + }); + return MultiProvider.createTestMultiProvider({ + signer: hre.ethers.provider.getSigner(account), + }); + } + + // Helper method to expect exactly N updates to be applied + async function expectTxsAndUpdate( + ism: EvmIsmModule, + config: IsmConfig, + n: number, + ) { + const txs = await ism.update(config); + expect(txs.length).to.equal(n); + + for (const tx of txs) { + await multiProvider.sendTransaction(chain, tx); + } + } + + // ism module and config for testing + let testIsm: EvmIsmModule; + let testConfig: IsmConfig; + + // expect that the ISM matches the config after all tests + afterEach(async () => { + const normalizedDerivedConfig = normalizeConfig(await testIsm.read()); + const normalizedConfig = normalizeConfig(testConfig); + assert.deepStrictEqual(normalizedDerivedConfig, normalizedConfig); + }); + + // create a new ISM and verify that it matches the config + async function createIsm( + config: IsmConfig, + ): Promise<{ ism: EvmIsmModule; initialIsmAddress: Address }> { + const ism = await EvmIsmModule.create({ + chain, + config, + proxyFactoryFactories: factoryAddresses, + mailbox: mailboxAddress, + multiProvider, + }); + testIsm = ism; + testConfig = config; + return { ism, initialIsmAddress: ism.serialize().deployedIsm }; + } + + describe('create', async () => { + it('deploys a simple ism', async () => { + const config = randomMultisigIsmConfig(3, 5); + await createIsm(config); + }); + + it('deploys a trusted relayer ism', async () => { + const relayer = randomAddress(); + const config: TrustedRelayerIsmConfig = { + type: IsmType.TRUSTED_RELAYER, + relayer, + }; + await createIsm(config); + }); + + for (const type of [IsmType.ROUTING, IsmType.FALLBACK_ROUTING]) { + it(`deploys ${type} routingIsm with correct routes`, async () => { + exampleRoutingConfig.type = type as + | IsmType.ROUTING + | IsmType.FALLBACK_ROUTING; + await createIsm(exampleRoutingConfig); + }); + } + + for (let i = 0; i < 16; i++) { + it(`deploys a random ism config #${i}`, async () => { + const config = randomIsmConfig(); + await createIsm(config); + }); + } + }); + + describe('update', async () => { + for (const type of [IsmType.ROUTING, IsmType.FALLBACK_ROUTING]) { + beforeEach(() => { + exampleRoutingConfig.type = type as + | IsmType.ROUTING + | IsmType.FALLBACK_ROUTING; + }); + + it(`should skip deployment with warning if no chain metadata configured ${type}`, async () => { + // create a new ISM + const { ism } = await createIsm(exampleRoutingConfig); + + // add config for a domain the multiprovider doesn't have + exampleRoutingConfig.domains['test5'] = { + type: IsmType.MESSAGE_ID_MULTISIG, + threshold: 1, + validators: [randomAddress()], + }; + + // expect 0 txs, as adding test5 domain is no-op + await expectTxsAndUpdate(ism, exampleRoutingConfig, 0); + }); + + it(`update route in an existing ${type}`, async () => { + // create a new ISM + const { ism, initialIsmAddress } = await createIsm( + exampleRoutingConfig, + ); + + // changing the type of a domain should enroll the domain + ( + exampleRoutingConfig.domains[TestChainName.test2] as MultisigIsmConfig + ).type = IsmType.MESSAGE_ID_MULTISIG; + + // expect 1 tx to enroll test2 domain + await expectTxsAndUpdate(ism, exampleRoutingConfig, 1); + + // check that the ISM address is the same + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .true; + }); + + it(`deletes route in an existing ${type}`, async () => { + // create a new ISM + const { ism, initialIsmAddress } = await createIsm( + exampleRoutingConfig, + ); + + // deleting the domain should unenroll the domain + delete exampleRoutingConfig.domains[TestChainName.test3]; + + // expect 1 tx to unenroll test3 domain + await expectTxsAndUpdate(ism, exampleRoutingConfig, 1); + + // expect the ISM address to be the same + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .true; + }); + + it(`deletes route in an existing ${type} even if not in multiprovider`, async () => { + // create a new ISM + const { ism } = await createIsm(exampleRoutingConfig); + + // keep track of the domains before deleting + const numDomainsBefore = Object.keys( + ((await ism.read()) as RoutingIsmConfig).domains, + ).length; + + // deleting the domain and removing from multiprovider should unenroll the domain + delete exampleRoutingConfig.domains[TestChainName.test3]; + multiProvider = multiProvider.intersect( + // remove test3 from multiprovider + testChains.filter((c) => c !== TestChainName.test3), + ).result; + + // expect 1 tx to unenroll test3 domain + await expectTxsAndUpdate(ism, exampleRoutingConfig, 1); + + // domains should have decreased by 1 + const numDomainsAfter = Object.keys( + ((await ism.read()) as RoutingIsmConfig).domains, + ).length; + console.log(numDomainsBefore, numDomainsAfter); + expect(numDomainsBefore - 1).to.equal(numDomainsAfter); + }); + + it(`updates owner in an existing ${type}`, async () => { + // create a new ISM + const { ism, initialIsmAddress } = await createIsm( + exampleRoutingConfig, + ); + + // change the config owner + exampleRoutingConfig.owner = randomAddress(); + + // expect 1 tx to transfer ownership + await expectTxsAndUpdate(ism, exampleRoutingConfig, 1); + + // expect the ISM address to be the same + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .true; + }); + + it(`no changes to an existing ${type} means no redeployment or updates`, async () => { + // create a new ISM + const { ism, initialIsmAddress } = await createIsm( + exampleRoutingConfig, + ); + + // expect 0 updates + await expectTxsAndUpdate(ism, exampleRoutingConfig, 0); + + // expect the ISM address to be the same + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .true; + }); + + it(`update owner in an existing ${type} not owned by deployer`, async () => { + // ISM owner is not the deployer + exampleRoutingConfig.owner = randomAddress(); + const originalOwner = exampleRoutingConfig.owner; + + // create a new ISM + const { ism, initialIsmAddress } = await createIsm( + exampleRoutingConfig, + ); + + // update the config owner and impersonate the original owner + exampleRoutingConfig.owner = randomAddress(); + multiProvider = await impersonateAccount(originalOwner); + + // expect 1 tx to transfer ownership + await expectTxsAndUpdate(ism, exampleRoutingConfig, 1); + + // expect the ISM address to be unchanged + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .true; + }); + + it(`update validators in an existing ${type}`, async () => { + // create a new ISM + const { ism, initialIsmAddress } = await createIsm( + exampleRoutingConfig, + ); + + // update the validators for a domain + ( + exampleRoutingConfig.domains[TestChainName.test2] as MultisigIsmConfig + ).validators = [randomAddress(), randomAddress()]; + + // expect 1 tx to update validator set for test2 domain + await expectTxsAndUpdate(ism, exampleRoutingConfig, 1); + + // expect the ISM address to be the same + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .true; + }); + + it(`update threshold in an existing ${type}`, async () => { + // create a new ISM + const { ism, initialIsmAddress } = await createIsm( + exampleRoutingConfig, + ); + + // update the threshold for a domain + ( + exampleRoutingConfig.domains[TestChainName.test2] as MultisigIsmConfig + ).threshold = 2; + + // expect 1 tx to update threshold for test2 domain + await expectTxsAndUpdate(ism, exampleRoutingConfig, 1); + + // expect the ISM address to be the same + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .true; + }); + } + + it(`redeploy same config if the mailbox address changes for defaultFallbackRoutingIsm`, async () => { + exampleRoutingConfig.type = IsmType.FALLBACK_ROUTING; + + // create a new ISM + const { ism, initialIsmAddress } = await createIsm(exampleRoutingConfig); + + // point to new mailbox + ism.setNewMailbox(newMailboxAddress); + + // expect a new ISM to be deployed, so no in-place updates to return + await expectTxsAndUpdate(ism, exampleRoutingConfig, 0); + + // expect the ISM address to be different + expect(eqAddress(initialIsmAddress, ism.serialize().deployedIsm)).to.be + .false; + + // expect that the ISM is configured with the new mailbox + const onchainIsm = FallbackDomainRoutingHook__factory.connect( + ism.serialize().deployedIsm, + multiProvider.getSigner(chain), + ); + const onchainMailbox = await onchainIsm['mailbox()'](); + expect(eqAddress(onchainMailbox, newMailboxAddress)).to.be.true; + }); + }); +}); diff --git a/typescript/sdk/src/ism/EvmIsmModule.ts b/typescript/sdk/src/ism/EvmIsmModule.ts index 7d9921832..e75a12699 100644 --- a/typescript/sdk/src/ism/EvmIsmModule.ts +++ b/typescript/sdk/src/ism/EvmIsmModule.ts @@ -1,89 +1,631 @@ -import { Address, ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; +import { ethers } from 'ethers'; +import { Logger } from 'pino'; -import { HyperlaneContracts } from '../contracts/types.js'; +import { + DefaultFallbackRoutingIsm__factory, + DomainRoutingIsm, + DomainRoutingIsmFactory__factory, + DomainRoutingIsm__factory, + IAggregationIsm, + IAggregationIsm__factory, + IInterchainSecurityModule__factory, + IMultisigIsm, + IMultisigIsm__factory, + IRoutingIsm, + MailboxClient__factory, + OPStackIsm__factory, + Ownable__factory, + PausableIsm__factory, + TestIsm__factory, + TrustedRelayerIsm__factory, +} from '@hyperlane-xyz/core'; +import { + Address, + ProtocolType, + assert, + configDeepEquals, + eqAddress, + normalizeConfig, + objFilter, + rootLogger, +} from '@hyperlane-xyz/utils'; +import { Domain } from '@hyperlane-xyz/utils'; + +import { attachAndConnectContracts } from '../contracts/contracts.js'; +import { HyperlaneAddresses, HyperlaneContracts } from '../contracts/types.js'; import { HyperlaneModule, - HyperlaneModuleArgs, + HyperlaneModuleParams, } from '../core/AbstractHyperlaneModule.js'; -import { HyperlaneDeployer } from '../deploy/HyperlaneDeployer.js'; -import { ProxyFactoryFactories } from '../deploy/contracts.js'; +import { EvmModuleDeployer } from '../deploy/EvmModuleDeployer.js'; +import { + ProxyFactoryFactories, + proxyFactoryFactories, +} from '../deploy/contracts.js'; +import { ContractVerifier } from '../deploy/verify/ContractVerifier.js'; import { MultiProvider } from '../providers/MultiProvider.js'; -import { EthersV5Transaction } from '../providers/ProviderType.js'; -import { ChainNameOrId } from '../types.js'; +import { AnnotatedEV5Transaction } from '../providers/ProviderType.js'; +import { ChainName, ChainNameOrId } from '../types.js'; +import { findMatchingLogEvents } from '../utils/logUtils.js'; -import { EvmIsmCreator } from './EvmIsmCreator.js'; import { EvmIsmReader } from './EvmIsmReader.js'; -import { IsmConfig } from './types.js'; +import { + AggregationIsmConfig, + DeployedIsm, + IsmConfig, + IsmType, + MUTABLE_ISM_TYPE, + MultisigIsmConfig, + RoutingIsmConfig, +} from './types.js'; +import { calculateDomainRoutingDelta } from './utils.js'; + +type IsmModuleAddresses = { + deployedIsm: Address; + mailbox: Address; +}; export class EvmIsmModule extends HyperlaneModule< ProtocolType.Ethereum, IsmConfig, - HyperlaneContracts & { - deployedIsm: Address; - } + HyperlaneAddresses & IsmModuleAddresses > { - protected logger = rootLogger.child({ module: 'EvmIsmModule' }); - protected reader: EvmIsmReader; - protected creator: EvmIsmCreator; + protected readonly logger = rootLogger.child({ module: 'EvmIsmModule' }); + protected readonly reader: EvmIsmReader; + protected readonly deployer: EvmModuleDeployer; + protected readonly factories: HyperlaneContracts; + + // Adding these to reduce how often we need to grab from MultiProvider. + public readonly chain: ChainName; + // We use domainId here because MultiProvider.getDomainId() will always + // return a number, and EVM the domainId and chainId are the same. + public readonly domainId: Domain; protected constructor( protected readonly multiProvider: MultiProvider, - protected readonly deployer: HyperlaneDeployer, - args: HyperlaneModuleArgs< + params: HyperlaneModuleParams< IsmConfig, - HyperlaneContracts & { - deployedIsm: Address; - } + HyperlaneAddresses & IsmModuleAddresses >, + contractVerifier?: ContractVerifier, ) { - super(args); - this.reader = new EvmIsmReader(multiProvider, args.chain); - this.creator = new EvmIsmCreator(deployer, multiProvider, args.addresses); + super(params); + + this.reader = new EvmIsmReader(multiProvider, params.chain); + this.deployer = new EvmModuleDeployer( + this.multiProvider, + {}, + this.logger, + contractVerifier, + ); + + this.factories = attachAndConnectContracts( + { + staticMerkleRootMultisigIsmFactory: + params.addresses.staticMerkleRootMultisigIsmFactory, + staticMessageIdMultisigIsmFactory: + params.addresses.staticMessageIdMultisigIsmFactory, + staticAggregationIsmFactory: + params.addresses.staticAggregationIsmFactory, + staticAggregationHookFactory: + params.addresses.staticAggregationHookFactory, + domainRoutingIsmFactory: params.addresses.domainRoutingIsmFactory, + }, + proxyFactoryFactories, + multiProvider.getSigner(params.chain), + ); + + this.chain = this.multiProvider.getChainName(this.args.chain); + this.domainId = this.multiProvider.getDomainId(this.chain); } public async read(): Promise { - return await this.reader.deriveIsmConfig(this.args.addresses.deployedIsm); + return typeof this.args.config === 'string' + ? this.args.addresses.deployedIsm + : this.reader.deriveIsmConfig(this.args.addresses.deployedIsm); } - public async update(config: IsmConfig): Promise { - throw new Error('Method not implemented.'); + // whoever calls update() needs to ensure that targetConfig has a valid owner + public async update( + targetConfig: IsmConfig, + ): Promise { + // save current config for comparison + // normalize the config to ensure it's in a consistent format for comparison + const currentConfig = normalizeConfig(await this.read()); - const destination = this.multiProvider.getChainName(this.args.chain); - await this.creator.update({ - destination, - config, - existingIsmAddress: this.args.addresses.deployedIsm, + // Update the config + this.args.config = targetConfig; + + // moduleMatchesConfig expects any domain filtering to have been done already + if ( + typeof targetConfig !== 'string' && + (targetConfig.type === IsmType.ROUTING || + targetConfig.type === IsmType.FALLBACK_ROUTING) + ) { + // filter for known domains + const { availableDomains } = this.filterRoutingIsmDomains({ + config: targetConfig, + }); + targetConfig.domains = availableDomains; + } + + // If configs match, no updates needed + if (configDeepEquals(currentConfig, targetConfig)) { + return []; + } + + // Else, we have to figure out what an update for this ISM entails + + // If target config is an address ISM, just update the address + // if config -> address ISM, update address + // if address ISM -> address ISM, update address + if (typeof targetConfig === 'string') { + // TODO: https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3773 + this.args.addresses.deployedIsm = targetConfig; + return []; + } + + // Check if we need to deploy a new ISM + if ( + // if address ISM -> config, do a new deploy + typeof currentConfig === 'string' || + // if config -> config, AND types are different, do a new deploy + currentConfig.type !== targetConfig.type || + // if it is not a mutable ISM, do a new deploy + !MUTABLE_ISM_TYPE.includes(targetConfig.type) + ) { + const contract = await this.deploy({ + config: targetConfig, + }); + + this.args.addresses.deployedIsm = contract.address; + return []; + } + + // At this point, only the 3 ownable/mutable ISM types should remain: PAUSABLE, ROUTING, FALLBACK_ROUTING + if ( + targetConfig.type !== IsmType.PAUSABLE && + targetConfig.type !== IsmType.ROUTING && + targetConfig.type !== IsmType.FALLBACK_ROUTING + ) { + throw new Error(`Unsupported ISM type ${targetConfig.type}`); + } + + const logger = this.logger.child({ + destination: this.chain, + ismType: targetConfig.type, }); - return []; + const provider = this.multiProvider.getProvider(this.chain); + + logger.debug(`Updating ${targetConfig.type} on ${this.chain}`); + + // if it's a fallback routing ISM, do a mailbox diff check and deploy a new ISM if needed + if (targetConfig.type === IsmType.FALLBACK_ROUTING) { + // can only retrieve mailbox address if current ISM type is also Fallback Routing + const mailboxAddress = + currentConfig.type === IsmType.FALLBACK_ROUTING + ? await MailboxClient__factory.connect( + this.args.addresses.deployedIsm, + provider, + ).mailbox() + : ''; // empty string to force a mailbox diff + + // if mailbox delta, deploy new routing ISM before updating + // this will always be the case if the current ISM is not a fallback routing ISM + if (!eqAddress(mailboxAddress, this.args.addresses.mailbox)) { + const newIsm = await this.deployRoutingIsm({ + config: targetConfig, + logger, + }); + + this.args.addresses.deployedIsm = newIsm.address; + } + } + + // if it's either of the routing ISMs, update their submodules + let updateTxs: AnnotatedEV5Transaction[] = []; + if ( + targetConfig.type === IsmType.ROUTING || + targetConfig.type === IsmType.FALLBACK_ROUTING + ) { + updateTxs = await this.updateRoutingIsm({ + current: currentConfig as RoutingIsmConfig, + target: targetConfig, + logger, + }); + } + + // Lastly, check if the resolved owner is different from the current owner + const owner = await Ownable__factory.connect( + this.args.addresses.deployedIsm, + provider, + ).owner(); + + // Return an ownership transfer transaction if required + if (!eqAddress(targetConfig.owner, owner)) { + updateTxs.push({ + annotation: 'Transferring ownership of ownable ISM...', + chainId: this.domainId, + to: this.args.addresses.deployedIsm, + data: Ownable__factory.createInterface().encodeFunctionData( + 'transferOwnership(address)', + [targetConfig.owner], + ), + }); + } + + return updateTxs; } // manually write static create function public static async create({ chain, config, - deployer, - factories, + proxyFactoryFactories, + mailbox, multiProvider, }: { chain: ChainNameOrId; config: IsmConfig; - deployer: HyperlaneDeployer; - factories: HyperlaneContracts; + proxyFactoryFactories: HyperlaneAddresses; + mailbox: Address; multiProvider: MultiProvider; }): Promise { - const destination = multiProvider.getChainName(chain); - const ismCreator = new EvmIsmCreator(deployer, multiProvider, factories); - const deployedIsm = await ismCreator.deploy({ - config, - destination, - }); - return new EvmIsmModule(multiProvider, deployer, { + // instantiate new EvmIsmModule + const module = new EvmIsmModule(multiProvider, { addresses: { - ...factories, - deployedIsm: deployedIsm.address, + ...proxyFactoryFactories, + mailbox, + deployedIsm: ethers.constants.AddressZero, }, chain, config, }); + + // deploy ISM and assign address to module + const deployedIsm = await module.deploy({ config }); + module.args.addresses.deployedIsm = deployedIsm.address; + + return module; + } + + protected async updateRoutingIsm({ + current, + target, + logger, + }: { + current: RoutingIsmConfig; + target: RoutingIsmConfig; + logger: Logger; + }): Promise { + const routingIsmInterface = DomainRoutingIsm__factory.createInterface(); + const updateTxs = []; + + const { domainsToEnroll, domainsToUnenroll } = calculateDomainRoutingDelta( + current, + target, + ); + + // Enroll domains + for (const origin of domainsToEnroll) { + logger.debug( + `Reconfiguring preexisting routing ISM for origin ${origin}...`, + ); + const ism = await this.deploy({ + config: target.domains[origin], + }); + + const domainId = this.multiProvider.getDomainId(origin); + updateTxs.push({ + annotation: `Setting new ISM for origin ${origin}...`, + chainId: this.domainId, + to: this.args.addresses.deployedIsm, + data: routingIsmInterface.encodeFunctionData('set(uint32,address)', [ + domainId, + ism.address, + ]), + }); + } + + // Unenroll domains + for (const origin of domainsToUnenroll) { + const domainId = this.multiProvider.getDomainId(origin); + updateTxs.push({ + annotation: `Unenrolling originDomain ${domainId} from preexisting routing ISM at ${this.args.addresses.deployedIsm}...`, + chainId: this.domainId, + to: this.args.addresses.deployedIsm, + data: routingIsmInterface.encodeFunctionData('remove(uint32)', [ + domainId, + ]), + }); + } + + return updateTxs; + } + + protected async deploy({ + config, + }: { + config: C; + }): Promise { + // If it's an address ISM, just return a base ISM + if (typeof config === 'string') { + // TODO: https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3773 + // we can remove the ts-ignore once we have a proper type for address ISMs + // @ts-ignore + return IInterchainSecurityModule__factory.connect( + config, + this.multiProvider.getSignerOrProvider(this.args.chain), + ); + } + + const ismType = config.type; + const logger = rootLogger.child({ chainName: this.chain, ismType }); + + logger.debug(`Deploying ${ismType} to ${this.args.chain}`); + + switch (ismType) { + case IsmType.MESSAGE_ID_MULTISIG: + case IsmType.MERKLE_ROOT_MULTISIG: + return this.deployMultisigIsm({ + config, + logger, + }); + + case IsmType.ROUTING: + case IsmType.FALLBACK_ROUTING: + return this.deployRoutingIsm({ + config, + logger, + }); + + case IsmType.AGGREGATION: + return this.deployAggregationIsm({ + config, + logger, + }); + + case IsmType.OP_STACK: + return this.deployer.deployContractFromFactory({ + chain: this.chain, + factory: new OPStackIsm__factory(), + contractName: IsmType.OP_STACK, + constructorArgs: [config.nativeBridge], + }); + + case IsmType.PAUSABLE: + return this.deployer.deployContractFromFactory({ + chain: this.chain, + factory: new PausableIsm__factory(), + contractName: IsmType.PAUSABLE, + constructorArgs: [config.owner], + }); + + case IsmType.TRUSTED_RELAYER: + assert( + this.args.addresses.mailbox, + `Mailbox address is required for deploying ${ismType}`, + ); + return this.deployer.deployContractFromFactory({ + chain: this.chain, + factory: new TrustedRelayerIsm__factory(), + contractName: IsmType.TRUSTED_RELAYER, + constructorArgs: [this.args.addresses.mailbox, config.relayer], + }); + + case IsmType.TEST_ISM: + return this.deployer.deployContractFromFactory({ + chain: this.chain, + factory: new TestIsm__factory(), + contractName: IsmType.TEST_ISM, + constructorArgs: [], + }); + + default: + throw new Error(`Unsupported ISM type ${ismType}`); + } + } + + protected async deployMultisigIsm({ + config, + logger, + }: { + config: MultisigIsmConfig; + logger: Logger; + }): Promise { + const signer = this.multiProvider.getSigner(this.chain); + const factoryName = + config.type === IsmType.MERKLE_ROOT_MULTISIG + ? 'staticMerkleRootMultisigIsmFactory' + : 'staticMessageIdMultisigIsmFactory'; + + const address = await EvmModuleDeployer.deployStaticAddressSet({ + chain: this.chain, + factory: this.factories[factoryName], + values: config.validators, + logger, + threshold: config.threshold, + multiProvider: this.multiProvider, + }); + + return IMultisigIsm__factory.connect(address, signer); + } + + protected async deployRoutingIsm({ + config, + logger, + }: { + config: RoutingIsmConfig; + logger: Logger; + }): Promise { + // filter out domains which are not part of the multiprovider + const { availableDomains, availableDomainIds } = + this.filterRoutingIsmDomains({ + config, + }); + config.domains = availableDomains; + + // deploy the submodules first + const submoduleAddresses: Address[] = await Promise.all( + Object.keys(config.domains).map(async (origin) => { + const { address } = await this.deploy({ + config: config.domains[origin], + }); + return address; + }), + ); + + if (config.type === IsmType.FALLBACK_ROUTING) { + // deploy the fallback routing ISM + logger.debug('Deploying fallback routing ISM ...'); + const ism = await this.multiProvider.handleDeploy( + this.chain, + new DefaultFallbackRoutingIsm__factory(), + [this.args.addresses.mailbox], + ); + + // initialize the fallback routing ISM + logger.debug('Initializing fallback routing ISM ...'); + const tx = await ism['initialize(address,uint32[],address[])']( + config.owner, + availableDomainIds, + submoduleAddresses, + ); + + await this.multiProvider.handleTx(this.chain, tx); + // return the fallback routing ISM + return ism; + } + + // then deploy the domain routing ISM + logger.debug('Deploying domain routing ISM ...'); + return this.deployDomainRoutingIsm({ + owner: config.owner, + domainIds: availableDomainIds, + submoduleAddresses, + }); + } + + protected async deployDomainRoutingIsm({ + owner, + domainIds, + submoduleAddresses, + }: { + owner: string; + domainIds: number[]; + submoduleAddresses: string[]; + }): Promise { + const overrides = this.multiProvider.getTransactionOverrides( + this.args.chain, + ); + + const signer = this.multiProvider.getSigner(this.args.chain); + const domainRoutingIsmFactory = DomainRoutingIsmFactory__factory.connect( + this.args.addresses.domainRoutingIsmFactory, + signer, + ); + + // estimate gas + const estimatedGas = await domainRoutingIsmFactory.estimateGas.deploy( + owner, + domainIds, + submoduleAddresses, + overrides, + ); + + // deploying new domain routing ISM, add 10% buffer + const tx = await domainRoutingIsmFactory.deploy( + owner, + domainIds, + submoduleAddresses, + { + ...overrides, + gasLimit: estimatedGas.add(estimatedGas.div(10)), // 10% buffer + }, + ); + + const receipt = await this.multiProvider.handleTx(this.args.chain, tx); + const dispatchLogs = findMatchingLogEvents( + receipt.logs, + domainRoutingIsmFactory.interface, + 'ModuleDeployed', + ); + + if (dispatchLogs.length === 0) { + throw new Error('No ModuleDeployed event found'); + } + + const moduleAddress = dispatchLogs[0].args['module']; + return DomainRoutingIsm__factory.connect(moduleAddress, signer); + } + + protected async deployAggregationIsm({ + config, + logger, + }: { + config: AggregationIsmConfig; + logger: Logger; + }): Promise { + const addresses: Address[] = []; + // Needs to be deployed sequentially because Ethers will throw `Error: replacement fee too low` + for (const module of config.modules) { + const submodule = await this.deploy({ config: module }); + addresses.push(submodule.address); + } + + const factoryName = 'staticAggregationIsmFactory'; + const address = await EvmModuleDeployer.deployStaticAddressSet({ + chain: this.chain, + factory: this.factories[factoryName], + values: addresses, + logger: logger, + threshold: config.threshold, + multiProvider: this.multiProvider, + }); + + const signer = this.multiProvider.getSigner(this.args.chain); + return IAggregationIsm__factory.connect(address, signer); + } + + // Updates the mailbox address if it is different from the current one. + // Logs changes and updates the internal state of the module. + public setNewMailbox(newMailboxAddress: Address): void { + const currentMailboxAddress = this.args.addresses.mailbox; + + if (currentMailboxAddress === newMailboxAddress) { + this.logger.debug( + `Mailbox address is already set to ${newMailboxAddress}`, + ); + return; + } + + this.logger.debug( + `Setting new mailbox address from ${currentMailboxAddress} to ${newMailboxAddress}`, + ); + + // Update the mailbox address in the arguments + this.args.addresses.mailbox = newMailboxAddress; + } + + // filtering out domains which are not part of the multiprovider + private filterRoutingIsmDomains({ config }: { config: RoutingIsmConfig }) { + const availableDomainIds: number[] = []; + const availableDomains = objFilter( + config.domains, + (domain, _): _ is IsmConfig => { + const domainId = this.multiProvider.tryGetDomainId(domain); + if (domainId === null) { + this.logger.warn( + `Domain ${domain} doesn't have chain metadata provided, skipping ...`, + ); + return false; + } + + availableDomainIds.push(domainId); + return true; + }, + ); + + return { availableDomains, availableDomainIds }; } } diff --git a/typescript/sdk/src/ism/HyperlaneIsmFactory.hardhat-test.ts b/typescript/sdk/src/ism/HyperlaneIsmFactory.hardhat-test.ts index f535277e4..87cf1044a 100644 --- a/typescript/sdk/src/ism/HyperlaneIsmFactory.hardhat-test.ts +++ b/typescript/sdk/src/ism/HyperlaneIsmFactory.hardhat-test.ts @@ -126,7 +126,7 @@ describe('HyperlaneIsmFactory', async () => { owner: await multiProvider.getSignerAddress(chain), domains: Object.fromEntries( testChains - .filter((c) => c !== TestChainName.test1) + .filter((c) => c !== TestChainName.test1 && c !== TestChainName.test4) .map((c) => [c, randomMultisigIsmConfig(3, 5)]), ), }; @@ -353,13 +353,13 @@ describe('HyperlaneIsmFactory', async () => { }); const existingIsm = ism.address; const domainsBefore = await (ism as DomainRoutingIsm).domains(); - // deleting the domain and removing from multiprovider should unenroll the domain // NB: we'll deploy new multisigIsms for the domains bc of new factories but the routingIsm address should be the same because of existingIsmAddress delete exampleRoutingConfig.domains['test3']; multiProvider = multiProvider.intersect([ TestChainName.test1, - 'test2', + TestChainName.test2, + TestChainName.test4, ]).result; ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); ismFactory = new HyperlaneIsmFactory( diff --git a/typescript/sdk/src/ism/HyperlaneIsmFactory.ts b/typescript/sdk/src/ism/HyperlaneIsmFactory.ts index ca3046848..d6e4e7440 100644 --- a/typescript/sdk/src/ism/HyperlaneIsmFactory.ts +++ b/typescript/sdk/src/ism/HyperlaneIsmFactory.ts @@ -36,7 +36,6 @@ import { ProxyFactoryFactories, proxyFactoryFactories, } from '../deploy/contracts.js'; -import { resolveOrDeployAccountOwner } from '../deploy/types.js'; import { MultiProvider } from '../providers/MultiProvider.js'; import { ChainMap, ChainName } from '../types.js'; @@ -151,13 +150,7 @@ export class HyperlaneIsmFactory extends HyperlaneApp { destination, new PausableIsm__factory(), IsmType.PAUSABLE, - [ - await resolveOrDeployAccountOwner( - this.multiProvider, - destination, - config.owner, - ), - ], + [config.owner], ); await this.deployer.transferOwnershipOfContracts(destination, config, { [IsmType.PAUSABLE]: contract, @@ -327,11 +320,6 @@ export class HyperlaneIsmFactory extends HyperlaneApp { } } else { const isms: ChainMap
= {}; - const owner = await resolveOrDeployAccountOwner( - this.multiProvider, - destination, - config.owner, - ); for (const origin of Object.keys(config.domains)) { const ism = await this.deploy({ destination, @@ -360,7 +348,7 @@ export class HyperlaneIsmFactory extends HyperlaneApp { receipt = await this.multiProvider.handleTx( destination, routingIsm['initialize(address,uint32[],address[])']( - owner, + config.owner, safeConfigDomains, submoduleAddresses, overrides, @@ -368,11 +356,7 @@ export class HyperlaneIsmFactory extends HyperlaneApp { ); } else { // deploying new domain routing ISM - const owner = await resolveOrDeployAccountOwner( - this.multiProvider, - destination, - config.owner, - ); + const owner = config.owner; // estimate gas const estimatedGas = await domainRoutingIsmFactory.estimateGas.deploy( owner, diff --git a/typescript/sdk/src/ism/metadata/builder.ts b/typescript/sdk/src/ism/metadata/builder.ts index 66e364bbc..a18d14dfb 100644 --- a/typescript/sdk/src/ism/metadata/builder.ts +++ b/typescript/sdk/src/ism/metadata/builder.ts @@ -75,6 +75,9 @@ export class BaseMetadataBuilder implements MetadataBuilder { case IsmType.MERKLE_ROOT_MULTISIG: case IsmType.MESSAGE_ID_MULTISIG: + if (typeof hook === 'string') { + throw new Error('Hook context must be an object (for multisig ISM)'); + } const merkleTreeHook = deepFind( hook, (v): v is WithAddress => diff --git a/typescript/sdk/src/ism/metadata/multisig.ts b/typescript/sdk/src/ism/metadata/multisig.ts index afb6539b0..19aa957d0 100644 --- a/typescript/sdk/src/ism/metadata/multisig.ts +++ b/typescript/sdk/src/ism/metadata/multisig.ts @@ -220,7 +220,15 @@ export class MultisigMetadataBuilder implements MetadataBuilder { return toHexString(buf); } - static decodeSimplePrefix(metadata: string) { + static decodeSimplePrefix(metadata: string): { + signatureOffset: number; + type: IsmType; + checkpoint: { + root: string; + index: number; + merkle_tree_hook_address: string; + }; + } { const buf = fromHexString(metadata); const merkleTree = toHexString(buf.subarray(0, 32)); const root = toHexString(buf.subarray(32, 64)); @@ -251,7 +259,16 @@ export class MultisigMetadataBuilder implements MetadataBuilder { return toHexString(buf); } - static decodeProofPrefix(metadata: string) { + static decodeProofPrefix(metadata: string): { + signatureOffset: number; + type: IsmType; + checkpoint: { + root: string; + index: number; + merkle_tree_hook_address: string; + }; + proof: MerkleProof; + } { const buf = fromHexString(metadata); const merkleTree = toHexString(buf.subarray(0, 32)); const messageIndex = buf.readUint32BE(32); diff --git a/typescript/sdk/src/ism/multisig.ts b/typescript/sdk/src/ism/multisig.ts index d2dd05756..83025f198 100644 --- a/typescript/sdk/src/ism/multisig.ts +++ b/typescript/sdk/src/ism/multisig.ts @@ -42,7 +42,7 @@ export const buildAggregationIsmConfigs = ( (chain, config): config is MultisigConfig => chain !== local && chains.includes(chain), ), - (_, config) => ({ + (_, config): AggregationIsmConfig => ({ type: IsmType.AGGREGATION, modules: [ { @@ -56,5 +56,5 @@ export const buildAggregationIsmConfigs = ( ], threshold: 1, }), - ) as ChainMap; + ); }; diff --git a/typescript/sdk/src/ism/schemas.ts b/typescript/sdk/src/ism/schemas.ts index c449e0e42..176a5539c 100644 --- a/typescript/sdk/src/ism/schemas.ts +++ b/typescript/sdk/src/ism/schemas.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { OwnableConfigSchema } from '../deploy/schemas.js'; import { ZHash } from '../metadata/customZodTypes.js'; +import { OwnableSchema, PausableSchema } from '../schemas.js'; -import { AggregationIsmConfig, IsmConfig, IsmType } from './types.js'; +import { AggregationIsmConfig, IsmType, RoutingIsmConfig } from './types.js'; export const TestIsmConfigSchema = z.object({ type: z.literal(IsmType.TEST_ISM), @@ -25,10 +25,9 @@ export const OpStackIsmConfigSchema = z.object({ nativeBridge: z.string(), }); -export const PausableIsmConfigSchema = OwnableConfigSchema.and( +export const PausableIsmConfigSchema = PausableSchema.and( z.object({ type: z.literal(IsmType.PAUSABLE), - paused: z.boolean().optional(), }), ); @@ -41,40 +40,36 @@ export const MultisigIsmConfigSchema = MultisigConfigSchema.and( }), ); -export const RoutingIsmConfigSchema = OwnableConfigSchema.and( - z.object({ - type: z.union([ - z.literal(IsmType.ROUTING), - z.literal(IsmType.FALLBACK_ROUTING), - ]), - domains: z.record(z.string(), z.nativeEnum(IsmType)), - }), +export const RoutingIsmConfigSchema: z.ZodSchema = z.lazy( + () => + OwnableSchema.extend({ + type: z.union([ + z.literal(IsmType.ROUTING), + z.literal(IsmType.FALLBACK_ROUTING), + ]), + domains: z.record(IsmConfigSchema), + }), ); -export const AggregationIsmConfigSchema: z.ZodSchema = - z.lazy(() => - z - .object({ - type: z.literal(IsmType.AGGREGATION), - modules: z.array(IsmConfigSchema), - threshold: z.number(), - }) - .refine((data) => { - if (data.threshold > data.modules.length) return false; - - return true; - }), - ); +export const AggregationIsmConfigSchema: z.ZodSchema = z + .lazy(() => + z.object({ + type: z.literal(IsmType.AGGREGATION), + modules: z.array(IsmConfigSchema), + threshold: z.number(), + }), + ) + .refine((data) => data.threshold <= data.modules.length, { + message: 'Threshold must be less than or equal to the number of modules', + }); -export const IsmConfigSchema: z.ZodSchema = z.lazy(() => - z.union([ - z.string(), - TestIsmConfigSchema, - OpStackIsmConfigSchema, - PausableIsmConfigSchema, - TrustedRelayerIsmConfigSchema, - MultisigIsmConfigSchema, - RoutingIsmConfigSchema, - AggregationIsmConfigSchema, - ]), -); +export const IsmConfigSchema = z.union([ + ZHash, + TestIsmConfigSchema, + OpStackIsmConfigSchema, + PausableIsmConfigSchema, + TrustedRelayerIsmConfigSchema, + MultisigIsmConfigSchema, + RoutingIsmConfigSchema, + AggregationIsmConfigSchema, +]); diff --git a/typescript/sdk/src/ism/types.ts b/typescript/sdk/src/ism/types.ts index 89892662d..631a7a5f5 100644 --- a/typescript/sdk/src/ism/types.ts +++ b/typescript/sdk/src/ism/types.ts @@ -1,5 +1,8 @@ +import { z } from 'zod'; + import { IAggregationIsm, + IInterchainSecurityModule, IMultisigIsm, IRoutingIsm, OPStackIsm, @@ -12,6 +15,15 @@ import type { Address, Domain, ValueOf } from '@hyperlane-xyz/utils'; import { OwnableConfig } from '../deploy/types.js'; import { ChainMap } from '../types.js'; +import { + IsmConfigSchema, + MultisigIsmConfigSchema, + OpStackIsmConfigSchema, + PausableIsmConfigSchema, + TestIsmConfigSchema, + TrustedRelayerIsmConfigSchema, +} from './schemas.js'; + // this enum should match the IInterchainSecurityModule.sol enum // meant for the relayer export enum ModuleType { @@ -28,6 +40,7 @@ export enum ModuleType { // this enum can be adjusted as per deployments necessary // meant for the deployer and checker export enum IsmType { + CUSTOM = 'custom', OP_STACK = 'opStackIsm', ROUTING = 'domainRoutingIsm', FALLBACK_ROUTING = 'defaultFallbackRoutingIsm', @@ -39,6 +52,13 @@ export enum IsmType { TRUSTED_RELAYER = 'trustedRelayerIsm', } +// ISM types that can be updated in-place +export const MUTABLE_ISM_TYPE = [ + IsmType.ROUTING, + IsmType.FALLBACK_ROUTING, + IsmType.PAUSABLE, +]; + // mapping between the two enums export function ismTypeToModuleType(ismType: IsmType): ModuleType { switch (ismType) { @@ -55,6 +75,7 @@ export function ismTypeToModuleType(ismType: IsmType): ModuleType { case IsmType.OP_STACK: case IsmType.TEST_ISM: case IsmType.PAUSABLE: + case IsmType.CUSTOM: case IsmType.TRUSTED_RELAYER: return ModuleType.NULL; } @@ -65,18 +86,19 @@ export type MultisigConfig = { threshold: number; }; -export type MultisigIsmConfig = MultisigConfig & { - type: IsmType.MERKLE_ROOT_MULTISIG | IsmType.MESSAGE_ID_MULTISIG; -}; - -export type TestIsmConfig = { - type: IsmType.TEST_ISM; -}; +export type MultisigIsmConfig = z.infer; +export type TestIsmConfig = z.infer; +export type PausableIsmConfig = z.infer; +export type OpStackIsmConfig = z.infer; +export type TrustedRelayerIsmConfig = z.infer< + typeof TrustedRelayerIsmConfigSchema +>; -export type PausableIsmConfig = OwnableConfig & { - type: IsmType.PAUSABLE; - paused?: boolean; -}; +export type NullIsmConfig = + | TestIsmConfig + | PausableIsmConfig + | OpStackIsmConfig + | TrustedRelayerIsmConfig; export type RoutingIsmConfig = OwnableConfig & { type: IsmType.ROUTING | IsmType.FALLBACK_ROUTING; @@ -89,31 +111,10 @@ export type AggregationIsmConfig = { threshold: number; }; -export type OpStackIsmConfig = { - type: IsmType.OP_STACK; - origin: Address; - nativeBridge: Address; -}; - -export type TrustedRelayerIsmConfig = { - type: IsmType.TRUSTED_RELAYER; - relayer: Address; -}; - -export type NullIsmConfig = - | PausableIsmConfig - | TestIsmConfig - | OpStackIsmConfig - | TrustedRelayerIsmConfig; - -export type IsmConfig = - | Address - | NullIsmConfig - | RoutingIsmConfig - | MultisigIsmConfig - | AggregationIsmConfig; +export type IsmConfig = z.infer; export type DeployedIsmType = { + [IsmType.CUSTOM]: IInterchainSecurityModule; [IsmType.ROUTING]: IRoutingIsm; [IsmType.FALLBACK_ROUTING]: IRoutingIsm; [IsmType.AGGREGATION]: IAggregationIsm; diff --git a/typescript/sdk/src/ism/utils.ts b/typescript/sdk/src/ism/utils.ts index 2ee8a0d06..6b1574925 100644 --- a/typescript/sdk/src/ism/utils.ts +++ b/typescript/sdk/src/ism/utils.ts @@ -14,6 +14,7 @@ import { } from '@hyperlane-xyz/core'; import { Address, + configDeepEquals, eqAddress, formatMessage, normalizeAddress, @@ -23,7 +24,6 @@ import { import { HyperlaneContracts } from '../contracts/types.js'; import { ProxyFactoryFactories } from '../deploy/contracts.js'; -import { resolveOrDeployAccountOwner } from '../deploy/types.js'; import { MultiProvider } from '../providers/MultiProvider.js'; import { ChainName } from '../types.js'; @@ -38,6 +38,46 @@ import { const logger = rootLogger.child({ module: 'IsmUtils' }); +// Determines the domains to enroll and unenroll to update the current ISM config +// to match the target ISM config. +export function calculateDomainRoutingDelta( + current: RoutingIsmConfig, + target: RoutingIsmConfig, +): { domainsToEnroll: ChainName[]; domainsToUnenroll: ChainName[] } { + const domainsToEnroll = []; + for (const origin of Object.keys(target.domains)) { + if (!current.domains[origin]) { + domainsToEnroll.push(origin); + } else { + const subModuleMatches = configDeepEquals( + current.domains[origin], + target.domains[origin], + ); + if (!subModuleMatches) domainsToEnroll.push(origin); + } + } + + const domainsToUnenroll = Object.keys(current.domains).reduce( + (acc, origin) => { + if (!Object.keys(target.domains).includes(origin)) { + acc.push(origin); + } + return acc; + }, + [] as ChainName[], + ); + + return { + domainsToEnroll, + domainsToUnenroll, + }; +} + +/* + * The following functions are considered legacy and are deprecated. DO NOT USE. + * ----------------------------------------------------------------------------- + */ + // Note that this function may return false negatives, but should // not return false positives. // This can happen if, for example, the module has sender, recipient, or @@ -222,11 +262,7 @@ export async function moduleMatchesConfig( ); // Check that the RoutingISM owner matches the config const owner = await routingIsm.owner(); - const expectedOwner = await resolveOrDeployAccountOwner( - multiProvider, - chain, - config.owner, - ); + const expectedOwner = config.owner; matches &&= eqAddress(owner, expectedOwner); // check if the mailbox matches the config for fallback routing if (config.type === IsmType.FALLBACK_ROUTING) { @@ -314,11 +350,7 @@ export async function moduleMatchesConfig( case IsmType.PAUSABLE: { const pausableIsm = PausableIsm__factory.connect(moduleAddress, provider); const owner = await pausableIsm.owner(); - const expectedOwner = await resolveOrDeployAccountOwner( - multiProvider, - chain, - config.owner, - ); + const expectedOwner = config.owner; matches &&= eqAddress(owner, expectedOwner); if (config.paused) { @@ -360,11 +392,7 @@ export async function routingModuleDelta( }; // if owners don't match, we need to transfer ownership - const expectedOwner = await resolveOrDeployAccountOwner( - multiProvider, - destination, - config.owner, - ); + const expectedOwner = config.owner; if (!eqAddress(owner, normalizeAddress(expectedOwner))) delta.owner = expectedOwner; if (config.type === IsmType.FALLBACK_ROUTING) { diff --git a/typescript/sdk/src/middleware/account/InterchainAccount.ts b/typescript/sdk/src/middleware/account/InterchainAccount.ts index 8d13e8f84..7c5fe6c0a 100644 --- a/typescript/sdk/src/middleware/account/InterchainAccount.ts +++ b/typescript/sdk/src/middleware/account/InterchainAccount.ts @@ -1,9 +1,8 @@ -import { BigNumber, BytesLike, PopulatedTransaction } from 'ethers'; +import { BigNumber, PopulatedTransaction } from 'ethers'; import { InterchainAccountRouter } from '@hyperlane-xyz/core'; import { Address, - CallData, addressToBytes32, bytes32ToAddress, } from '@hyperlane-xyz/utils'; @@ -22,7 +21,7 @@ import { InterchainAccountFactories, interchainAccountFactories, } from './contracts.js'; -import { AccountConfig } from './types.js'; +import { AccountConfig, GetCallRemoteSettings } from './types.js'; export class InterchainAccount extends RouterApp { constructor( @@ -88,13 +87,13 @@ export class InterchainAccount extends RouterApp { } // meant for ICA governance to return the populatedTx - async getCallRemote( - chain: ChainName, - destination: ChainName, - innerCalls: CallData[], - config: AccountConfig, - hookMetadata?: BytesLike, - ): Promise { + async getCallRemote({ + chain, + destination, + innerCalls, + config, + hookMetadata, + }: GetCallRemoteSettings): Promise { const localRouter = this.router(this.contractsMap[chain]); const remoteDomain = this.multiProvider.getDomainId(destination); const quote = await localRouter['quoteGasPayment(uint32)'](remoteDomain); @@ -139,34 +138,49 @@ export class InterchainAccount extends RouterApp { // general helper for different overloaded callRemote functions // can override the gasLimit by StandardHookMetadata.overrideGasLimit for optional hookMetadata here - async callRemote( - chain: ChainName, - destination: ChainName, - calls: Array, - config: AccountConfig, - hookMetadata?: string, - ): Promise { + async callRemote({ + chain, + destination, + innerCalls, + config, + hookMetadata, + }: GetCallRemoteSettings): Promise { await this.multiProvider.sendTransaction( chain, - this.getCallRemote(chain, destination, calls, config, hookMetadata), + this.getCallRemote({ + chain, + destination, + innerCalls, + config, + hookMetadata, + }), ); } } -export async function deployInterchainAccount( +export function buildInterchainAccountApp( multiProvider: MultiProvider, chain: ChainName, config: AccountConfig, -): Promise
{ +): InterchainAccount { if (!config.localRouter) { throw new Error('localRouter is required for account deployment'); } const addressesMap: HyperlaneAddressesMap = { [chain]: { interchainAccountRouter: config.localRouter }, }; - const router = InterchainAccount.fromAddressesMap( - addressesMap, + return InterchainAccount.fromAddressesMap(addressesMap, multiProvider); +} + +export async function deployInterchainAccount( + multiProvider: MultiProvider, + chain: ChainName, + config: AccountConfig, +): Promise
{ + const interchainAccountApp: InterchainAccount = buildInterchainAccountApp( multiProvider, + chain, + config, ); - return router.deployAccount(chain, config); + return interchainAccountApp.deployAccount(chain, config); } diff --git a/typescript/sdk/src/middleware/account/InterchainAccountDeployer.ts b/typescript/sdk/src/middleware/account/InterchainAccountDeployer.ts index 13f430488..c752861fe 100644 --- a/typescript/sdk/src/middleware/account/InterchainAccountDeployer.ts +++ b/typescript/sdk/src/middleware/account/InterchainAccountDeployer.ts @@ -1,6 +1,7 @@ import { ethers } from 'ethers'; import { Router } from '@hyperlane-xyz/core'; +import { assert } from '@hyperlane-xyz/utils'; import { HyperlaneContracts } from '../../contracts/types.js'; import { ContractVerifier } from '../../deploy/verify/ContractVerifier.js'; @@ -49,9 +50,16 @@ export class InterchainAccountDeployer extends ProxiedRouterDeployer< async initializeArgs(chain: string, config: RouterConfig): Promise { const owner = await this.multiProvider.getSignerAddress(chain); + if (config.interchainSecurityModule) { + assert( + typeof config.interchainSecurityModule === 'string', + 'ISM objects not supported in ICA deployer', + ); + } + return [ config.hook ?? ethers.constants.AddressZero, - config.interchainSecurityModule! as string, // deployed in deployContracts + config.interchainSecurityModule!, owner, ]; } diff --git a/typescript/sdk/src/middleware/account/accounts.hardhat-test.ts b/typescript/sdk/src/middleware/account/accounts.hardhat-test.ts index dbf3dbc6b..78cb86fbd 100644 --- a/typescript/sdk/src/middleware/account/accounts.hardhat-test.ts +++ b/typescript/sdk/src/middleware/account/accounts.hardhat-test.ts @@ -1,6 +1,6 @@ import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; import { expect } from 'chai'; -import { BigNumber, constants } from 'ethers'; +import { constants } from 'ethers'; import hre from 'hardhat'; import { @@ -84,7 +84,7 @@ describe('InterchainAccounts', async () => { const call = { to: recipient.address, data, - value: BigNumber.from('0'), + value: '0', }; const quote = await local['quoteGasPayment(uint32)']( multiProvider.getDomainId(remoteChain), @@ -95,7 +95,12 @@ describe('InterchainAccounts', async () => { owner: signer.address, localRouter: local.address, }; - await app.callRemote(localChain, remoteChain, [call], config); + await app.callRemote({ + chain: localChain, + destination: remoteChain, + innerCalls: [call], + config, + }); const balanceAfter = await signer.getBalance(); await coreApp.processMessages(); expect(balanceAfter).to.lte(balanceBefore.sub(quote)); diff --git a/typescript/sdk/src/middleware/account/schemas.ts b/typescript/sdk/src/middleware/account/schemas.ts index 5b95b13d1..6e90b1a19 100644 --- a/typescript/sdk/src/middleware/account/schemas.ts +++ b/typescript/sdk/src/middleware/account/schemas.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; import { ZChainName, ZHash } from '../../metadata/customZodTypes.js'; +import { + BigNumberSchema, + CallDataSchema, +} from '../../providers/transactions/schemas.js'; export const AccountConfigSchema = z.object({ origin: ZChainName, @@ -9,3 +13,12 @@ export const AccountConfigSchema = z.object({ routerOverride: ZHash.optional(), ismOverride: ZHash.optional(), }); + +/* For InterchainAccount::getCallRemote() */ +export const GetCallRemoteSettingsSchema = z.object({ + chain: ZChainName, + destination: ZChainName, + innerCalls: z.array(CallDataSchema), + config: AccountConfigSchema, + hookMetadata: BigNumberSchema.optional(), +}); diff --git a/typescript/sdk/src/middleware/account/types.ts b/typescript/sdk/src/middleware/account/types.ts index 85d5acda7..58dd28d14 100644 --- a/typescript/sdk/src/middleware/account/types.ts +++ b/typescript/sdk/src/middleware/account/types.ts @@ -1,11 +1,7 @@ -import { Address } from '@hyperlane-xyz/utils'; +import { z } from 'zod'; -import { ChainName } from '../../types.js'; +import { AccountConfigSchema, GetCallRemoteSettingsSchema } from './schemas.js'; -export type AccountConfig = { - origin: ChainName; - owner: Address; - localRouter?: Address; - routerOverride?: Address; - ismOverride?: Address; -}; +export type AccountConfig = z.infer; +/* For InterchainAccount::getCallRemote() */ +export type GetCallRemoteSettings = z.infer; diff --git a/typescript/sdk/src/providers/MultiProvider.ts b/typescript/sdk/src/providers/MultiProvider.ts index 0df4567dc..fb8a36b19 100644 --- a/typescript/sdk/src/providers/MultiProvider.ts +++ b/typescript/sdk/src/providers/MultiProvider.ts @@ -16,6 +16,7 @@ import { ChainMetadataManager } from '../metadata/ChainMetadataManager.js'; import { ChainMetadata } from '../metadata/chainMetadataTypes.js'; import { ChainMap, ChainName, ChainNameOrId } from '../types.js'; +import { AnnotatedEV5Transaction } from './ProviderType.js'; import { ProviderBuilderFn, defaultProviderBuilder, @@ -384,9 +385,13 @@ export class MultiProvider extends ChainMetadataManager { */ async sendTransaction( chainNameOrId: ChainNameOrId, - tx: PopulatedTransaction | Promise, + txProm: AnnotatedEV5Transaction | Promise, ): Promise { - const txReq = await this.prepareTx(chainNameOrId, await tx); + const { annotation, ...tx } = await txProm; + if (annotation) { + this.logger.info(annotation); + } + const txReq = await this.prepareTx(chainNameOrId, tx); const signer = this.getSigner(chainNameOrId); const response = await signer.sendTransaction(txReq); this.logger.info(`Sent tx ${response.hash}`); diff --git a/typescript/sdk/src/providers/ProviderType.ts b/typescript/sdk/src/providers/ProviderType.ts index fa656766b..d7d84f152 100644 --- a/typescript/sdk/src/providers/ProviderType.ts +++ b/typescript/sdk/src/providers/ProviderType.ts @@ -15,7 +15,6 @@ import type { providers as EV5Providers, PopulatedTransaction as EV5Transaction, } from 'ethers'; -// import type { Contract as Ev6Contract, Provider as Ev6Provider } from 'ethers6'; import type { GetContractReturnType, PublicClient, @@ -27,7 +26,6 @@ import { ProtocolType } from '@hyperlane-xyz/utils'; export enum ProviderType { EthersV5 = 'ethers-v5', - // EthersV6 = 'ethers-v6', Disabled for now to simplify build tooling Viem = 'viem', SolanaWeb3 = 'solana-web3', CosmJs = 'cosmjs', @@ -103,11 +101,6 @@ export interface EthersV5Provider provider: EV5Providers.Provider; } -// export interface EthersV6Provider extends TypedProviderBase { -// type: ProviderType.EthersV6; -// provider: Ev6Provider; -// } - export interface ViemProvider extends TypedProviderBase { type: ProviderType.Viem; provider: PublicClient; @@ -152,11 +145,6 @@ export interface EthersV5Contract extends TypedContractBase { contract: EV5Contract; } -// export interface EthersV6Contract extends TypedContractBase { -// type: ProviderType.EthersV6; -// contract: Ev6Contract; -// } - export interface ViemContract extends TypedContractBase { type: ProviderType.Viem; contract: GetContractReturnType; @@ -203,10 +191,9 @@ export interface EthersV5Transaction transaction: EV5Transaction; } -// export interface EthersV6Transaction extends TypedTransactionBase { -// type: ProviderType.EthersV6; -// contract: Ev6Transaction; -// } +export interface AnnotatedEV5Transaction extends EV5Transaction { + annotation?: string; +} export interface ViemTransaction extends TypedTransactionBase { type: ProviderType.Viem; diff --git a/typescript/sdk/src/providers/SmartProvider/SmartProvider.ts b/typescript/sdk/src/providers/SmartProvider/SmartProvider.ts index a5daf9ea0..f5c579a38 100644 --- a/typescript/sdk/src/providers/SmartProvider/SmartProvider.ts +++ b/typescript/sdk/src/providers/SmartProvider/SmartProvider.ts @@ -1,5 +1,5 @@ import { BigNumber, providers, utils } from 'ethers'; -import { Logger } from 'pino'; +import pino, { Logger } from 'pino'; import { raceWithContext, @@ -97,7 +97,11 @@ export class HyperlaneSmartProvider this.supportedMethods = [...supportedMethods.values()]; } - async getPriorityFee() { + setLogLevel(level: pino.LevelWithSilentOrString) { + this.logger.level = level; + } + + async getPriorityFee(): Promise { try { return BigNumber.from(await this.perform('maxPriorityFeePerGas', {})); } catch (error) { @@ -271,7 +275,7 @@ export class HyperlaneSmartProvider providerResultPromises.push(resultPromise); pIndex += 1; } else if (result.status === ProviderStatus.Error) { - this.logger.warn( + this.logger.debug( `Error from provider #${pIndex}: ${result.error} - ${ !isLastProvider ? ' Triggering next provider.' : '' }`, diff --git a/typescript/sdk/src/providers/transactions/schemas.test.ts b/typescript/sdk/src/providers/transactions/schemas.test.ts new file mode 100644 index 000000000..248878d30 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/schemas.test.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai'; + +import { Address } from '@hyperlane-xyz/utils'; + +import { CallDataSchema, PopulatedTransactionSchema } from './schemas.js'; +import { CallData, PopulatedTransaction } from './types.js'; + +describe('transactions schemas', () => { + const ADDRESS_MOCK: Address = '0x1234567890123456789012345678901234567890'; + const DATA_MOCK: string = '0xabcdef'; + const CHAIN_ID_MOCK: number = 1; + const VALUE_MOCK: string = '100'; + + const INVALID_ADDRESS: Address = '0x1'; + + describe('PopulatedTransactionSchema', () => { + it('should parse valid PopulatedTransaction', () => { + const validPopulatedTransaction: PopulatedTransaction = { + to: ADDRESS_MOCK, + data: DATA_MOCK, + chainId: CHAIN_ID_MOCK, + }; + const result = PopulatedTransactionSchema.safeParse( + validPopulatedTransaction, + ); + expect(result.success).to.be.true; + }); + + it('should fail parsing invalid PopulatedTransaction', () => { + const invalidPopulatedTransaction: PopulatedTransaction = { + to: INVALID_ADDRESS, + data: DATA_MOCK, + chainId: CHAIN_ID_MOCK, + }; + const result = PopulatedTransactionSchema.safeParse( + invalidPopulatedTransaction, + ); + expect(result.success).to.be.false; + }); + }); + + describe('CallDataSchema', () => { + it('should parse valid CallData', () => { + const validCallData: CallData = { + to: ADDRESS_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }; + const result = CallDataSchema.safeParse(validCallData); + expect(result.success).to.be.true; + }); + + it('should parse CallData without optional value', () => { + const validCallDataWithoutValue: CallData = { + to: ADDRESS_MOCK, + data: DATA_MOCK, + }; + const result = CallDataSchema.safeParse(validCallDataWithoutValue); + expect(result.success).to.be.true; + }); + + it('should fail parsing invalid CallData', () => { + const invalidCallData: CallData = { + to: INVALID_ADDRESS, + data: DATA_MOCK, + value: VALUE_MOCK, + }; + const result = CallDataSchema.safeParse(invalidCallData); + expect(result.success).to.be.false; + }); + }); +}); diff --git a/typescript/sdk/src/providers/transactions/schemas.ts b/typescript/sdk/src/providers/transactions/schemas.ts new file mode 100644 index 000000000..706a96179 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/schemas.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { ZHash } from '../../metadata/customZodTypes.js'; + +export const BigNumberSchema = z.string(); + +export const PopulatedTransactionSchema = z.object({ + to: ZHash, + data: z.string(), + chainId: z.number(), +}); + +export const CallDataSchema = z.object({ + to: ZHash, + data: z.string(), + value: BigNumberSchema.optional(), +}); diff --git a/typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts b/typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts index 4e38f9c25..7b3b47840 100644 --- a/typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts +++ b/typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts @@ -1,5 +1,5 @@ export enum TxSubmitterType { - JSON_RPC = 'JSON RPC', - IMPERSONATED_ACCOUNT = 'Impersonated Account', - GNOSIS_SAFE = 'Gnosis Safe', + JSON_RPC = 'jsonRpc', + IMPERSONATED_ACCOUNT = 'impersonatedAccount', + GNOSIS_SAFE = 'gnosisSafe', } diff --git a/typescript/sdk/src/providers/transactions/submitter/builder/schemas.ts b/typescript/sdk/src/providers/transactions/submitter/builder/schemas.ts new file mode 100644 index 000000000..0ccea2827 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/builder/schemas.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +import { ZChainName } from '../../../../metadata/customZodTypes.js'; +import { TransformerMetadataSchema } from '../../transformer/schemas.js'; +import { SubmitterMetadataSchema } from '../schemas.js'; + +export const SubmissionStrategySchema = z.object({ + chain: ZChainName, + submitter: SubmitterMetadataSchema, + transforms: z.array(TransformerMetadataSchema).optional(), +}); diff --git a/typescript/sdk/src/providers/transactions/submitter/builder/types.ts b/typescript/sdk/src/providers/transactions/submitter/builder/types.ts new file mode 100644 index 000000000..e8a901aca --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/builder/types.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +import { SubmissionStrategySchema } from './schemas.js'; + +export type SubmissionStrategy = z.infer; diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts index 5fb760e32..74e1ce53c 100644 --- a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts @@ -1,4 +1,3 @@ -import { PopulatedTransaction } from 'ethers'; import { Logger } from 'pino'; import { Address, assert, rootLogger } from '@hyperlane-xyz/utils'; @@ -6,10 +5,11 @@ import { Address, assert, rootLogger } from '@hyperlane-xyz/utils'; // @ts-ignore import { getSafe, getSafeService } from '../../../../utils/gnosisSafe.js'; import { MultiProvider } from '../../../MultiProvider.js'; +import { PopulatedTransaction } from '../../types.js'; import { TxSubmitterType } from '../TxSubmitterTypes.js'; import { EV5TxSubmitterInterface } from './EV5TxSubmitterInterface.js'; -import { EV5GnosisSafeTxSubmitterProps } from './EV5TxSubmitterTypes.js'; +import { EV5GnosisSafeTxSubmitterProps } from './types.js'; export class EV5GnosisSafeTxSubmitter implements EV5TxSubmitterInterface { public readonly txSubmitterType: TxSubmitterType = @@ -39,9 +39,6 @@ export class EV5GnosisSafeTxSubmitter implements EV5TxSubmitterInterface { ); const safeTransactionBatch: any[] = txs.map( ({ to, data, value, chainId }: PopulatedTransaction) => { - assert(to, 'Invalid PopulatedTransaction: Missing to field'); - assert(data, 'Invalid PopulatedTransaction: Missing data field'); - assert(chainId, 'Invalid PopulatedTransaction: Missing chainId field'); const txChain = this.multiProvider.getChainName(chainId); assert( txChain === this.props.chain, diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts index 816baf053..c184ab17d 100644 --- a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts @@ -4,12 +4,15 @@ import { Logger } from 'pino'; import { rootLogger } from '@hyperlane-xyz/utils'; -import { impersonateAccount } from '../../../../utils/fork.js'; +import { + impersonateAccount, + stopImpersonatingAccount, +} from '../../../../utils/fork.js'; import { MultiProvider } from '../../../MultiProvider.js'; import { TxSubmitterType } from '../TxSubmitterTypes.js'; import { EV5JsonRpcTxSubmitter } from './EV5JsonRpcTxSubmitter.js'; -import { EV5ImpersonatedAccountTxSubmitterProps } from './EV5TxSubmitterTypes.js'; +import { EV5ImpersonatedAccountTxSubmitterProps } from './types.js'; export class EV5ImpersonatedAccountTxSubmitter extends EV5JsonRpcTxSubmitter { public readonly txSubmitterType: TxSubmitterType = @@ -32,7 +35,9 @@ export class EV5ImpersonatedAccountTxSubmitter extends EV5JsonRpcTxSubmitter { const impersonatedAccount = await impersonateAccount( this.props.userAddress, ); - super.multiProvider.setSharedSigner(impersonatedAccount); - return await super.submit(...txs); + this.multiProvider.setSharedSigner(impersonatedAccount); + const transactionReceipts = await super.submit(...txs); + await stopImpersonatingAccount(this.props.userAddress); + return transactionReceipts; } } diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5TxSubmitterTypes.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5TxSubmitterTypes.ts deleted file mode 100644 index cf6f7f164..000000000 --- a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5TxSubmitterTypes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Address } from '@hyperlane-xyz/utils'; - -import { ChainName } from '../../../../types.js'; - -export interface EV5GnosisSafeTxSubmitterProps { - chain: ChainName; - safeAddress: Address; -} - -export interface EV5ImpersonatedAccountTxSubmitterProps { - chain: ChainName; - userAddress: Address; -} diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/schemas.test.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/schemas.test.ts new file mode 100644 index 000000000..fdffb778a --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/schemas.test.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai'; + +import { Address } from '@hyperlane-xyz/utils'; + +import { ChainName } from '../../../../types.js'; + +import { + EV5GnosisSafeTxSubmitterPropsSchema, + EV5ImpersonatedAccountTxSubmitterPropsSchema, +} from './schemas.js'; +import { + EV5GnosisSafeTxSubmitterProps, + EV5ImpersonatedAccountTxSubmitterProps, +} from './types.js'; + +describe('ethersV5 submitter props schemas', () => { + const CHAIN_MOCK: ChainName = 'ethereum'; + const ADDRESS_MOCK: Address = '0x1234567890123456789012345678901234567890'; + + const INVALID_ADDRESS: Address = '0x1'; + + describe('EV5GnosisSafeTxSubmitterPropsSchema', () => { + it('should parse valid props', () => { + const validProps: EV5GnosisSafeTxSubmitterProps = { + chain: CHAIN_MOCK, + safeAddress: ADDRESS_MOCK, + }; + const result = EV5GnosisSafeTxSubmitterPropsSchema.safeParse(validProps); + expect(result.success).to.be.true; + }); + + it('should fail parsing invalid props', () => { + const invalidProps = { + chain: CHAIN_MOCK, + }; + const result = + EV5GnosisSafeTxSubmitterPropsSchema.safeParse(invalidProps); + expect(result.success).to.be.false; + }); + }); + + describe('EV5ImpersonatedAccountTxSubmitterPropsSchema', () => { + it('should parse valid props', () => { + const validProps: EV5ImpersonatedAccountTxSubmitterProps = { + userAddress: '0x1234567890123456789012345678901234567890', + }; + const result = + EV5ImpersonatedAccountTxSubmitterPropsSchema.safeParse(validProps); + expect(result.success).to.be.true; + }); + + it('should fail parsing invalid props', () => { + const invalidProps: EV5ImpersonatedAccountTxSubmitterProps = { + userAddress: INVALID_ADDRESS, + }; + const result = + EV5ImpersonatedAccountTxSubmitterPropsSchema.safeParse(invalidProps); + expect(result.success).to.be.false; + }); + }); +}); diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/schemas.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/schemas.ts new file mode 100644 index 000000000..7b5cb9a6c --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/schemas.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { ZChainName, ZHash } from '../../../../metadata/customZodTypes.js'; + +export const EV5GnosisSafeTxSubmitterPropsSchema = z.object({ + chain: ZChainName, + safeAddress: ZHash, +}); + +export const EV5ImpersonatedAccountTxSubmitterPropsSchema = z.object({ + userAddress: ZHash, +}); diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/types.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/types.ts new file mode 100644 index 000000000..67edd01d4 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/types.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { + EV5GnosisSafeTxSubmitterPropsSchema, + EV5ImpersonatedAccountTxSubmitterPropsSchema, +} from './schemas.js'; + +export type EV5GnosisSafeTxSubmitterProps = z.infer< + typeof EV5GnosisSafeTxSubmitterPropsSchema +>; +export type EV5ImpersonatedAccountTxSubmitterProps = z.infer< + typeof EV5ImpersonatedAccountTxSubmitterPropsSchema +>; diff --git a/typescript/sdk/src/providers/transactions/submitter/schemas.ts b/typescript/sdk/src/providers/transactions/submitter/schemas.ts new file mode 100644 index 000000000..0c4185aaf --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/schemas.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { TxSubmitterType } from './TxSubmitterTypes.js'; +import { + EV5GnosisSafeTxSubmitterPropsSchema, + EV5ImpersonatedAccountTxSubmitterPropsSchema, +} from './ethersV5/schemas.js'; + +export const SubmitterMetadataSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal(TxSubmitterType.JSON_RPC), + props: z.object({}).optional(), + }), + z.object({ + type: z.literal(TxSubmitterType.IMPERSONATED_ACCOUNT), + props: EV5ImpersonatedAccountTxSubmitterPropsSchema, + }), + z.object({ + type: z.literal(TxSubmitterType.GNOSIS_SAFE), + props: EV5GnosisSafeTxSubmitterPropsSchema, + }), +]); diff --git a/typescript/sdk/src/providers/transactions/submitter/types.ts b/typescript/sdk/src/providers/transactions/submitter/types.ts new file mode 100644 index 000000000..92e79f9b4 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/types.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +import { SubmitterMetadataSchema } from './schemas.js'; + +export type SubmitterMetadata = z.infer; diff --git a/typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts b/typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts index b8e029b2c..0f04841ef 100644 --- a/typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts +++ b/typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts @@ -1,3 +1,3 @@ export enum TxTransformerType { - ICA = 'Interchain Account', + INTERCHAIN_ACCOUNT = 'interchainAccount', } diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts index 25ae331e7..9a3e659fb 100644 --- a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts +++ b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts @@ -1,19 +1,25 @@ -import { PopulatedTransaction } from 'ethers'; +import { ethers } from 'ethers'; import { Logger } from 'pino'; -import { CallData, assert, rootLogger } from '@hyperlane-xyz/utils'; +import { assert, objKeys, rootLogger } from '@hyperlane-xyz/utils'; +import { + InterchainAccount, + buildInterchainAccountApp, +} from '../../../../middleware/account/InterchainAccount.js'; import { ChainName } from '../../../../types.js'; import { MultiProvider } from '../../../MultiProvider.js'; +import { CallData, PopulatedTransaction } from '../../types.js'; import { TxTransformerType } from '../TxTransformerTypes.js'; import { EV5TxTransformerInterface } from './EV5TxTransformerInterface.js'; -import { EV5InterchainAccountTxTransformerProps } from './EV5TxTransformerTypes.js'; +import { EV5InterchainAccountTxTransformerProps } from './types.js'; export class EV5InterchainAccountTxTransformer implements EV5TxTransformerInterface { - public readonly txTransformerType: TxTransformerType = TxTransformerType.ICA; + public readonly txTransformerType: TxTransformerType = + TxTransformerType.INTERCHAIN_ACCOUNT; protected readonly logger: Logger = rootLogger.child({ module: 'ica-transformer', }); @@ -21,35 +27,48 @@ export class EV5InterchainAccountTxTransformer constructor( public readonly multiProvider: MultiProvider, public readonly props: EV5InterchainAccountTxTransformerProps, - ) {} + ) { + assert( + this.props.config.localRouter, + 'Invalid AccountConfig: Cannot retrieve InterchainAccount.', + ); + } public async transform( ...txs: PopulatedTransaction[] - ): Promise { - const txChainsToInnerCalls: Record = {}; - - txs.map(({ to, data, value, chainId }: PopulatedTransaction) => { - assert(to, 'Invalid PopulatedTransaction: Missing to field'); - assert(data, 'Invalid PopulatedTransaction: Missing data field'); - assert(chainId, 'Invalid PopulatedTransaction: Missing chainId field'); - const txChain = this.multiProvider.getChainName(chainId); - if (!txChainsToInnerCalls[txChain]) txChainsToInnerCalls[txChain] = []; - txChainsToInnerCalls[txChain].push({ to, data, value }); - }); - - const transformedTxs: Promise[] = []; - Object.keys(txChainsToInnerCalls).map((txChain: ChainName) => { + ): Promise { + const txChainsToInnerCalls: Record = txs.reduce( + ( + txChainToInnerCalls: Record, + { to, data, chainId }: PopulatedTransaction, + ) => { + const txChain = this.multiProvider.getChainName(chainId); + txChainToInnerCalls[txChain] ||= []; + txChainToInnerCalls[txChain].push({ to, data }); + return txChainToInnerCalls; + }, + {}, + ); + + const interchainAccountApp: InterchainAccount = buildInterchainAccountApp( + this.multiProvider, + this.props.chain, + this.props.config, + ); + + const transformedTxs: ethers.PopulatedTransaction[] = []; + for (const txChain of objKeys(txChainsToInnerCalls)) { transformedTxs.push( - this.props.interchainAccount.getCallRemote( - this.props.chain, - txChain, - txChainsToInnerCalls[txChain], - this.props.accountConfig, - this.props.hookMetadata, - ), + await interchainAccountApp.getCallRemote({ + chain: this.props.chain, + destination: txChain, + innerCalls: txChainsToInnerCalls[txChain], + config: this.props.config, + hookMetadata: this.props.hookMetadata, + }), ); - }); + } - return Promise.all(transformedTxs); + return transformedTxs; } } diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5TxTransformerTypes.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5TxTransformerTypes.ts deleted file mode 100644 index e8c7eb06a..000000000 --- a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5TxTransformerTypes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { InterchainAccount } from '../../../../middleware/account/InterchainAccount.js'; -import { AccountConfig } from '../../../../middleware/account/types.js'; -import { ChainName } from '../../../../types.js'; - -export interface EV5InterchainAccountTxTransformerProps { - chain: ChainName; - interchainAccount: InterchainAccount; - accountConfig: AccountConfig; - hookMetadata?: string; -} diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/schemas.test.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/schemas.test.ts new file mode 100644 index 000000000..5418f31fe --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/ethersV5/schemas.test.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai'; + +import { Address } from '@hyperlane-xyz/utils'; + +import { ChainName } from '../../../../types.js'; + +import { EV5InterchainAccountTxTransformerPropsSchema } from './schemas.js'; +import { EV5InterchainAccountTxTransformerProps } from './types.js'; + +describe('ethersV5 transformer props schemas', () => { + const CHAIN_MOCK: ChainName = 'ethereum'; + const ORIGIN_MOCK: ChainName = 'arbitrum'; + const ADDRESS_MOCK: Address = '0x1234567890123456789012345678901234567890'; + const HOOK_METADATA_MOCK: string = '1243'; + + describe('EV5InterchainAccountTxTransformerProps', () => { + it('should parse valid props', () => { + const validProps: EV5InterchainAccountTxTransformerProps = { + chain: CHAIN_MOCK, + config: { + origin: ORIGIN_MOCK, + owner: ADDRESS_MOCK, + }, + hookMetadata: HOOK_METADATA_MOCK, + }; + const result = + EV5InterchainAccountTxTransformerPropsSchema.safeParse(validProps); + expect(result.success).to.be.true; + }); + + it('should fail parsing props when required fields are missing', () => { + const invalidProps = { + chain: CHAIN_MOCK, + }; + const result = + EV5InterchainAccountTxTransformerPropsSchema.safeParse(invalidProps); + expect(result.success).to.be.false; + }); + + it('should parse props when extra fields are present', () => { + const validProps = { + chain: CHAIN_MOCK, + config: { + origin: ORIGIN_MOCK, + owner: ADDRESS_MOCK, + }, + miscData: 1234, + nonsense: 'bleh', + ish: true, + }; + const result = + EV5InterchainAccountTxTransformerPropsSchema.safeParse(validProps); + expect(result.success).to.be.true; + }); + }); +}); diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/schemas.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/schemas.ts new file mode 100644 index 000000000..7d49b0e91 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/ethersV5/schemas.ts @@ -0,0 +1,8 @@ +import { GetCallRemoteSettingsSchema } from '../../../../middleware/account/schemas.js'; + +export const EV5InterchainAccountTxTransformerPropsSchema = + GetCallRemoteSettingsSchema.pick({ + chain: true, + config: true, + hookMetadata: true, + }); diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/types.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/types.ts new file mode 100644 index 000000000..f4b848f42 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/ethersV5/types.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { EV5InterchainAccountTxTransformerPropsSchema } from './schemas.js'; + +export type EV5InterchainAccountTxTransformerProps = z.infer< + typeof EV5InterchainAccountTxTransformerPropsSchema +>; diff --git a/typescript/sdk/src/providers/transactions/transformer/schemas.ts b/typescript/sdk/src/providers/transactions/transformer/schemas.ts new file mode 100644 index 000000000..14a5bb358 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/schemas.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +import { TxTransformerType } from './TxTransformerTypes.js'; +import { EV5InterchainAccountTxTransformerPropsSchema } from './ethersV5/schemas.js'; + +export const TransformerMetadataSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal(TxTransformerType.INTERCHAIN_ACCOUNT), + props: EV5InterchainAccountTxTransformerPropsSchema, + }), +]); diff --git a/typescript/sdk/src/providers/transactions/transformer/types.ts b/typescript/sdk/src/providers/transactions/transformer/types.ts new file mode 100644 index 000000000..7f5e47912 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/types.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +import { TransformerMetadataSchema } from './schemas.js'; + +export type TransformerMetadata = z.infer; diff --git a/typescript/sdk/src/providers/transactions/types.ts b/typescript/sdk/src/providers/transactions/types.ts new file mode 100644 index 000000000..b1b91b1c4 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/types.ts @@ -0,0 +1,9 @@ +import { ethers } from 'ethers'; +import { z } from 'zod'; + +import { CallDataSchema, PopulatedTransactionSchema } from './schemas.js'; + +export type PopulatedTransaction = z.infer & + ethers.PopulatedTransaction; + +export type CallData = z.infer; diff --git a/typescript/sdk/src/router/HyperlaneRouterChecker.ts b/typescript/sdk/src/router/HyperlaneRouterChecker.ts index 06d52f748..543f3fdcf 100644 --- a/typescript/sdk/src/router/HyperlaneRouterChecker.ts +++ b/typescript/sdk/src/router/HyperlaneRouterChecker.ts @@ -1,8 +1,4 @@ -import { ethers } from 'ethers'; -import { zeroAddress } from 'viem'; - -import { IMailbox__factory } from '@hyperlane-xyz/core'; -import { addressToBytes32, eqAddress } from '@hyperlane-xyz/utils'; +import { addressToBytes32, assert, eqAddress } from '@hyperlane-xyz/utils'; import { HyperlaneFactories } from '../contracts/types.js'; import { HyperlaneAppChecker } from '../deploy/HyperlaneAppChecker.js'; @@ -15,7 +11,6 @@ import { RouterApp } from './RouterApps.js'; import { ClientViolation, ClientViolationType, - MailboxClientConfig, RouterConfig, RouterViolation, RouterViolationType, @@ -38,103 +33,76 @@ export class HyperlaneRouterChecker< async checkChain(chain: ChainName): Promise { await this.checkMailboxClient(chain); await this.checkEnrolledRouters(chain); - await super.checkOwnership(chain, this.configMap[chain].owner); + await super.checkOwnership( + chain, + this.configMap[chain].owner, + this.configMap[chain].ownerOverrides, + ); } async checkMailboxClient(chain: ChainName): Promise { const router = this.app.router(this.app.getContracts(chain)); - const checkMailboxClientProperty = async ( - property: keyof MailboxClientConfig, - actual: string, - violationType: ClientViolationType, - ) => { - const value = this.configMap[chain][property]; + const config = this.configMap[chain]; - // If the value is an object, it's an ISM config - // and we should make sure it matches the actual ISM config - if (value && typeof value === 'object') { - if (!this.ismFactory) { - throw Error( - 'ISM factory not provided to HyperlaneRouterChecker, cannot check object-based ISM config', - ); - } - - const matches = await moduleMatchesConfig( - chain, - actual, - value, - this.multiProvider, - this.ismFactory!.chainMap[chain], - ); + const mailboxAddr = await router.mailbox(); + if (!eqAddress(mailboxAddr, config.mailbox)) { + this.addViolation({ + chain, + type: ClientViolationType.Mailbox, + contract: router, + actual: mailboxAddr, + expected: config.mailbox, + }); + } - if (!matches) { - const violation: ClientViolation = { - chain, - type: violationType, - contract: router, - actual, - expected: value, - description: `ISM config does not match deployed ISM`, - }; - this.addViolation(violation); - } - return; - } - const expected = - value && typeof value === 'string' - ? value - : ethers.constants.AddressZero; - if (!eqAddress(actual, expected)) { - const violation: ClientViolation = { + if (config.hook) { + assert( + typeof config.hook === 'string', + 'Hook objects not supported in router checker', + ); + const hook = await router.hook(); + if (!eqAddress(hook, config.hook as string)) { + this.addViolation({ chain, - type: violationType, + type: ClientViolationType.Hook, contract: router, - actual, - expected, - }; - this.addViolation(violation); + actual: hook, + expected: config.hook, + }); } - }; + } - const mailboxAddr = await router.mailbox(); - await checkMailboxClientProperty( - 'mailbox', - mailboxAddr, - ClientViolationType.Mailbox, - ); - await checkMailboxClientProperty( - 'hook', - await router.hook(), - ClientViolationType.Hook, - ); + if (config.interchainSecurityModule) { + const actual = await router.interchainSecurityModule(); + if ( + typeof config.interchainSecurityModule !== 'string' && + !this.ismFactory + ) { + throw Error( + 'ISM factory not provided to HyperlaneRouterChecker, cannot check object-based ISM config', + ); + } - const mailbox = IMailbox__factory.connect( - mailboxAddr, - this.multiProvider.getProvider(chain), - ); - const ism = await mailbox.recipientIsm(router.address); + const matches = await moduleMatchesConfig( + chain, + actual, + config.interchainSecurityModule, + this.multiProvider, + this.ismFactory?.chainMap[chain] ?? ({} as any), + ); - if ( - !this.configMap[chain].interchainSecurityModule || - this.configMap[chain].interchainSecurityModule === zeroAddress - ) { - const defaultIsm = await mailbox.defaultIsm(); - if (!eqAddress(defaultIsm, ism)) { - this.addViolation({ + if (!matches) { + const violation: ClientViolation = { chain, type: ClientViolationType.InterchainSecurityModule, contract: router, - actual: ism, - expected: zeroAddress, - }); + actual, + expected: config.interchainSecurityModule, + description: `ISM config does not match deployed ISM`, + }; + this.addViolation(violation); } - } else { - await checkMailboxClientProperty( - 'interchainSecurityModule', - ism, - ClientViolationType.InterchainSecurityModule, - ); } } diff --git a/typescript/sdk/src/router/ProxiedRouterDeployer.ts b/typescript/sdk/src/router/ProxiedRouterDeployer.ts index 335a421e2..8f4d03d2f 100644 --- a/typescript/sdk/src/router/ProxiedRouterDeployer.ts +++ b/typescript/sdk/src/router/ProxiedRouterDeployer.ts @@ -9,7 +9,6 @@ import { eqAddress } from '@hyperlane-xyz/utils'; import { HyperlaneContracts, HyperlaneFactories } from '../contracts/types.js'; import { DeployerOptions } from '../deploy/HyperlaneDeployer.js'; -import { resolveOrDeployAccountOwner } from '../deploy/types.js'; import { MultiProvider } from '../providers/MultiProvider.js'; import { ChainName } from '../types.js'; @@ -100,11 +99,7 @@ export abstract class ProxiedRouterDeployer< constants.AddressZero, this.multiProvider.getProvider(chain), ); - adminOwner = await resolveOrDeployAccountOwner( - this.multiProvider, - chain, - config.owner, - ); + adminOwner = config.owner; } await super.runIfOwner(chain, proxyAdmin, async () => { diff --git a/typescript/sdk/src/router/schemas.ts b/typescript/sdk/src/router/schemas.ts index 6c6ffa031..30fd704d2 100644 --- a/typescript/sdk/src/router/schemas.ts +++ b/typescript/sdk/src/router/schemas.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; -import { OwnableConfigSchema } from '../deploy/schemas.js'; +import { HookConfigSchema } from '../hook/schemas.js'; import { IsmConfigSchema } from '../ism/schemas.js'; import { ZHash } from '../metadata/customZodTypes.js'; +import { OwnableSchema } from '../schemas.js'; -export const MailboxClientConfigSchema = OwnableConfigSchema.extend({ +export const MailboxClientConfigSchema = OwnableSchema.extend({ mailbox: ZHash, - hook: ZHash.optional(), + hook: HookConfigSchema.optional(), interchainSecurityModule: IsmConfigSchema.optional(), }); diff --git a/typescript/sdk/src/schemas.ts b/typescript/sdk/src/schemas.ts new file mode 100644 index 000000000..9e9b0ee1c --- /dev/null +++ b/typescript/sdk/src/schemas.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { ZHash } from './metadata/customZodTypes.js'; + +export const OwnableSchema = z.object({ + owner: ZHash, + ownerOverrides: z.record(ZHash).optional(), +}); + +export const PausableSchema = OwnableSchema.extend({ + paused: z.boolean(), +}); diff --git a/typescript/sdk/src/test/testUtils.ts b/typescript/sdk/src/test/testUtils.ts index 5613d6be6..35b711823 100644 --- a/typescript/sdk/src/test/testUtils.ts +++ b/typescript/sdk/src/test/testUtils.ts @@ -12,6 +12,10 @@ import { IsmType } from '../ism/types.js'; import { RouterConfig } from '../router/types.js'; import { ChainMap, ChainName } from '../types.js'; +export function randomInt(max: number, min = 0): number { + return Math.floor(Math.random() * (max - min)) + min; +} + export function randomAddress(): Address { return ethers.utils.hexlify(ethers.utils.randomBytes(20)); } @@ -59,8 +63,8 @@ export function testCoreConfig( } const TEST_ORACLE_CONFIG = { - gasPrice: ethers.utils.parseUnits('1', 'gwei'), - tokenExchangeRate: ethers.utils.parseUnits('1', 10), + gasPrice: ethers.utils.parseUnits('1', 'gwei').toString(), + tokenExchangeRate: ethers.utils.parseUnits('1', 10).toString(), }; const TEST_OVERHEAD_COST = 60000; @@ -80,6 +84,7 @@ export function testIgpConfig( return [ local, { + type: HookType.INTERCHAIN_GAS_PAYMASTER, owner, oracleKey: owner, beneficiary: owner, diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts new file mode 100644 index 000000000..0a60e2dcb --- /dev/null +++ b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts @@ -0,0 +1,212 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import hre from 'hardhat'; + +import { + ERC20Test, + ERC20Test__factory, + ERC4626Test__factory, + GasRouter, + HypERC20CollateralVaultDeposit__factory, + HypERC20__factory, + HypNative__factory, + Mailbox, + Mailbox__factory, +} from '@hyperlane-xyz/core'; +import { + HyperlaneContractsMap, + RouterConfig, + TestChainName, +} from '@hyperlane-xyz/sdk'; + +import { TestCoreApp } from '../core/TestCoreApp.js'; +import { TestCoreDeployer } from '../core/TestCoreDeployer.js'; +import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js'; +import { ProxyFactoryFactories } from '../deploy/contracts.js'; +import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { ChainMap } from '../types.js'; + +import { EvmERC20WarpModule } from './EvmERC20WarpModule.js'; +import { TokenType } from './config.js'; +import { TokenRouterConfig } from './schemas.js'; + +describe('EvmERC20WarpHyperlaneModule', async () => { + const TOKEN_NAME = 'fake'; + const TOKEN_SUPPLY = '100000000000000000000'; + const TOKEN_DECIMALS = 18; + const chain = TestChainName.test4; + let mailbox: Mailbox; + let hookAddress: string; + let ismFactory: HyperlaneIsmFactory; + let factories: HyperlaneContractsMap; + let erc20Factory: ERC20Test__factory; + let token: ERC20Test; + let signer: SignerWithAddress; + let multiProvider: MultiProvider; + let coreApp: TestCoreApp; + let routerConfigMap: ChainMap; + let baseConfig: RouterConfig; + + async function validateCoreValues(deployedToken: GasRouter) { + expect(await deployedToken.mailbox()).to.equal(mailbox.address); + expect(await deployedToken.hook()).to.equal(hookAddress); + expect(await deployedToken.interchainSecurityModule()).to.equal( + constants.AddressZero, + ); + expect(await deployedToken.owner()).to.equal(signer.address); + } + + before(async () => { + [signer] = await hre.ethers.getSigners(); + multiProvider = MultiProvider.createTestMultiProvider({ signer }); + const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); + factories = await ismFactoryDeployer.deploy( + multiProvider.mapKnownChains(() => ({})), + ); + ismFactory = new HyperlaneIsmFactory(factories, multiProvider); + coreApp = await new TestCoreDeployer(multiProvider, ismFactory).deployApp(); + routerConfigMap = coreApp.getRouterConfig(signer.address); + + erc20Factory = new ERC20Test__factory(signer); + token = await erc20Factory.deploy( + TOKEN_NAME, + TOKEN_NAME, + TOKEN_SUPPLY, + TOKEN_DECIMALS, + ); + + baseConfig = routerConfigMap[chain]; + + mailbox = Mailbox__factory.connect(baseConfig.mailbox, signer); + hookAddress = await mailbox.defaultHook(); + }); + + it('should create with a collateral config', async () => { + const config = { + ...baseConfig, + type: TokenType.collateral, + token: token.address, + hook: hookAddress, + }; + + // Deploy using WarpModule + const evmERC20WarpModule = await EvmERC20WarpModule.create({ + chain, + config, + multiProvider, + }); + + // Let's derive it's onchain token type + const { deployedTokenRoute } = evmERC20WarpModule.serialize(); + const tokenType: TokenType = + await evmERC20WarpModule.reader.deriveTokenType(deployedTokenRoute); + expect(tokenType).to.equal(TokenType.collateral); + }); + + it('should create with a collateral vault config', async () => { + const vaultFactory = new ERC4626Test__factory(signer); + const vault = await vaultFactory.deploy( + token.address, + TOKEN_NAME, + TOKEN_NAME, + ); + const config = { + type: TokenType.collateralVault, + token: vault.address, + hook: hookAddress, + ...baseConfig, + }; + + // Deploy using WarpModule + const evmERC20WarpModule = await EvmERC20WarpModule.create({ + chain, + config, + multiProvider, + }); + + // Let's derive it's onchain token type + const { deployedTokenRoute } = evmERC20WarpModule.serialize(); + const tokenType: TokenType = + await evmERC20WarpModule.reader.deriveTokenType(deployedTokenRoute); + expect(tokenType).to.equal(TokenType.collateralVault); + + // Validate onchain token values + const collateralVaultContract = + HypERC20CollateralVaultDeposit__factory.connect( + deployedTokenRoute, + signer, + ); + await validateCoreValues(collateralVaultContract); + expect(await collateralVaultContract.vault()).to.equal(vault.address); + expect(await collateralVaultContract.wrappedToken()).to.equal( + token.address, + ); + }); + + it('should create with a synthetic config', async () => { + const config = { + type: TokenType.synthetic, + token: token.address, + hook: hookAddress, + name: TOKEN_NAME, + symbol: TOKEN_NAME, + decimals: TOKEN_DECIMALS, + totalSupply: TOKEN_SUPPLY, + ...baseConfig, + }; + + // Deploy using WarpModule + const evmERC20WarpModule = await EvmERC20WarpModule.create({ + chain, + config, + multiProvider, + }); + + // Let's derive it's onchain token type + const { deployedTokenRoute } = evmERC20WarpModule.serialize(); + const tokenType: TokenType = + await evmERC20WarpModule.reader.deriveTokenType(deployedTokenRoute); + expect(tokenType).to.equal(TokenType.synthetic); + + // Validate onchain token values + const syntheticContract = HypERC20__factory.connect( + deployedTokenRoute, + signer, + ); + await validateCoreValues(syntheticContract); + expect(await syntheticContract.name()).to.equal(TOKEN_NAME); + expect(await syntheticContract.symbol()).to.equal(TOKEN_NAME); + expect(await syntheticContract.decimals()).to.equal(TOKEN_DECIMALS); + expect(await syntheticContract.totalSupply()).to.equal(TOKEN_SUPPLY); + }); + + it('should create with a native config', async () => { + const config = { + type: TokenType.native, + hook: hookAddress, + ...baseConfig, + } as TokenRouterConfig; + + // Deploy using WarpModule + const evmERC20WarpModule = await EvmERC20WarpModule.create({ + chain, + config, + multiProvider, + }); + + // Let's derive it's onchain token type + const { deployedTokenRoute } = evmERC20WarpModule.serialize(); + const tokenType: TokenType = + await evmERC20WarpModule.reader.deriveTokenType(deployedTokenRoute); + expect(tokenType).to.equal(TokenType.native); + + // Validate onchain token values + const nativeContract = HypNative__factory.connect( + deployedTokenRoute, + signer, + ); + await validateCoreValues(nativeContract); + }); +}); diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.ts b/typescript/sdk/src/token/EvmERC20WarpModule.ts new file mode 100644 index 000000000..20802e02f --- /dev/null +++ b/typescript/sdk/src/token/EvmERC20WarpModule.ts @@ -0,0 +1,92 @@ +import { Address, ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; + +import { + HyperlaneModule, + HyperlaneModuleParams, +} from '../core/AbstractHyperlaneModule.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { AnnotatedEV5Transaction } from '../providers/ProviderType.js'; +import { ChainNameOrId } from '../types.js'; + +import { EvmERC20WarpRouteReader } from './EvmERC20WarpRouteReader.js'; +import { HypERC20Deployer } from './deploy.js'; +import { TokenRouterConfig } from './schemas.js'; + +export class EvmERC20WarpModule extends HyperlaneModule< + ProtocolType.Ethereum, + TokenRouterConfig, + { + deployedTokenRoute: Address; + } +> { + protected logger = rootLogger.child({ + module: 'EvmERC20WarpModule', + }); + reader: EvmERC20WarpRouteReader; + + constructor( + protected readonly multiProvider: MultiProvider, + args: HyperlaneModuleParams< + TokenRouterConfig, + { + deployedTokenRoute: Address; + } + >, + ) { + super(args); + this.reader = new EvmERC20WarpRouteReader(multiProvider, args.chain); + } + + /** + * Retrieves the token router configuration for the specified address. + * + * @param address - The address to derive the token router configuration from. + * @returns A promise that resolves to the token router configuration. + */ + public async read(): Promise { + return this.reader.deriveWarpRouteConfig( + this.args.addresses.deployedTokenRoute, + ); + } + + /** + * Updates the Warp Route contract with the provided configuration. + * + * @remark Currently only supports updating ISM or hook. + * + * @param expectedConfig - The configuration for the token router to be updated. + * @returns An array of Ethereum transactions that were executed to update the contract, or an error if the update failed. + */ + public async update( + _expectedConfig: TokenRouterConfig, + ): Promise { + throw Error('Not implemented'); + } + + /** + * Deploys the Warp Route. + * + * @param chain - The chain to deploy the module on. + * @param config - The configuration for the token router. + * @param multiProvider - The multi-provider instance to use. + * @returns A new instance of the EvmERC20WarpHyperlaneModule. + */ + public static async create(params: { + chain: ChainNameOrId; + config: TokenRouterConfig; + multiProvider: MultiProvider; + }): Promise { + const { chain, config, multiProvider } = params; + const chainName = multiProvider.getChainName(chain); + const deployer = new HypERC20Deployer(multiProvider); + const deployedContracts = await deployer.deployContracts(chainName, config); + + return new EvmERC20WarpModule(multiProvider, { + addresses: { + deployedTokenRoute: deployedContracts[config.type].address, + }, + chain, + config, + }); + } +} diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.hardhat-test.ts new file mode 100644 index 000000000..dc791a2d6 --- /dev/null +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.hardhat-test.ts @@ -0,0 +1,237 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; +import { expect } from 'chai'; +import hre from 'hardhat'; + +import { + ERC20Test, + ERC20Test__factory, + ERC4626, + ERC4626Test__factory, + Mailbox, + Mailbox__factory, +} from '@hyperlane-xyz/core'; +import { + HyperlaneContractsMap, + RouterConfig, + TestChainName, + TokenRouterConfig, +} from '@hyperlane-xyz/sdk'; + +import { TestCoreApp } from '../core/TestCoreApp.js'; +import { TestCoreDeployer } from '../core/TestCoreDeployer.js'; +import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js'; +import { ProxyFactoryFactories } from '../deploy/contracts.js'; +import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; +import { MultiProvider } from '../providers/MultiProvider.js'; +import { ChainMap } from '../types.js'; + +import { EvmERC20WarpRouteReader } from './EvmERC20WarpRouteReader.js'; +import { TokenType } from './config.js'; +import { HypERC20Deployer } from './deploy.js'; + +describe('ERC20WarpRouterReader', async () => { + const TOKEN_NAME = 'fake'; + const TOKEN_SUPPLY = '100000000000000000000'; + const TOKEN_DECIMALS = 18; + const GAS = 65_000; + const chain = TestChainName.test4; + let ismFactory: HyperlaneIsmFactory; + let factories: HyperlaneContractsMap; + let erc20Factory: ERC20Test__factory; + let token: ERC20Test; + let signer: SignerWithAddress; + let deployer: HypERC20Deployer; + let multiProvider: MultiProvider; + let coreApp: TestCoreApp; + let routerConfigMap: ChainMap; + let baseConfig: RouterConfig; + let mailbox: Mailbox; + let evmERC20WarpRouteReader: EvmERC20WarpRouteReader; + let vault: ERC4626; + before(async () => { + [signer] = await hre.ethers.getSigners(); + multiProvider = MultiProvider.createTestMultiProvider({ signer }); + const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); + factories = await ismFactoryDeployer.deploy( + multiProvider.mapKnownChains(() => ({})), + ); + ismFactory = new HyperlaneIsmFactory(factories, multiProvider); + coreApp = await new TestCoreDeployer(multiProvider, ismFactory).deployApp(); + routerConfigMap = coreApp.getRouterConfig(signer.address); + + erc20Factory = new ERC20Test__factory(signer); + token = await erc20Factory.deploy( + TOKEN_NAME, + TOKEN_NAME, + TOKEN_SUPPLY, + TOKEN_DECIMALS, + ); + + baseConfig = routerConfigMap[chain]; + mailbox = Mailbox__factory.connect(baseConfig.mailbox, signer); + evmERC20WarpRouteReader = new EvmERC20WarpRouteReader(multiProvider, chain); + deployer = new HypERC20Deployer(multiProvider); + + const vaultFactory = new ERC4626Test__factory(signer); + vault = await vaultFactory.deploy(token.address, TOKEN_NAME, TOKEN_NAME); + }); + + it('should derive a token type from contract', async () => { + const typesToDerive = [ + TokenType.collateral, + TokenType.collateralVault, + TokenType.synthetic, + TokenType.native, + ] as const; + + await Promise.all( + typesToDerive.map(async (type) => { + // Create config + const config = { + [chain]: { + type, + token: + type === TokenType.collateralVault + ? vault.address + : token.address, + hook: await mailbox.defaultHook(), + name: TOKEN_NAME, + symbol: TOKEN_NAME, + decimals: TOKEN_DECIMALS, + totalSupply: TOKEN_SUPPLY, + gas: GAS, + ...baseConfig, + }, + }; + // Deploy warp route with config + const warpRoute = await deployer.deploy(config); + const derivedTokenType = await evmERC20WarpRouteReader.deriveTokenType( + warpRoute[chain][type].address, + ); + expect(derivedTokenType).to.equal(type); + }), + ); + }); + + it('should derive collateral config correctly', async () => { + // Create config + const config = { + [chain]: { + type: TokenType.collateral, + token: token.address, + hook: await mailbox.defaultHook(), + interchainsecurityModule: await mailbox.defaultIsm(), + ...baseConfig, + }, + }; + // Deploy with config + const warpRoute = await deployer.deploy(config); + + // Derive config and check if each value matches + const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig( + warpRoute[chain].collateral.address, + ); + for (const [key, value] of Object.entries(derivedConfig)) { + const deployedValue = (config[chain] as any)[key]; + if (deployedValue && typeof value === 'string') + expect(deployedValue).to.equal(value); + } + + // Check hook because they're potentially objects + expect(derivedConfig.hook).to.deep.equal( + await evmERC20WarpRouteReader.evmHookReader.deriveHookConfig( + config[chain].hook as string, + ), + ); + // Check ism. should return undefined + expect(derivedConfig.interchainSecurityModule).to.be.undefined; + + // Check if token values matches + if (derivedConfig.type === TokenType.collateral) { + expect(derivedConfig.name).to.equal(TOKEN_NAME); + expect(derivedConfig.symbol).to.equal(TOKEN_NAME); + expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); + } + }); + + it('should derive synthetic config correctly', async () => { + // Create config + const config = { + [chain]: { + type: TokenType.synthetic, + token: token.address, + hook: await mailbox.defaultHook(), + name: TOKEN_NAME, + symbol: TOKEN_NAME, + decimals: TOKEN_DECIMALS, + totalSupply: TOKEN_SUPPLY, + ...baseConfig, + }, + }; + // Deploy with config + const warpRoute = await deployer.deploy(config); + + // Derive config and check if each value matches + const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig( + warpRoute[chain].synthetic.address, + ); + for (const [key, value] of Object.entries(derivedConfig)) { + const deployedValue = (config[chain] as any)[key]; + if (deployedValue && typeof value === 'string') + expect(deployedValue).to.equal(value); + } + + // Check if token values matches + if (derivedConfig.type === TokenType.collateral) { + expect(derivedConfig.name).to.equal(TOKEN_NAME); + expect(derivedConfig.symbol).to.equal(TOKEN_NAME); + } + }); + + it('should derive native config correctly', async () => { + // Create config + const config = { + [chain]: { + type: TokenType.native, + hook: await mailbox.defaultHook(), + ...baseConfig, + }, + } as ChainMap; + // Deploy with config + const warpRoute = await deployer.deploy(config); + + // Derive config and check if each value matches + const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig( + warpRoute[chain].native.address, + ); + for (const [key, value] of Object.entries(derivedConfig)) { + const deployedValue = (config[chain] as any)[key]; + if (deployedValue && typeof value === 'string') + expect(deployedValue).to.equal(value); + } + + // Check if token values matches + expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); + }); + + it('should return undefined if ism is not set onchain', async () => { + // Create config + const config = { + [chain]: { + type: TokenType.collateral, + token: token.address, + hook: await mailbox.defaultHook(), + ...baseConfig, + }, + }; + // Deploy with config + const warpRoute = await deployer.deploy(config); + + // Derive config and check if each value matches + const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig( + warpRoute[chain].collateral.address, + ); + + expect(derivedConfig.interchainSecurityModule).to.be.undefined; + }); +}); diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts index d62285705..8f460b945 100644 --- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts @@ -1,33 +1,42 @@ -import { ethers, providers } from 'ethers'; +import { BigNumber, constants, providers } from 'ethers'; import { - ERC20__factory, + HypERC20CollateralVaultDeposit__factory, HypERC20Collateral__factory, - MailboxClient__factory, + HypERC20__factory, } from '@hyperlane-xyz/core'; -import { Address, eqAddress } from '@hyperlane-xyz/utils'; +import { + MailboxClientConfig, + TokenRouterConfig, + TokenType, +} from '@hyperlane-xyz/sdk'; +import { + Address, + eqAddress, + getLogLevel, + rootLogger, +} from '@hyperlane-xyz/utils'; import { DEFAULT_CONTRACT_READ_CONCURRENCY } from '../consts/concurrency.js'; import { EvmHookReader } from '../hook/EvmHookReader.js'; import { EvmIsmReader } from '../ism/EvmIsmReader.js'; import { MultiProvider } from '../providers/MultiProvider.js'; -import { MailboxClientConfig } from '../router/types.js'; -import { ChainName } from '../types.js'; +import { ChainNameOrId } from '../types.js'; -import { TokenType } from './config.js'; -import { TokenRouterConfig } from './schemas.js'; +import { CollateralExtensions } from './config.js'; import { TokenMetadata } from './types.js'; -const { AddressZero } = ethers.constants; - -export class EvmWarpRouteReader { +export class EvmERC20WarpRouteReader { + protected readonly logger = rootLogger.child({ + module: 'EvmERC20WarpRouteReader', + }); provider: providers.Provider; evmHookReader: EvmHookReader; evmIsmReader: EvmIsmReader; constructor( protected readonly multiProvider: MultiProvider, - protected readonly chain: ChainName, + protected readonly chain: ChainNameOrId, protected readonly concurrency: number = DEFAULT_CONTRACT_READ_CONCURRENCY, ) { this.provider = this.multiProvider.getProvider(chain); @@ -38,50 +47,101 @@ export class EvmWarpRouteReader { /** * Derives the configuration for a Hyperlane ERC20 router contract at the given address. * - * @param address - The address of the Hyperlane ERC20 router contract. + * @param warpRouteAddress - The address of the Hyperlane ERC20 router contract. * @returns The configuration for the Hyperlane ERC20 router. * */ async deriveWarpRouteConfig( - address: Address, - type = TokenType.collateral, + warpRouteAddress: Address, ): Promise { - const mailboxClientConfig = await this.fetchMailboxClientConfig(address); - - let token: Address; - switch (type) { - case TokenType.collateral: - token = await HypERC20Collateral__factory.connect( - address, - this.provider, - ).wrappedToken(); - break; - case TokenType.synthetic: - token = address; - break; - default: - throw new Error(`Invalid token type: ${type}`); - } - const fetchedTokenMetadata = await this.fetchTokenMetadata(token); + // Derive the config type + const type = await this.deriveTokenType(warpRouteAddress); + const fetchedBaseMetadata = await this.fetchMailboxClientConfig( + warpRouteAddress, + ); + const fetchedTokenMetadata = await this.fetchTokenMetadata( + type, + warpRouteAddress, + ); return { - type, - token: TokenType.collateral === type ? token : undefined, - ...mailboxClientConfig, + ...fetchedBaseMetadata, ...fetchedTokenMetadata, + type, } as TokenRouterConfig; } + /** + * Derives the token type for a given Warp Route address using specific methods + * + * @param warpRouteAddress - The Warp Route address to derive the token type for. + * @returns The derived token type, which can be one of: collateralVault, collateral, native, or synthetic. + */ + async deriveTokenType(warpRouteAddress: Address): Promise { + const contractTypes: Partial< + Record + > = { + collateralVault: { + factory: HypERC20CollateralVaultDeposit__factory, + method: 'vault', + }, + collateral: { + factory: HypERC20Collateral__factory, + method: 'wrappedToken', + }, + synthetic: { + factory: HypERC20__factory, + method: 'decimals', + }, + }; + + // Temporarily turn off SmartProvider logging + // Provider errors are expected because deriving will call methods that may not exist in the Bytecode + this.setSmartProviderLogLevel('silent'); + + // First, try checking token specific methods + for (const [tokenType, { factory, method }] of Object.entries( + contractTypes, + )) { + try { + const warpRoute = factory.connect(warpRouteAddress, this.provider); + await warpRoute[method](); + + this.setSmartProviderLogLevel(getLogLevel()); // returns to original level defined by rootLogger + return tokenType as TokenType; + } catch (e) { + continue; + } + } + + // Finally check native + // Using estimateGas to send 1 wei. Success implies that the Warp Route has a receive() function + try { + await this.multiProvider.estimateGas(this.chain, { + to: warpRouteAddress, + from: await this.multiProvider.getSignerAddress(this.chain), + value: BigNumber.from(1), + }); + return TokenType.native; + } catch (e) { + throw Error( + `Error accessing token specific method, implying this is not a supported token.`, + ); + } finally { + this.setSmartProviderLogLevel(getLogLevel()); // returns to original level defined by rootLogger + } + } + /** * Fetches the base metadata for a Warp Route contract. * * @param routerAddress - The address of the Warp Route contract. - * @returns The base metadata for the Warp Route contract, including the mailbox, owner, wrapped token address, hook, and interchain security module. + * @returns The base metadata for the Warp Route contract, including the mailbox, owner, hook, and ism. */ async fetchMailboxClientConfig( routerAddress: Address, ): Promise { - const warpRoute = MailboxClient__factory.connect( + const warpRoute = HypERC20Collateral__factory.connect( routerAddress, this.provider, ); @@ -92,11 +152,12 @@ export class EvmWarpRouteReader { warpRoute.interchainSecurityModule(), ]); - const derivedIsm = eqAddress(ism, AddressZero) + const derivedIsm = eqAddress(ism, constants.AddressZero) ? undefined : await this.evmIsmReader.deriveIsmConfig(ism); - // TODO: add after https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3667 is fixed - const derivedHook = eqAddress(hook, AddressZero) ? undefined : hook; + const derivedHook = eqAddress(hook, constants.AddressZero) + ? undefined + : await this.evmHookReader.deriveHookConfig(hook); return { mailbox, @@ -111,16 +172,62 @@ export class EvmWarpRouteReader { * * @param tokenAddress - The address of the token. * @returns A partial ERC20 metadata object containing the token name, symbol, total supply, and decimals. + * Throws if unsupported token type */ - async fetchTokenMetadata(tokenAddress: Address): Promise { - const erc20 = ERC20__factory.connect(tokenAddress, this.provider); - const [name, symbol, totalSupply, decimals] = await Promise.all([ + async fetchTokenMetadata( + type: TokenType, + tokenAddress: Address, + ): Promise { + if (CollateralExtensions.includes(type)) { + const erc20 = HypERC20Collateral__factory.connect( + tokenAddress, + this.provider, + ); + const token = await erc20.wrappedToken(); + const { name, symbol, decimals, totalSupply } = + await this.fetchERC20Metadata(token); + + return { name, symbol, decimals, totalSupply, token }; + } else if (type === TokenType.synthetic) { + return this.fetchERC20Metadata(tokenAddress); + } else if (type === TokenType.native) { + const chainMetadata = this.multiProvider.getChainMetadata(this.chain); + if (chainMetadata.nativeToken) { + const { name, symbol, decimals } = chainMetadata.nativeToken; + return { name, symbol, decimals, totalSupply: 0 }; + } else { + throw new Error( + `Warp route config specifies native token but chain metadata for ${this.chain} does not provide native token details`, + ); + } + } else { + throw new Error( + `Unsupported token type ${type} when fetching token metadata`, + ); + } + } + + async fetchERC20Metadata(tokenAddress: Address): Promise { + const erc20 = HypERC20__factory.connect(tokenAddress, this.provider); + const [name, symbol, decimals, totalSupply] = await Promise.all([ erc20.name(), erc20.symbol(), - erc20.totalSupply(), erc20.decimals(), + erc20.totalSupply(), ]); - return { name, symbol, totalSupply: totalSupply.toString(), decimals }; + return { name, symbol, decimals, totalSupply: totalSupply.toString() }; + } + + /** + * Conditionally sets the log level for a smart provider. + * + * @param level - The log level to set, e.g. 'debug', 'info', 'warn', 'error'. + */ + protected setSmartProviderLogLevel(level: string) { + if ('setLogLevel' in this.provider) { + //@ts-ignore + this.provider.setLogLevel(level); + } } } diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts index 4d549b865..885cdad2f 100644 --- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts @@ -305,7 +305,7 @@ export class EvmHypXERC20LockboxAdapter ); } - async getMintLimit() { + async getMintLimit(): Promise { const xERC20 = await this.hypXERC20Lockbox.xERC20(); const limit = await IXERC20__factory.connect( @@ -316,7 +316,7 @@ export class EvmHypXERC20LockboxAdapter return BigInt(limit.toString()); } - async getBurnLimit() { + async getBurnLimit(): Promise { const xERC20 = await this.hypXERC20Lockbox.xERC20(); const limit = await IXERC20__factory.connect( @@ -348,7 +348,7 @@ export class EvmHypXERC20Adapter ); } - async getMintLimit() { + async getMintLimit(): Promise { const xERC20 = await this.hypXERC20.wrappedToken(); const limit = await IXERC20__factory.connect( @@ -359,7 +359,7 @@ export class EvmHypXERC20Adapter return BigInt(limit.toString()); } - async getBurnLimit() { + async getBurnLimit(): Promise { const xERC20 = await this.hypXERC20.wrappedToken(); const limit = await IXERC20__factory.connect( diff --git a/typescript/sdk/src/token/config.ts b/typescript/sdk/src/token/config.ts index a68b2a5a9..a89264ee6 100644 --- a/typescript/sdk/src/token/config.ts +++ b/typescript/sdk/src/token/config.ts @@ -13,7 +13,12 @@ export enum TokenType { nativeScaled = 'nativeScaled', } -export const gasOverhead = (tokenType: TokenType) => { +export const CollateralExtensions = [ + TokenType.collateral, + TokenType.collateralVault, +]; + +export const gasOverhead = (tokenType: TokenType): number => { switch (tokenType) { case TokenType.fastSynthetic: case TokenType.synthetic: diff --git a/typescript/sdk/src/token/deploy.hardhat-test.ts b/typescript/sdk/src/token/deploy.hardhat-test.ts index 49323191d..772813943 100644 --- a/typescript/sdk/src/token/deploy.hardhat-test.ts +++ b/typescript/sdk/src/token/deploy.hardhat-test.ts @@ -12,7 +12,7 @@ import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDe import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; import { MultiProvider } from '../providers/MultiProvider.js'; -import { EvmWarpRouteReader } from './EvmERC20WarpRouteReader.js'; +import { EvmERC20WarpRouteReader } from './EvmERC20WarpRouteReader.js'; import { TokenType } from './config.js'; import { HypERC20Deployer } from './deploy.js'; import { TokenRouterConfig } from './schemas.js'; @@ -70,11 +70,14 @@ describe('TokenDeployer', async () => { for (const type of [TokenType.collateral, TokenType.synthetic]) { describe('ERC20WarpRouterReader', async () => { - let reader: EvmWarpRouteReader; + let reader: EvmERC20WarpRouteReader; let routerAddress: Address; before(() => { - reader = new EvmWarpRouteReader(multiProvider, TestChainName.test1); + reader = new EvmERC20WarpRouteReader( + multiProvider, + TestChainName.test1, + ); }); beforeEach(async () => { @@ -88,12 +91,9 @@ describe('TokenDeployer', async () => { routerAddress = warpRoute[chain][type].address; }); - it(`should derive TokenRouterConfig from ${type} correctly`, async () => { - const derivedConfig = await reader.deriveWarpRouteConfig( - routerAddress, - type, - ); - expect(derivedConfig).to.include(config[chain]); + it(`should derive TokenRouterConfig correctly`, async () => { + const derivedConfig = await reader.deriveWarpRouteConfig(routerAddress); + expect(derivedConfig.type).to.equal(config[chain].type); }); }); } diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts index b6098a497..9c8a4dbff 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -65,10 +65,6 @@ abstract class TokenDeployer< } async initializeArgs(_: ChainName, config: TokenRouterConfig): Promise { - // ISM config can be an object, but is not supported right now. - if (typeof config.interchainSecurityModule === 'object') { - throw new Error('Token deployer does not support ISM objects currently'); - } const defaultArgs = [ config.hook ?? constants.AddressZero, config.interchainSecurityModule ?? constants.AddressZero, diff --git a/typescript/sdk/src/utils/fork.ts b/typescript/sdk/src/utils/fork.ts index 51ed576e0..7215133ae 100644 --- a/typescript/sdk/src/utils/fork.ts +++ b/typescript/sdk/src/utils/fork.ts @@ -93,7 +93,7 @@ export const stopImpersonatingAccount = async ( ) => { rootLogger.info(`Stopping account impersonation for address (${address})...`); - if (isValidAddressEvm(address)) + if (!isValidAddressEvm(address)) throw new Error( `Cannot stop account impersonation: invalid address format: ${address}`, ); diff --git a/typescript/sdk/src/utils/logUtils.ts b/typescript/sdk/src/utils/logUtils.ts new file mode 100644 index 000000000..5e2eeb366 --- /dev/null +++ b/typescript/sdk/src/utils/logUtils.ts @@ -0,0 +1,21 @@ +import { ethers } from 'ethers'; +import { Log } from 'viem'; + +export function findMatchingLogEvents( + logs: (ethers.providers.Log | Log)[], + iface: ethers.utils.Interface, + eventName: string, +): ethers.utils.LogDescription[] { + return logs + .map((log) => { + try { + return iface.parseLog(log); + } catch (e) { + return undefined; + } + }) + .filter( + (log): log is ethers.utils.LogDescription => + !!log && log.name === eventName, + ); +} diff --git a/typescript/utils/CHANGELOG.md b/typescript/utils/CHANGELOG.md index 62d15f378..f87fa8b08 100644 --- a/typescript/utils/CHANGELOG.md +++ b/typescript/utils/CHANGELOG.md @@ -1,5 +1,7 @@ # @hyperlane-xyz/utils +## 4.0.0 + ## 3.16.0 ## 3.15.1 diff --git a/typescript/utils/package.json b/typescript/utils/package.json index e924add19..8095dec62 100644 --- a/typescript/utils/package.json +++ b/typescript/utils/package.json @@ -1,7 +1,7 @@ { "name": "@hyperlane-xyz/utils", "description": "General utilities and types for the Hyperlane network", - "version": "3.16.0", + "version": "4.0.0", "dependencies": { "@cosmjs/encoding": "^0.31.3", "@solana/web3.js": "^1.78.0", diff --git a/typescript/utils/src/index.ts b/typescript/utils/src/index.ts index fdaa20476..d12f41936 100644 --- a/typescript/utils/src/index.ts +++ b/typescript/utils/src/index.ts @@ -111,6 +111,8 @@ export { pick, promiseObjAll, stringifyObject, + normalizeConfig, + configDeepEquals, } from './objects.js'; export { difference, setEquality, symmetricDifference } from './sets.js'; export { diff --git a/typescript/utils/src/objects.ts b/typescript/utils/src/objects.ts index 131ab1f4b..4ca6221c0 100644 --- a/typescript/utils/src/objects.ts +++ b/typescript/utils/src/objects.ts @@ -1,6 +1,8 @@ +import { deepStrictEqual } from 'node:assert/strict'; import { stringify as yamlStringify } from 'yaml'; -import { ethersBigNumberSerializer } from './logging.js'; +import { ethersBigNumberSerializer, rootLogger } from './logging.js'; +import { WithAddress } from './types.js'; import { assert } from './validation.js'; export function isObject(item: any) { @@ -142,7 +144,7 @@ export function arrayToObject(keys: Array, val = true) { } export function stringifyObject( - object: object, + object: any, format: 'json' | 'yaml' = 'yaml', space?: number, ): string { @@ -154,3 +156,32 @@ export function stringifyObject( } return yamlStringify(JSON.parse(json), null, space); } + +// Function to recursively remove 'address' properties and lowercase string properties +export function normalizeConfig(obj: WithAddress): any { + if (Array.isArray(obj)) { + return obj.map(normalizeConfig); + } else if (obj !== null && typeof obj === 'object') { + const newObj: any = {}; + for (const key in obj) { + if (key !== 'address') { + newObj[key] = key === 'type' ? obj[key] : normalizeConfig(obj[key]); + } + } + return newObj; + } else if (typeof obj === 'string') { + return obj.toLowerCase(); + } + + return obj; +} + +export function configDeepEquals(v1: any, v2: any): boolean { + try { + deepStrictEqual(v1, v2); + return true; + } catch (error) { + rootLogger.info((error as Error).message); + return false; + } +} diff --git a/yarn.lock b/yarn.lock index 547bea289..80adf046c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5684,8 +5684,8 @@ __metadata: "@ethersproject/abi": "npm:*" "@ethersproject/providers": "npm:*" "@hyperlane-xyz/registry": "npm:2.1.1" - "@hyperlane-xyz/sdk": "npm:3.16.0" - "@hyperlane-xyz/utils": "npm:3.16.0" + "@hyperlane-xyz/sdk": "npm:4.0.0" + "@hyperlane-xyz/utils": "npm:4.0.0" "@inquirer/prompts": "npm:^3.0.0" "@types/mocha": "npm:^10.0.1" "@types/node": "npm:^18.14.5" @@ -5708,17 +5708,34 @@ __metadata: yaml: "npm:^2.4.1" yargs: "npm:^17.7.2" zod: "npm:^3.21.2" + zod-validation-error: "npm:^3.3.0" bin: hyperlane: ./dist/cli.js languageName: unknown linkType: soft -"@hyperlane-xyz/core@npm:3.16.0, @hyperlane-xyz/core@workspace:solidity": +"@hyperlane-xyz/core@npm:3.7.0": + version: 3.7.0 + resolution: "@hyperlane-xyz/core@npm:3.7.0" + dependencies: + "@eth-optimism/contracts": "npm:^0.6.0" + "@hyperlane-xyz/utils": "npm:3.7.0" + "@openzeppelin/contracts": "npm:^4.9.3" + "@openzeppelin/contracts-upgradeable": "npm:^v4.9.3" + peerDependencies: + "@ethersproject/abi": "*" + "@ethersproject/providers": "*" + "@types/sinon-chai": "*" + checksum: efa01d943dd5b67830bb7244291c8ba9849472e804dff589463de76d3c03e56bc8d62454b575a6621aa1b8b53cc0d1d3b752a83d34f4b328ecd85e1ff23230d5 + languageName: node + linkType: hard + +"@hyperlane-xyz/core@npm:4.0.0, @hyperlane-xyz/core@workspace:solidity": version: 0.0.0-use.local resolution: "@hyperlane-xyz/core@workspace:solidity" dependencies: "@eth-optimism/contracts": "npm:^0.6.0" - "@hyperlane-xyz/utils": "npm:3.16.0" + "@hyperlane-xyz/utils": "npm:4.0.0" "@layerzerolabs/lz-evm-oapp-v2": "npm:2.0.2" "@layerzerolabs/solidity-examples": "npm:^1.1.0" "@nomiclabs/hardhat-ethers": "npm:^2.2.3" @@ -5753,29 +5770,13 @@ __metadata: languageName: unknown linkType: soft -"@hyperlane-xyz/core@npm:3.7.0": - version: 3.7.0 - resolution: "@hyperlane-xyz/core@npm:3.7.0" - dependencies: - "@eth-optimism/contracts": "npm:^0.6.0" - "@hyperlane-xyz/utils": "npm:3.7.0" - "@openzeppelin/contracts": "npm:^4.9.3" - "@openzeppelin/contracts-upgradeable": "npm:^v4.9.3" - peerDependencies: - "@ethersproject/abi": "*" - "@ethersproject/providers": "*" - "@types/sinon-chai": "*" - checksum: efa01d943dd5b67830bb7244291c8ba9849472e804dff589463de76d3c03e56bc8d62454b575a6621aa1b8b53cc0d1d3b752a83d34f4b328ecd85e1ff23230d5 - languageName: node - linkType: hard - -"@hyperlane-xyz/helloworld@npm:3.16.0, @hyperlane-xyz/helloworld@workspace:typescript/helloworld": +"@hyperlane-xyz/helloworld@npm:4.0.0, @hyperlane-xyz/helloworld@workspace:typescript/helloworld": version: 0.0.0-use.local resolution: "@hyperlane-xyz/helloworld@workspace:typescript/helloworld" dependencies: - "@hyperlane-xyz/core": "npm:3.16.0" + "@hyperlane-xyz/core": "npm:4.0.0" "@hyperlane-xyz/registry": "npm:2.1.1" - "@hyperlane-xyz/sdk": "npm:3.16.0" + "@hyperlane-xyz/sdk": "npm:4.0.0" "@nomiclabs/hardhat-ethers": "npm:^2.2.3" "@nomiclabs/hardhat-waffle": "npm:^2.0.6" "@openzeppelin/contracts-upgradeable": "npm:^4.9.3" @@ -5822,10 +5823,10 @@ __metadata: "@ethersproject/hardware-wallets": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.2" "@google-cloud/secret-manager": "npm:^5.5.0" - "@hyperlane-xyz/helloworld": "npm:3.16.0" + "@hyperlane-xyz/helloworld": "npm:4.0.0" "@hyperlane-xyz/registry": "npm:2.1.1" - "@hyperlane-xyz/sdk": "npm:3.16.0" - "@hyperlane-xyz/utils": "npm:3.16.0" + "@hyperlane-xyz/sdk": "npm:4.0.0" + "@hyperlane-xyz/utils": "npm:4.0.0" "@nomiclabs/hardhat-ethers": "npm:^2.2.3" "@nomiclabs/hardhat-etherscan": "npm:^3.0.3" "@nomiclabs/hardhat-waffle": "npm:^2.0.6" @@ -5851,6 +5852,7 @@ __metadata: prompts: "npm:^2.4.2" tsx: "npm:^4.7.1" typescript: "npm:5.3.3" + yaml: "npm:^2.4.5" yargs: "npm:^17.7.2" peerDependencies: "@ethersproject/abi": "*" @@ -5885,15 +5887,43 @@ __metadata: languageName: node linkType: hard -"@hyperlane-xyz/sdk@npm:3.16.0, @hyperlane-xyz/sdk@workspace:typescript/sdk": +"@hyperlane-xyz/sdk@npm:3.7.0": + version: 3.7.0 + resolution: "@hyperlane-xyz/sdk@npm:3.7.0" + dependencies: + "@cosmjs/cosmwasm-stargate": "npm:^0.31.3" + "@cosmjs/stargate": "npm:^0.31.3" + "@hyperlane-xyz/core": "npm:3.7.0" + "@hyperlane-xyz/utils": "npm:3.7.0" + "@solana/spl-token": "npm:^0.3.8" + "@solana/web3.js": "npm:^1.78.0" + "@types/coingecko-api": "npm:^1.0.10" + "@types/debug": "npm:^4.1.7" + "@wagmi/chains": "npm:^1.8.0" + bignumber.js: "npm:^9.1.1" + coingecko-api: "npm:^1.0.10" + cosmjs-types: "npm:^0.9.0" + cross-fetch: "npm:^3.1.5" + debug: "npm:^4.3.4" + ethers: "npm:^5.7.2" + viem: "npm:^1.20.0" + zod: "npm:^3.21.2" + peerDependencies: + "@ethersproject/abi": "*" + "@ethersproject/providers": "*" + checksum: b124a42f34502c4dad4127723d345158f592056d7e60e17d87c84bf81664ead20232ffaff66e6c21968dfd5693ba5122910fbcaa6b7db5b05fdd5d2051592835 + languageName: node + linkType: hard + +"@hyperlane-xyz/sdk@npm:4.0.0, @hyperlane-xyz/sdk@workspace:typescript/sdk": version: 0.0.0-use.local resolution: "@hyperlane-xyz/sdk@workspace:typescript/sdk" dependencies: "@aws-sdk/client-s3": "npm:^3.74.0" "@cosmjs/cosmwasm-stargate": "npm:^0.31.3" "@cosmjs/stargate": "npm:^0.31.3" - "@hyperlane-xyz/core": "npm:3.16.0" - "@hyperlane-xyz/utils": "npm:3.16.0" + "@hyperlane-xyz/core": "npm:4.0.0" + "@hyperlane-xyz/utils": "npm:4.0.0" "@nomiclabs/hardhat-ethers": "npm:^2.2.3" "@nomiclabs/hardhat-waffle": "npm:^2.0.6" "@safe-global/api-kit": "npm:1.3.0" @@ -5933,35 +5963,19 @@ __metadata: languageName: unknown linkType: soft -"@hyperlane-xyz/sdk@npm:3.7.0": +"@hyperlane-xyz/utils@npm:3.7.0": version: 3.7.0 - resolution: "@hyperlane-xyz/sdk@npm:3.7.0" + resolution: "@hyperlane-xyz/utils@npm:3.7.0" dependencies: - "@cosmjs/cosmwasm-stargate": "npm:^0.31.3" - "@cosmjs/stargate": "npm:^0.31.3" - "@hyperlane-xyz/core": "npm:3.7.0" - "@hyperlane-xyz/utils": "npm:3.7.0" - "@solana/spl-token": "npm:^0.3.8" + "@cosmjs/encoding": "npm:^0.31.3" "@solana/web3.js": "npm:^1.78.0" - "@types/coingecko-api": "npm:^1.0.10" - "@types/debug": "npm:^4.1.7" - "@wagmi/chains": "npm:^1.8.0" bignumber.js: "npm:^9.1.1" - coingecko-api: "npm:^1.0.10" - cosmjs-types: "npm:^0.9.0" - cross-fetch: "npm:^3.1.5" - debug: "npm:^4.3.4" ethers: "npm:^5.7.2" - viem: "npm:^1.20.0" - zod: "npm:^3.21.2" - peerDependencies: - "@ethersproject/abi": "*" - "@ethersproject/providers": "*" - checksum: b124a42f34502c4dad4127723d345158f592056d7e60e17d87c84bf81664ead20232ffaff66e6c21968dfd5693ba5122910fbcaa6b7db5b05fdd5d2051592835 + checksum: c76f36913c572702b9dfe22fd868db6fed01c0da9485319e33e8d00a6b8a1bfdcecb5f61c8a3fd8ccbef0b36809e8055db62d75d0c6759d5e079ee330586bcd1 languageName: node linkType: hard -"@hyperlane-xyz/utils@npm:3.16.0, @hyperlane-xyz/utils@workspace:typescript/utils": +"@hyperlane-xyz/utils@npm:4.0.0, @hyperlane-xyz/utils@workspace:typescript/utils": version: 0.0.0-use.local resolution: "@hyperlane-xyz/utils@workspace:typescript/utils" dependencies: @@ -5979,18 +5993,6 @@ __metadata: languageName: unknown linkType: soft -"@hyperlane-xyz/utils@npm:3.7.0": - version: 3.7.0 - resolution: "@hyperlane-xyz/utils@npm:3.7.0" - dependencies: - "@cosmjs/encoding": "npm:^0.31.3" - "@solana/web3.js": "npm:^1.78.0" - bignumber.js: "npm:^9.1.1" - ethers: "npm:^5.7.2" - checksum: c76f36913c572702b9dfe22fd868db6fed01c0da9485319e33e8d00a6b8a1bfdcecb5f61c8a3fd8ccbef0b36809e8055db62d75d0c6759d5e079ee330586bcd1 - languageName: node - linkType: hard - "@hyperlane-xyz/widgets@npm:3.7.0": version: 3.7.0 resolution: "@hyperlane-xyz/widgets@npm:3.7.0" @@ -26221,6 +26223,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.4.5": + version: 2.4.5 + resolution: "yaml@npm:2.4.5" + bin: + yaml: bin.mjs + checksum: b09bf5a615a65276d433d76b8e34ad6b4c0320b85eb3f1a39da132c61ae6e2ff34eff4624e6458d96d49566c93cf43408ba5e568218293a8c6541a2006883f64 + languageName: node + linkType: hard + "yargs-parser@npm:13.1.2, yargs-parser@npm:^13.1.2": version: 13.1.2 resolution: "yargs-parser@npm:13.1.2" @@ -26375,6 +26386,15 @@ __metadata: languageName: node linkType: hard +"zod-validation-error@npm:^3.3.0": + version: 3.3.0 + resolution: "zod-validation-error@npm:3.3.0" + peerDependencies: + zod: ^3.18.0 + checksum: 19574cbc453c7a41105de572546e95191958f459dd93440f541a42c0ff209b56f1cd54e8f8ab1899430dd7c183e11cd16e8cace0bd4fc5d356ef772645210792 + languageName: node + linkType: hard + "zod@npm:^3.21.2": version: 3.21.2 resolution: "zod@npm:3.21.2"