diff --git a/.changeset/dry-foxes-battle.md b/.changeset/dry-foxes-battle.md new file mode 100644 index 000000000..eadb8427e --- /dev/null +++ b/.changeset/dry-foxes-battle.md @@ -0,0 +1,6 @@ +--- +'@hyperlane-xyz/cli': minor +--- + +Add strategyUrl detect and validation in the beginning of `warp apply` +Remove yaml transactions print from `warp apply` diff --git a/.changeset/few-goats-add.md b/.changeset/few-goats-add.md new file mode 100644 index 000000000..ffcab1f49 --- /dev/null +++ b/.changeset/few-goats-add.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +Enroll new validators. Add tx overrides when deploying ICA accounts. Core checker now surfaces owner violations for defaultHook and requiredHook. App checker temporarily ignores bytecode mismatch violations. diff --git a/.changeset/grumpy-ears-relate.md b/.changeset/grumpy-ears-relate.md new file mode 100644 index 000000000..8dbc0a515 --- /dev/null +++ b/.changeset/grumpy-ears-relate.md @@ -0,0 +1,6 @@ +--- +'@hyperlane-xyz/cli': minor +'@hyperlane-xyz/sdk': minor +--- + +Adds the warp check command to compare warp routes config files with on chain warp route deployments diff --git a/.changeset/itchy-singers-hang.md b/.changeset/itchy-singers-hang.md new file mode 100644 index 000000000..97096ff1a --- /dev/null +++ b/.changeset/itchy-singers-hang.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/core': patch +--- + +Patched OPL2ToL1Ism to check for correct messageId for external call in verify diff --git a/.changeset/plenty-chicken-clean.md b/.changeset/plenty-chicken-clean.md new file mode 100644 index 000000000..e35a59eff --- /dev/null +++ b/.changeset/plenty-chicken-clean.md @@ -0,0 +1,6 @@ +--- +'@hyperlane-xyz/cli': minor +'@hyperlane-xyz/sdk': minor +--- + +Add rebasing yield route support into CLI/SDK diff --git a/.changeset/red-actors-shop.md b/.changeset/red-actors-shop.md new file mode 100644 index 000000000..0ee301e90 --- /dev/null +++ b/.changeset/red-actors-shop.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/core': patch +--- + +Added nonce to HypERC4626 diff --git a/.changeset/silver-dancers-rhyme.md b/.changeset/silver-dancers-rhyme.md new file mode 100644 index 000000000..14ce7dd00 --- /dev/null +++ b/.changeset/silver-dancers-rhyme.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/infra': minor +--- + +Updates the warpIds for Renzo's latest deployment to Sei and Taiko to be used by the Checker diff --git a/.changeset/sweet-humans-argue.md b/.changeset/sweet-humans-argue.md new file mode 100644 index 000000000..3a6ff4647 --- /dev/null +++ b/.changeset/sweet-humans-argue.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/core': minor +--- + +Added PRECISION and rateUpdateNonce to ensure compatibility of HypERC4626 diff --git a/.changeset/tricky-mangos-sin.md b/.changeset/tricky-mangos-sin.md new file mode 100644 index 000000000..31ea11b57 --- /dev/null +++ b/.changeset/tricky-mangos-sin.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/cli': minor +--- + +updates the multi chain selection prompt by adding search functionality and an optional confirmation prompt for the current selection diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1b7d7694..b37075002 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -234,40 +234,6 @@ jobs: RUST_BACKTRACE: 'full' SEALEVEL_ENABLED: ${{ steps.check-rust-changes.outputs.rust_changes }} - cli-advanced-e2e: - runs-on: ubuntu-latest - 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-install] - strategy: - matrix: - include: - - test-type: preset_hook_enabled - - test-type: configure_hook_enabled - - test-type: pi_with_core_chain - steps: - - uses: actions/setup-node@v4 - with: - node-version: 18 - - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - submodules: recursive - - - name: foundry-install - uses: foundry-rs/foundry-toolchain@v1 - - - name: yarn-build - uses: ./.github/actions/yarn-build-with-cache - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - - name: Checkout registry - uses: ./.github/actions/checkout-registry - - - name: cli e2e tests - run: ./typescript/cli/ci-advanced-test.sh ${{ matrix.test-type }} - env-test: runs-on: ubuntu-latest env: diff --git a/rust/main/agents/relayer/src/msg/op_queue.rs b/rust/main/agents/relayer/src/msg/op_queue.rs index 41ac5b6d6..d22e0c3c8 100644 --- a/rust/main/agents/relayer/src/msg/op_queue.rs +++ b/rust/main/agents/relayer/src/msg/op_queue.rs @@ -29,12 +29,10 @@ impl OpQueue { /// it's very likely that its status has just changed, so this forces the caller to consider the new status #[instrument(skip(self), ret, fields(queue_label=%self.queue_metrics_label), level = "trace")] pub async fn push(&self, mut op: QueueOperation, new_status: Option) { - if let Some(new_status) = new_status { - op.set_status_and_update_metrics( - new_status, - Arc::new(self.get_operation_metric(op.as_ref())), - ); - } + op.set_status_and_update_metrics( + new_status, + Arc::new(self.get_operation_metric(op.as_ref())), + ); self.queue.lock().await.push(Reverse(op)); } diff --git a/rust/main/agents/relayer/src/msg/op_submitter.rs b/rust/main/agents/relayer/src/msg/op_submitter.rs index 0694595ae..f35a991c4 100644 --- a/rust/main/agents/relayer/src/msg/op_submitter.rs +++ b/rust/main/agents/relayer/src/msg/op_submitter.rs @@ -293,6 +293,7 @@ async fn prepare_task( } PendingOperationResult::Drop => { metrics.ops_dropped.inc(); + op.decrement_metric_if_exists(); } PendingOperationResult::Confirm(reason) => { debug!(?op, "Pushing operation to confirm queue"); @@ -369,6 +370,7 @@ async fn submit_single_operation( } PendingOperationResult::Drop => { // Not expected to hit this case in `submit`, but it's here for completeness + op.decrement_metric_if_exists(); } PendingOperationResult::Success | PendingOperationResult::Confirm(_) => { confirm_op(op, confirm_queue, metrics).await @@ -457,9 +459,7 @@ async fn confirm_operation( PendingOperationResult::Success => { debug!(?op, "Operation confirmed"); metrics.ops_confirmed.inc(); - if let Some(metric) = op.get_metric() { - metric.dec() - } + op.decrement_metric_if_exists(); } PendingOperationResult::NotReady => { confirm_queue.push(op, None).await; @@ -478,6 +478,7 @@ async fn confirm_operation( } PendingOperationResult::Drop => { metrics.ops_dropped.inc(); + op.decrement_metric_if_exists(); } } operation_result diff --git a/rust/main/chains/hyperlane-cosmos/src/providers/rpc/provider.rs b/rust/main/chains/hyperlane-cosmos/src/providers/rpc/provider.rs index f9ec3e975..20b6ac714 100644 --- a/rust/main/chains/hyperlane-cosmos/src/providers/rpc/provider.rs +++ b/rust/main/chains/hyperlane-cosmos/src/providers/rpc/provider.rs @@ -14,7 +14,7 @@ use tendermint_rpc::endpoint::block_results::Response as BlockResultsResponse; use tendermint_rpc::endpoint::tx; use tendermint_rpc::HttpClient; use time::OffsetDateTime; -use tracing::{debug, instrument, trace}; +use tracing::{debug, info, instrument, trace}; use hyperlane_core::{ ChainCommunicationError, ChainResult, ContractLocator, HyperlaneDomain, LogMeta, H256, U256, @@ -249,6 +249,9 @@ impl WasmRpcProvider for CosmosWasmRpcProvider { // The two calls below could be made in parallel, but on cosmos rate limiting is a bigger problem // than indexing latency, so we do them sequentially. let block = self.rpc_client.get_block(block_number).await?; + + debug!(?block_number, block_hash = ?block.block_id.hash, cursor_label, domain=?self.domain, "Getting logs in block with hash"); + let block_results = self.rpc_client.get_block_results(block_number).await?; Ok(self.handle_txs(block, block_results, parser, cursor_label)) @@ -268,7 +271,12 @@ impl WasmRpcProvider for CosmosWasmRpcProvider { debug!(?hash, cursor_label, domain=?self.domain, "Getting logs in transaction"); let tx = self.rpc_client.get_tx_by_hash(hash).await?; - let block = self.rpc_client.get_block(tx.height.value() as u32).await?; + + let block_number = tx.height.value() as u32; + let block = self.rpc_client.get_block(block_number).await?; + + debug!(?block_number, block_hash = ?block.block_id.hash, cursor_label, domain=?self.domain, "Getting logs in transaction: block info"); + let block_hash = H256::from_slice(block.block_id.hash.as_bytes()); Ok(self.handle_tx(tx, block_hash, parser).collect()) diff --git a/rust/main/chains/hyperlane-sealevel/src/client.rs b/rust/main/chains/hyperlane-sealevel/src/client.rs deleted file mode 100644 index cc41cd0b2..000000000 --- a/rust/main/chains/hyperlane-sealevel/src/client.rs +++ /dev/null @@ -1,29 +0,0 @@ -use solana_client::nonblocking::rpc_client::RpcClient; -use solana_sdk::commitment_config::CommitmentConfig; - -/// Kludge to implement Debug for RpcClient. -pub struct RpcClientWithDebug(RpcClient); - -impl RpcClientWithDebug { - pub fn new(rpc_endpoint: String) -> Self { - Self(RpcClient::new(rpc_endpoint)) - } - - pub fn new_with_commitment(rpc_endpoint: String, commitment: CommitmentConfig) -> Self { - Self(RpcClient::new_with_commitment(rpc_endpoint, commitment)) - } -} - -impl std::fmt::Debug for RpcClientWithDebug { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("RpcClient { ... }") - } -} - -impl std::ops::Deref for RpcClientWithDebug { - type Target = RpcClient; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} diff --git a/rust/main/chains/hyperlane-sealevel/src/interchain_gas.rs b/rust/main/chains/hyperlane-sealevel/src/interchain_gas.rs index 8c0972c9a..d2f78eb4b 100644 --- a/rust/main/chains/hyperlane-sealevel/src/interchain_gas.rs +++ b/rust/main/chains/hyperlane-sealevel/src/interchain_gas.rs @@ -16,9 +16,7 @@ use solana_client::{ use std::ops::RangeInclusive; use tracing::{info, instrument}; -use crate::{ - client::RpcClientWithDebug, utils::get_finalized_block_number, ConnectionConf, SealevelProvider, -}; +use crate::{ConnectionConf, SealevelProvider, SealevelRpcClient}; use solana_sdk::{commitment_config::CommitmentConfig, pubkey::Pubkey}; use derive_new::new; @@ -60,20 +58,14 @@ impl SealevelInterchainGasPaymaster { } async fn determine_igp_program_id( - rpc_client: &RpcClientWithDebug, + rpc_client: &SealevelRpcClient, igp_account_pubkey: &H256, ) -> ChainResult { let account = rpc_client - .get_account_with_commitment( - &Pubkey::from(<[u8; 32]>::from(*igp_account_pubkey)), - CommitmentConfig::finalized(), - ) - .await - .map_err(ChainCommunicationError::from_other)? - .value - .ok_or_else(|| { - ChainCommunicationError::from_other_str("Could not find IGP account for pubkey") - })?; + .get_account_with_finalized_commitment(&Pubkey::from(<[u8; 32]>::from( + *igp_account_pubkey, + ))) + .await?; Ok(account.owner) } } @@ -99,7 +91,7 @@ impl InterchainGasPaymaster for SealevelInterchainGasPaymaster {} /// Struct that retrieves event data for a Sealevel IGP contract #[derive(Debug)] pub struct SealevelInterchainGasPaymasterIndexer { - rpc_client: RpcClientWithDebug, + rpc_client: SealevelRpcClient, igp: SealevelInterchainGasPaymaster, } @@ -118,10 +110,7 @@ impl SealevelInterchainGasPaymasterIndexer { igp_account_locator: ContractLocator<'_>, ) -> ChainResult { // Set the `processed` commitment at rpc level - let rpc_client = RpcClientWithDebug::new_with_commitment( - conf.url.to_string(), - CommitmentConfig::processed(), - ); + let rpc_client = SealevelRpcClient::new(conf.url.to_string()); let igp = SealevelInterchainGasPaymaster::new(conf, &igp_account_locator).await?; Ok(Self { rpc_client, igp }) @@ -169,8 +158,7 @@ impl SealevelInterchainGasPaymasterIndexer { let accounts = self .rpc_client .get_program_accounts_with_config(&self.igp.program_id, config) - .await - .map_err(ChainCommunicationError::from_other)?; + .await?; tracing::debug!(accounts=?accounts, "Fetched program accounts"); @@ -202,13 +190,8 @@ impl SealevelInterchainGasPaymasterIndexer { // Now that we have the valid gas payment PDA pubkey, we can get the full account data. let account = self .rpc_client - .get_account_with_commitment(&valid_payment_pda_pubkey, CommitmentConfig::finalized()) - .await - .map_err(ChainCommunicationError::from_other)? - .value - .ok_or_else(|| { - ChainCommunicationError::from_other_str("Could not find account data") - })?; + .get_account_with_finalized_commitment(&valid_payment_pda_pubkey) + .await?; let gas_payment_account = GasPaymentAccount::fetch(&mut account.data.as_ref()) .map_err(ChainCommunicationError::from_other)? .into_inner(); @@ -274,7 +257,7 @@ impl Indexer for SealevelInterchainGasPaymasterIndexer { #[instrument(level = "debug", err, ret, skip(self))] #[allow(clippy::blocks_in_conditions)] // TODO: `rustc` 1.80.1 clippy issue async fn get_finalized_block_number(&self) -> ChainResult { - get_finalized_block_number(&self.rpc_client).await + self.rpc_client.get_block_height().await } } @@ -285,13 +268,8 @@ impl SequenceAwareIndexer for SealevelInterchainGasPaymast async fn latest_sequence_count_and_tip(&self) -> ChainResult<(Option, u32)> { let program_data_account = self .rpc_client - .get_account_with_commitment(&self.igp.data_pda_pubkey, CommitmentConfig::finalized()) - .await - .map_err(ChainCommunicationError::from_other)? - .value - .ok_or_else(|| { - ChainCommunicationError::from_other_str("Could not find account data") - })?; + .get_account_with_finalized_commitment(&self.igp.data_pda_pubkey) + .await?; let program_data = ProgramDataAccount::fetch(&mut program_data_account.data.as_ref()) .map_err(ChainCommunicationError::from_other)? .into_inner(); @@ -299,7 +277,7 @@ impl SequenceAwareIndexer for SealevelInterchainGasPaymast .payment_count .try_into() .map_err(StrOrIntParseError::from)?; - let tip = get_finalized_block_number(&self.rpc_client).await?; + let tip = self.rpc_client.get_block_height().await?; Ok((Some(payment_count), tip)) } } diff --git a/rust/main/chains/hyperlane-sealevel/src/interchain_security_module.rs b/rust/main/chains/hyperlane-sealevel/src/interchain_security_module.rs index 0f92432eb..aaf2683fd 100644 --- a/rust/main/chains/hyperlane-sealevel/src/interchain_security_module.rs +++ b/rust/main/chains/hyperlane-sealevel/src/interchain_security_module.rs @@ -10,7 +10,7 @@ use hyperlane_core::{ use hyperlane_sealevel_interchain_security_module_interface::InterchainSecurityModuleInstruction; use serializable_account_meta::SimulationReturnData; -use crate::{utils::simulate_instruction, ConnectionConf, RpcClientWithDebug, SealevelProvider}; +use crate::{ConnectionConf, SealevelProvider, SealevelRpcClient}; /// A reference to an InterchainSecurityModule contract on some Sealevel chain #[derive(Debug)] @@ -32,7 +32,7 @@ impl SealevelInterchainSecurityModule { } } - fn rpc(&self) -> &RpcClientWithDebug { + fn rpc(&self) -> &SealevelRpcClient { self.provider.rpc() } } @@ -64,18 +64,19 @@ impl InterchainSecurityModule for SealevelInterchainSecurityModule { vec![], ); - let module = simulate_instruction::>( - self.rpc(), - self.payer - .as_ref() - .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, - instruction, - ) - .await? - .ok_or_else(|| { - ChainCommunicationError::from_other_str("No return data was returned from the ISM") - })? - .return_data; + let module = self + .rpc() + .simulate_instruction::>( + self.payer + .as_ref() + .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, + instruction, + ) + .await? + .ok_or_else(|| { + ChainCommunicationError::from_other_str("No return data was returned from the ISM") + })? + .return_data; if let Some(module_type) = ModuleType::from_u32(module) { Ok(module_type) diff --git a/rust/main/chains/hyperlane-sealevel/src/lib.rs b/rust/main/chains/hyperlane-sealevel/src/lib.rs index 8cd8830f5..04e2218c6 100644 --- a/rust/main/chains/hyperlane-sealevel/src/lib.rs +++ b/rust/main/chains/hyperlane-sealevel/src/lib.rs @@ -5,12 +5,12 @@ #![deny(warnings)] pub use crate::multisig_ism::*; -pub(crate) use client::RpcClientWithDebug; pub use interchain_gas::*; pub use interchain_security_module::*; pub use mailbox::*; pub use merkle_tree_hook::*; pub use provider::*; +pub(crate) use rpc::SealevelRpcClient; pub use solana_sdk::signer::keypair::Keypair; pub use trait_builder::*; pub use validator_announce::*; @@ -22,8 +22,6 @@ mod mailbox; mod merkle_tree_hook; mod multisig_ism; mod provider; +mod rpc; mod trait_builder; -mod utils; - -mod client; mod validator_announce; diff --git a/rust/main/chains/hyperlane-sealevel/src/mailbox.rs b/rust/main/chains/hyperlane-sealevel/src/mailbox.rs index f101435c2..952599c42 100644 --- a/rust/main/chains/hyperlane-sealevel/src/mailbox.rs +++ b/rust/main/chains/hyperlane-sealevel/src/mailbox.rs @@ -8,11 +8,12 @@ use jsonrpc_core::futures_util::TryFutureExt; use tracing::{debug, info, instrument, warn}; use hyperlane_core::{ - accumulator::incremental::IncrementalMerkle, BatchItem, ChainCommunicationError, ChainResult, - Checkpoint, ContractLocator, Decode as _, Encode as _, FixedPointNumber, HyperlaneAbi, - HyperlaneChain, HyperlaneContract, HyperlaneDomain, HyperlaneMessage, HyperlaneProvider, - Indexed, Indexer, KnownHyperlaneDomain, LogMeta, Mailbox, MerkleTreeHook, SequenceAwareIndexer, - TxCostEstimate, TxOutcome, H256, H512, U256, + accumulator::incremental::IncrementalMerkle, BatchItem, ChainCommunicationError, + ChainCommunicationError::ContractError, ChainResult, Checkpoint, ContractLocator, Decode as _, + Encode as _, FixedPointNumber, HyperlaneAbi, HyperlaneChain, HyperlaneContract, + HyperlaneDomain, HyperlaneMessage, HyperlaneProvider, Indexed, Indexer, KnownHyperlaneDomain, + LogMeta, Mailbox, MerkleTreeHook, SequenceAwareIndexer, TxCostEstimate, TxOutcome, H256, H512, + U256, }; use hyperlane_sealevel_interchain_security_module_interface::{ InterchainSecurityModuleInstruction, VerifyInstruction, @@ -54,11 +55,7 @@ use solana_transaction_status::{ UiTransaction, UiTransactionReturnData, UiTransactionStatusMeta, }; -use crate::RpcClientWithDebug; -use crate::{ - utils::{get_account_metas, get_finalized_block_number, simulate_instruction}, - ConnectionConf, SealevelProvider, -}; +use crate::{ConnectionConf, SealevelProvider, SealevelRpcClient}; const SYSTEM_PROGRAM: &str = "11111111111111111111111111111111"; const SPL_NOOP: &str = "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV"; @@ -128,7 +125,7 @@ impl SealevelMailbox { self.outbox } - pub fn rpc(&self) -> &RpcClientWithDebug { + pub fn rpc(&self) -> &SealevelRpcClient { self.provider.rpc() } @@ -140,14 +137,14 @@ impl SealevelMailbox { &self, instruction: Instruction, ) -> ChainResult> { - simulate_instruction( - &self.rpc(), - self.payer - .as_ref() - .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, - instruction, - ) - .await + self.rpc() + .simulate_instruction( + self.payer + .as_ref() + .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, + instruction, + ) + .await } /// Simulates an Instruction that will return a list of AccountMetas. @@ -155,14 +152,14 @@ impl SealevelMailbox { &self, instruction: Instruction, ) -> ChainResult> { - get_account_metas( - &self.rpc(), - self.payer - .as_ref() - .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, - instruction, - ) - .await + self.rpc() + .get_account_metas( + self.payer + .as_ref() + .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, + instruction, + ) + .await } /// Gets the recipient ISM given a recipient program id and the ISM getter account metas. @@ -293,7 +290,6 @@ impl SealevelMailbox { .rpc() .send_and_confirm_transaction(transaction) .await - .map_err(ChainCommunicationError::from_other) } } @@ -343,13 +339,10 @@ impl SealevelMailbox { ); let recent_blockhash = if transaction.uses_durable_nonce() { - let (recent_blockhash, ..) = self - .provider + self.provider .rpc() .get_latest_blockhash_with_commitment(CommitmentConfig::processed()) - .await - .map_err(ChainCommunicationError::from_other)?; - recent_blockhash + .await? } else { *transaction.get_recent_blockhash() }; @@ -359,8 +352,7 @@ impl SealevelMailbox { .provider .rpc() .get_signature_statuses(&[*signature]) - .await - .map_err(ChainCommunicationError::from_other)?; + .await?; let signature_status = signature_statuses.value.first().cloned().flatten(); match signature_status { Some(_) => return Ok(*signature), @@ -368,9 +360,8 @@ impl SealevelMailbox { if !self .provider .rpc() - .is_blockhash_valid(&recent_blockhash, CommitmentConfig::processed()) - .await - .map_err(ChainCommunicationError::from_other)? + .is_blockhash_valid(&recent_blockhash) + .await? { // Block hash is not found by some reason break 'sending; @@ -439,23 +430,15 @@ impl Mailbox for SealevelMailbox { let account = self .rpc() - .get_account_with_commitment( - &processed_message_account_key, - CommitmentConfig::finalized(), - ) - .await - .map_err(ChainCommunicationError::from_other)?; + .get_possible_account_with_finalized_commitment(&processed_message_account_key) + .await?; - Ok(account.value.is_some()) + Ok(account.is_some()) } #[instrument(err, ret, skip(self))] async fn default_ism(&self) -> ChainResult { - let inbox_account = self - .rpc() - .get_account(&self.inbox.0) - .await - .map_err(ChainCommunicationError::from_other)?; + let inbox_account = self.rpc().get_account(&self.inbox.0).await?; let inbox = InboxAccount::fetch(&mut inbox_account.data.as_ref()) .map_err(ChainCommunicationError::from_other)? .into_inner(); @@ -591,11 +574,10 @@ impl Mailbox for SealevelMailbox { accounts, }; instructions.push(inbox_instruction); - let (recent_blockhash, _) = self + let recent_blockhash = self .rpc() .get_latest_blockhash_with_commitment(commitment) - .await - .map_err(ChainCommunicationError::from_other)?; + .await?; let txn = Transaction::new_signed_with_payer( &instructions, @@ -615,7 +597,6 @@ impl Mailbox for SealevelMailbox { .confirm_transaction_with_commitment(&signature, commitment) .await .map_err(|err| warn!("Failed to confirm inbox process transaction: {}", err)) - .map(|ctx| ctx.value) .unwrap_or(false); let txid = signature.into(); @@ -664,20 +645,12 @@ impl SealevelMailboxIndexer { }) } - fn rpc(&self) -> &RpcClientWithDebug { + fn rpc(&self) -> &SealevelRpcClient { &self.mailbox.rpc() } async fn get_finalized_block_number(&self) -> ChainResult { - let height = self - .rpc() - .get_block_height() - .await - .map_err(ChainCommunicationError::from_other)? - .try_into() - // FIXME solana block height is u64... - .expect("sealevel block height exceeds u32::MAX"); - Ok(height) + self.rpc().get_block_height().await } async fn get_message_with_nonce( @@ -718,8 +691,7 @@ impl SealevelMailboxIndexer { let accounts = self .rpc() .get_program_accounts_with_config(&self.mailbox.program_id, config) - .await - .map_err(ChainCommunicationError::from_other)?; + .await?; // Now loop through matching accounts and find the one with a valid account pubkey // that proves it's an actual message storage PDA. @@ -752,16 +724,8 @@ impl SealevelMailboxIndexer { // Now that we have the valid message storage PDA pubkey, we can get the full account data. let account = self .rpc() - .get_account_with_commitment( - &valid_message_storage_pda_pubkey, - CommitmentConfig::finalized(), - ) - .await - .map_err(ChainCommunicationError::from_other)? - .value - .ok_or_else(|| { - ChainCommunicationError::from_other_str("Could not find account data") - })?; + .get_account_with_finalized_commitment(&valid_message_storage_pda_pubkey) + .await?; let dispatched_message_account = DispatchedMessageAccount::fetch(&mut account.data.as_ref()) .map_err(ChainCommunicationError::from_other)? @@ -816,7 +780,7 @@ impl Indexer for SealevelMailboxIndexer { } async fn get_finalized_block_number(&self) -> ChainResult { - get_finalized_block_number(&self.rpc()).await + self.get_finalized_block_number().await } } diff --git a/rust/main/chains/hyperlane-sealevel/src/merkle_tree_hook.rs b/rust/main/chains/hyperlane-sealevel/src/merkle_tree_hook.rs index 3778627b2..947f7e70a 100644 --- a/rust/main/chains/hyperlane-sealevel/src/merkle_tree_hook.rs +++ b/rust/main/chains/hyperlane-sealevel/src/merkle_tree_hook.rs @@ -8,7 +8,6 @@ use hyperlane_core::{ MerkleTreeInsertion, SequenceAwareIndexer, }; use hyperlane_sealevel_mailbox::accounts::OutboxAccount; -use solana_sdk::commitment_config::CommitmentConfig; use tracing::instrument; use crate::{SealevelMailbox, SealevelMailboxIndexer}; @@ -25,13 +24,8 @@ impl MerkleTreeHook for SealevelMailbox { let outbox_account = self .rpc() - .get_account_with_commitment(&self.outbox.0, CommitmentConfig::finalized()) - .await - .map_err(ChainCommunicationError::from_other)? - .value - .ok_or_else(|| { - ChainCommunicationError::from_other_str("Could not find account data") - })?; + .get_account_with_finalized_commitment(&self.outbox.0) + .await?; let outbox = OutboxAccount::fetch(&mut outbox_account.data.as_ref()) .map_err(ChainCommunicationError::from_other)? .into_inner(); diff --git a/rust/main/chains/hyperlane-sealevel/src/multisig_ism.rs b/rust/main/chains/hyperlane-sealevel/src/multisig_ism.rs index 794e19c14..a3cdb1273 100644 --- a/rust/main/chains/hyperlane-sealevel/src/multisig_ism.rs +++ b/rust/main/chains/hyperlane-sealevel/src/multisig_ism.rs @@ -1,9 +1,9 @@ use async_trait::async_trait; - use hyperlane_core::{ ChainCommunicationError, ChainResult, ContractLocator, HyperlaneChain, HyperlaneContract, HyperlaneDomain, HyperlaneMessage, HyperlaneProvider, MultisigIsm, RawHyperlaneMessage, H256, }; +use hyperlane_sealevel_multisig_ism_message_id::instruction::ValidatorsAndThreshold; use serializable_account_meta::SimulationReturnData; use solana_sdk::{ instruction::{AccountMeta, Instruction}, @@ -11,12 +11,8 @@ use solana_sdk::{ signature::Keypair, }; -use crate::{ - utils::{get_account_metas, simulate_instruction}, - ConnectionConf, RpcClientWithDebug, SealevelProvider, -}; +use crate::{ConnectionConf, SealevelProvider, SealevelRpcClient}; -use hyperlane_sealevel_multisig_ism_message_id::instruction::ValidatorsAndThreshold; use multisig_ism::interface::{ MultisigIsmInstruction, VALIDATORS_AND_THRESHOLD_ACCOUNT_METAS_PDA_SEEDS, }; @@ -44,7 +40,7 @@ impl SealevelMultisigIsm { } } - fn rpc(&self) -> &RpcClientWithDebug { + fn rpc(&self) -> &SealevelRpcClient { self.provider.rpc() } } @@ -86,9 +82,9 @@ impl MultisigIsm for SealevelMultisigIsm { account_metas, ); - let validators_and_threshold = - simulate_instruction::>( - self.rpc(), + let validators_and_threshold = self + .rpc() + .simulate_instruction::>( self.payer .as_ref() .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, @@ -135,13 +131,13 @@ impl SealevelMultisigIsm { vec![AccountMeta::new_readonly(account_metas_pda_key, false)], ); - get_account_metas( - self.rpc(), - self.payer - .as_ref() - .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, - instruction, - ) - .await + self.rpc() + .get_account_metas( + self.payer + .as_ref() + .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?, + instruction, + ) + .await } } diff --git a/rust/main/chains/hyperlane-sealevel/src/provider.rs b/rust/main/chains/hyperlane-sealevel/src/provider.rs index 42b266550..b292d9594 100644 --- a/rust/main/chains/hyperlane-sealevel/src/provider.rs +++ b/rust/main/chains/hyperlane-sealevel/src/provider.rs @@ -6,44 +6,30 @@ use hyperlane_core::{ BlockInfo, ChainInfo, ChainResult, HyperlaneChain, HyperlaneDomain, HyperlaneProvider, TxnInfo, H256, U256, }; -use solana_sdk::{commitment_config::CommitmentConfig, pubkey::Pubkey}; +use solana_sdk::pubkey::Pubkey; -use crate::{client::RpcClientWithDebug, error::HyperlaneSealevelError, ConnectionConf}; +use crate::{error::HyperlaneSealevelError, ConnectionConf, SealevelRpcClient}; /// A wrapper around a Sealevel provider to get generic blockchain information. #[derive(Debug)] pub struct SealevelProvider { domain: HyperlaneDomain, - rpc_client: Arc, + rpc_client: Arc, } impl SealevelProvider { /// Create a new Sealevel provider. pub fn new(domain: HyperlaneDomain, conf: &ConnectionConf) -> Self { // Set the `processed` commitment at rpc level - let rpc_client = Arc::new(RpcClientWithDebug::new_with_commitment( - conf.url.to_string(), - CommitmentConfig::processed(), - )); + let rpc_client = Arc::new(SealevelRpcClient::new(conf.url.to_string())); SealevelProvider { domain, rpc_client } } /// Get an rpc client - pub fn rpc(&self) -> &RpcClientWithDebug { + pub fn rpc(&self) -> &SealevelRpcClient { &self.rpc_client } - - /// Get the balance of an address - pub async fn get_balance(&self, address: String) -> ChainResult { - let pubkey = Pubkey::from_str(&address).map_err(Into::::into)?; - let balance = self - .rpc_client - .get_balance(&pubkey) - .await - .map_err(Into::::into)?; - Ok(balance.into()) - } } impl HyperlaneChain for SealevelProvider { @@ -75,7 +61,8 @@ impl HyperlaneProvider for SealevelProvider { } async fn get_balance(&self, address: String) -> ChainResult { - self.get_balance(address).await + let pubkey = Pubkey::from_str(&address).map_err(Into::::into)?; + self.rpc_client.get_balance(&pubkey).await } async fn get_chain_metrics(&self) -> ChainResult> { diff --git a/rust/main/chains/hyperlane-sealevel/src/rpc.rs b/rust/main/chains/hyperlane-sealevel/src/rpc.rs new file mode 100644 index 000000000..1c82b77c0 --- /dev/null +++ b/rust/main/chains/hyperlane-sealevel/src/rpc.rs @@ -0,0 +1,3 @@ +pub use client::SealevelRpcClient; + +mod client; diff --git a/rust/main/chains/hyperlane-sealevel/src/rpc/client.rs b/rust/main/chains/hyperlane-sealevel/src/rpc/client.rs new file mode 100644 index 000000000..77f21ee1f --- /dev/null +++ b/rust/main/chains/hyperlane-sealevel/src/rpc/client.rs @@ -0,0 +1,242 @@ +use base64::Engine; +use borsh::{BorshDeserialize, BorshSerialize}; +use hyperlane_core::{ChainCommunicationError, ChainResult, U256}; +use serializable_account_meta::{SerializableAccountMeta, SimulationReturnData}; +use solana_client::{ + nonblocking::rpc_client::RpcClient, rpc_config::RpcProgramAccountsConfig, + rpc_response::Response, +}; +use solana_sdk::{ + account::Account, + commitment_config::CommitmentConfig, + hash::Hash, + instruction::{AccountMeta, Instruction}, + message::Message, + pubkey::Pubkey, + signature::{Keypair, Signature, Signer}, + transaction::Transaction, +}; +use solana_transaction_status::{TransactionStatus, UiReturnDataEncoding, UiTransactionReturnData}; + +use crate::error::HyperlaneSealevelError; + +pub struct SealevelRpcClient(RpcClient); + +impl SealevelRpcClient { + pub fn new(rpc_endpoint: String) -> Self { + Self(RpcClient::new_with_commitment( + rpc_endpoint, + CommitmentConfig::processed(), + )) + } + + pub async fn confirm_transaction_with_commitment( + &self, + signature: &Signature, + commitment: CommitmentConfig, + ) -> ChainResult { + self.0 + .confirm_transaction_with_commitment(signature, commitment) + .await + .map(|ctx| ctx.value) + .map_err(HyperlaneSealevelError::ClientError) + .map_err(Into::into) + } + + pub async fn get_account(&self, pubkey: &Pubkey) -> ChainResult { + self.0 + .get_account(pubkey) + .await + .map_err(ChainCommunicationError::from_other) + } + + /// Simulates an Instruction that will return a list of AccountMetas. + pub async fn get_account_metas( + &self, + payer: &Keypair, + instruction: Instruction, + ) -> ChainResult> { + // If there's no data at all, default to an empty vec. + let account_metas = self + .simulate_instruction::>>( + payer, + instruction, + ) + .await? + .map(|serializable_account_metas| { + serializable_account_metas + .return_data + .into_iter() + .map(|serializable_account_meta| serializable_account_meta.into()) + .collect() + }) + .unwrap_or_else(Vec::new); + + Ok(account_metas) + } + + pub async fn get_account_with_finalized_commitment( + &self, + pubkey: &Pubkey, + ) -> ChainResult { + self.get_possible_account_with_finalized_commitment(pubkey) + .await? + .ok_or_else(|| ChainCommunicationError::from_other_str("Could not find account data")) + } + + pub async fn get_possible_account_with_finalized_commitment( + &self, + pubkey: &Pubkey, + ) -> ChainResult> { + let account = self + .0 + .get_account_with_commitment(pubkey, CommitmentConfig::finalized()) + .await + .map_err(ChainCommunicationError::from_other)? + .value; + Ok(account) + } + + pub async fn get_block_height(&self) -> ChainResult { + let height = self + .0 + .get_block_height_with_commitment(CommitmentConfig::finalized()) + .await + .map_err(ChainCommunicationError::from_other)? + .try_into() + // FIXME solana block height is u64... + .expect("sealevel block height exceeds u32::MAX"); + Ok(height) + } + + pub async fn get_multiple_accounts_with_finalized_commitment( + &self, + pubkeys: &[Pubkey], + ) -> ChainResult>> { + let accounts = self + .0 + .get_multiple_accounts_with_commitment(pubkeys, CommitmentConfig::finalized()) + .await + .map_err(ChainCommunicationError::from_other)? + .value; + + Ok(accounts) + } + + pub async fn get_latest_blockhash_with_commitment( + &self, + commitment: CommitmentConfig, + ) -> ChainResult { + self.0 + .get_latest_blockhash_with_commitment(commitment) + .await + .map_err(ChainCommunicationError::from_other) + .map(|(blockhash, _)| blockhash) + } + + pub async fn get_program_accounts_with_config( + &self, + pubkey: &Pubkey, + config: RpcProgramAccountsConfig, + ) -> ChainResult> { + self.0 + .get_program_accounts_with_config(pubkey, config) + .await + .map_err(ChainCommunicationError::from_other) + } + + pub async fn get_signature_statuses( + &self, + signatures: &[Signature], + ) -> ChainResult>>> { + self.0 + .get_signature_statuses(signatures) + .await + .map_err(ChainCommunicationError::from_other) + } + + pub async fn get_balance(&self, pubkey: &Pubkey) -> ChainResult { + let balance = self + .0 + .get_balance(pubkey) + .await + .map_err(Into::::into) + .map_err(ChainCommunicationError::from)?; + + Ok(balance.into()) + } + + pub async fn is_blockhash_valid(&self, hash: &Hash) -> ChainResult { + self.0 + .is_blockhash_valid(hash, CommitmentConfig::processed()) + .await + .map_err(ChainCommunicationError::from_other) + } + + pub async fn send_and_confirm_transaction( + &self, + transaction: &Transaction, + ) -> ChainResult { + self.0 + .send_and_confirm_transaction(transaction) + .await + .map_err(ChainCommunicationError::from_other) + } + + /// Simulates an instruction, and attempts to deserialize it into a T. + /// If no return data at all was returned, returns Ok(None). + /// If some return data was returned but deserialization was unsuccessful, + /// an Err is returned. + pub async fn simulate_instruction( + &self, + payer: &Keypair, + instruction: Instruction, + ) -> ChainResult> { + let commitment = CommitmentConfig::finalized(); + let recent_blockhash = self + .get_latest_blockhash_with_commitment(commitment) + .await?; + let transaction = Transaction::new_unsigned(Message::new_with_blockhash( + &[instruction], + Some(&payer.pubkey()), + &recent_blockhash, + )); + let return_data = self.simulate_transaction(&transaction).await?; + + if let Some(return_data) = return_data { + let bytes = match return_data.data.1 { + UiReturnDataEncoding::Base64 => base64::engine::general_purpose::STANDARD + .decode(return_data.data.0) + .map_err(ChainCommunicationError::from_other)?, + }; + + let decoded_data = + T::try_from_slice(bytes.as_slice()).map_err(ChainCommunicationError::from_other)?; + + return Ok(Some(decoded_data)); + } + + Ok(None) + } + + async fn simulate_transaction( + &self, + transaction: &Transaction, + ) -> ChainResult> { + let return_data = self + .0 + .simulate_transaction(transaction) + .await + .map_err(ChainCommunicationError::from_other)? + .value + .return_data; + + Ok(return_data) + } +} + +impl std::fmt::Debug for SealevelRpcClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("RpcClient { ... }") + } +} diff --git a/rust/main/chains/hyperlane-sealevel/src/utils.rs b/rust/main/chains/hyperlane-sealevel/src/utils.rs deleted file mode 100644 index 56bec9e51..000000000 --- a/rust/main/chains/hyperlane-sealevel/src/utils.rs +++ /dev/null @@ -1,93 +0,0 @@ -use base64::Engine; -use borsh::{BorshDeserialize, BorshSerialize}; -use hyperlane_core::{ChainCommunicationError, ChainResult}; - -use serializable_account_meta::{SerializableAccountMeta, SimulationReturnData}; -use solana_client::nonblocking::rpc_client::RpcClient; -use solana_sdk::{ - commitment_config::CommitmentConfig, - instruction::{AccountMeta, Instruction}, - message::Message, - signature::{Keypair, Signer}, - transaction::Transaction, -}; -use solana_transaction_status::UiReturnDataEncoding; - -use crate::client::RpcClientWithDebug; - -/// Simulates an instruction, and attempts to deserialize it into a T. -/// If no return data at all was returned, returns Ok(None). -/// If some return data was returned but deserialization was unsuccessful, -/// an Err is returned. -pub async fn simulate_instruction( - rpc_client: &RpcClient, - payer: &Keypair, - instruction: Instruction, -) -> ChainResult> { - let commitment = CommitmentConfig::finalized(); - let (recent_blockhash, _) = rpc_client - .get_latest_blockhash_with_commitment(commitment) - .await - .map_err(ChainCommunicationError::from_other)?; - let return_data = rpc_client - .simulate_transaction(&Transaction::new_unsigned(Message::new_with_blockhash( - &[instruction], - Some(&payer.pubkey()), - &recent_blockhash, - ))) - .await - .map_err(ChainCommunicationError::from_other)? - .value - .return_data; - - if let Some(return_data) = return_data { - let bytes = match return_data.data.1 { - UiReturnDataEncoding::Base64 => base64::engine::general_purpose::STANDARD - .decode(return_data.data.0) - .map_err(ChainCommunicationError::from_other)?, - }; - - let decoded_data = - T::try_from_slice(bytes.as_slice()).map_err(ChainCommunicationError::from_other)?; - - return Ok(Some(decoded_data)); - } - - Ok(None) -} - -/// Simulates an Instruction that will return a list of AccountMetas. -pub async fn get_account_metas( - rpc_client: &RpcClient, - payer: &Keypair, - instruction: Instruction, -) -> ChainResult> { - // If there's no data at all, default to an empty vec. - let account_metas = simulate_instruction::>>( - rpc_client, - payer, - instruction, - ) - .await? - .map(|serializable_account_metas| { - serializable_account_metas - .return_data - .into_iter() - .map(|serializable_account_meta| serializable_account_meta.into()) - .collect() - }) - .unwrap_or_else(Vec::new); - - Ok(account_metas) -} - -pub async fn get_finalized_block_number(rpc_client: &RpcClientWithDebug) -> ChainResult { - let height = rpc_client - .get_block_height() - .await - .map_err(ChainCommunicationError::from_other)? - .try_into() - // FIXME solana block height is u64... - .expect("sealevel block height exceeds u32::MAX"); - Ok(height) -} diff --git a/rust/main/chains/hyperlane-sealevel/src/validator_announce.rs b/rust/main/chains/hyperlane-sealevel/src/validator_announce.rs index 52b19495a..3edfa0d06 100644 --- a/rust/main/chains/hyperlane-sealevel/src/validator_announce.rs +++ b/rust/main/chains/hyperlane-sealevel/src/validator_announce.rs @@ -1,17 +1,15 @@ use async_trait::async_trait; -use tracing::{info, instrument, warn}; - use hyperlane_core::{ - Announcement, ChainCommunicationError, ChainResult, ContractLocator, HyperlaneChain, - HyperlaneContract, HyperlaneDomain, SignedType, TxOutcome, ValidatorAnnounce, H160, H256, H512, - U256, + Announcement, ChainResult, ContractLocator, HyperlaneChain, HyperlaneContract, HyperlaneDomain, + SignedType, TxOutcome, ValidatorAnnounce, H160, H256, H512, U256, }; -use solana_sdk::{commitment_config::CommitmentConfig, pubkey::Pubkey}; - -use crate::{ConnectionConf, RpcClientWithDebug, SealevelProvider}; use hyperlane_sealevel_validator_announce::{ accounts::ValidatorStorageLocationsAccount, validator_storage_locations_pda_seeds, }; +use solana_sdk::pubkey::Pubkey; +use tracing::{info, instrument, warn}; + +use crate::{ConnectionConf, SealevelProvider, SealevelRpcClient}; /// A reference to a ValidatorAnnounce contract on some Sealevel chain #[derive(Debug)] @@ -33,7 +31,7 @@ impl SealevelValidatorAnnounce { } } - fn rpc(&self) -> &RpcClientWithDebug { + fn rpc(&self) -> &SealevelRpcClient { self.provider.rpc() } } @@ -79,10 +77,8 @@ impl ValidatorAnnounce for SealevelValidatorAnnounce { // If an account doesn't exist, it will be returned as None. let accounts = self .rpc() - .get_multiple_accounts_with_commitment(&account_pubkeys, CommitmentConfig::finalized()) - .await - .map_err(ChainCommunicationError::from_other)? - .value; + .get_multiple_accounts_with_finalized_commitment(&account_pubkeys) + .await?; // Parse the storage locations from each account. // If a validator's account doesn't exist, its storage locations will diff --git a/rust/main/config/mainnet_config.json b/rust/main/config/mainnet_config.json index c460fe308..2667590fe 100644 --- a/rust/main/config/mainnet_config.json +++ b/rust/main/config/mainnet_config.json @@ -2706,7 +2706,10 @@ "validatorAnnounce": "0xd83A4F747fE80Ed98839e05079B1B7Fe037b1638", "staticMerkleRootWeightedMultisigIsmFactory": "0xcb0D04010584AA5244b5826c990eeA4c16BeAC8C", "staticMessageIdWeightedMultisigIsmFactory": "0x609707355a53d2aAb6366f48E2b607C599D26B29", - "technicalStack": "other" + "technicalStack": "other", + "transactionOverrides": { + "gasPrice": 200000000 + } }, "sei": { "aggregationHook": "0x40514BD46C57455933Be8BAedE96C4F0Ba3507D6", diff --git a/rust/main/hyperlane-core/src/traits/pending_operation.rs b/rust/main/hyperlane-core/src/traits/pending_operation.rs index 8906777c3..f5480b197 100644 --- a/rust/main/hyperlane-core/src/traits/pending_operation.rs +++ b/rust/main/hyperlane-core/src/traits/pending_operation.rs @@ -67,6 +67,13 @@ pub trait PendingOperation: Send + Sync + Debug + TryBatchAs { /// Get the metric associated with this operation. fn get_metric(&self) -> Option>; + /// Decrement the metric associated with this operation if it exists. + fn decrement_metric_if_exists(&self) { + if let Some(metric) = self.get_metric() { + metric.dec(); + } + } + /// Set the metric associated with this operation. fn set_metric(&mut self, metric: Arc); @@ -80,10 +87,12 @@ pub trait PendingOperation: Send + Sync + Debug + TryBatchAs { /// Set the status of the operation and update the metrics. fn set_status_and_update_metrics( &mut self, - status: PendingOperationStatus, + status: Option, new_metric: Arc, ) { - self.set_status(status); + if let Some(status) = status { + self.set_status(status); + } if let Some(old_metric) = self.get_metric() { old_metric.dec(); } diff --git a/solidity/contracts/isms/TrustedRelayerIsm.sol b/solidity/contracts/isms/TrustedRelayerIsm.sol index aba894a94..87da1bb60 100644 --- a/solidity/contracts/isms/TrustedRelayerIsm.sol +++ b/solidity/contracts/isms/TrustedRelayerIsm.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.0; // ============ Internal Imports ============ import {IInterchainSecurityModule} from "../interfaces/IInterchainSecurityModule.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Message} from "../libs/Message.sol"; import {Mailbox} from "../Mailbox.sol"; import {PackageVersioned} from "contracts/PackageVersioned.sol"; @@ -15,6 +16,14 @@ contract TrustedRelayerIsm is IInterchainSecurityModule, PackageVersioned { address public immutable trustedRelayer; constructor(address _mailbox, address _trustedRelayer) { + require( + _trustedRelayer != address(0), + "TrustedRelayerIsm: invalid relayer" + ); + require( + Address.isContract(_mailbox), + "TrustedRelayerIsm: invalid mailbox" + ); mailbox = Mailbox(_mailbox); trustedRelayer = _trustedRelayer; } diff --git a/solidity/contracts/isms/hook/ArbL2ToL1Ism.sol b/solidity/contracts/isms/hook/ArbL2ToL1Ism.sol index a7bd71447..98b5f9bd6 100644 --- a/solidity/contracts/isms/hook/ArbL2ToL1Ism.sol +++ b/solidity/contracts/isms/hook/ArbL2ToL1Ism.sol @@ -63,6 +63,7 @@ contract ArbL2ToL1Ism is ) external override returns (bool) { if (!isVerified(message)) { _verifyWithOutboxCall(metadata, message); + require(isVerified(message), "ArbL2ToL1Ism: message not verified"); } releaseValueToRecipient(message); return true; diff --git a/solidity/contracts/isms/hook/OPL2ToL1Ism.sol b/solidity/contracts/isms/hook/OPL2ToL1Ism.sol index b333b15cd..ef3986861 100644 --- a/solidity/contracts/isms/hook/OPL2ToL1Ism.sol +++ b/solidity/contracts/isms/hook/OPL2ToL1Ism.sol @@ -66,9 +66,9 @@ contract OPL2ToL1Ism is bytes calldata metadata, bytes calldata message ) external override returns (bool) { - bool verified = isVerified(message); - if (!verified) { + if (!isVerified(message)) { _verifyWithPortalCall(metadata, message); + require(isVerified(message), "OPL2ToL1Ism: message not verified"); } releaseValueToRecipient(message); return true; diff --git a/solidity/contracts/isms/multisig/AbstractWeightedMultisigIsm.sol b/solidity/contracts/isms/multisig/AbstractWeightedMultisigIsm.sol index 2264b192c..8b7fee049 100644 --- a/solidity/contracts/isms/multisig/AbstractWeightedMultisigIsm.sol +++ b/solidity/contracts/isms/multisig/AbstractWeightedMultisigIsm.sol @@ -73,11 +73,14 @@ abstract contract AbstractStaticWeightedMultisigIsm is // assumes that signatures are ordered by validator for ( - uint256 i = 0; - _totalWeight < _thresholdWeight && i < _validatorCount; - ++i + uint256 signatureIndex = 0; + _totalWeight < _thresholdWeight && signatureIndex < _validatorCount; + ++signatureIndex ) { - address _signer = ECDSA.recover(_digest, signatureAt(_metadata, i)); + address _signer = ECDSA.recover( + _digest, + signatureAt(_metadata, signatureIndex) + ); // loop through remaining validators until we find a match while ( _validatorIndex < _validatorCount && @@ -90,6 +93,7 @@ abstract contract AbstractStaticWeightedMultisigIsm is // add the weight of the current validator _totalWeight += _validators[_validatorIndex].weight; + ++_validatorIndex; } require( _totalWeight >= _thresholdWeight, diff --git a/solidity/contracts/libs/RateLimited.sol b/solidity/contracts/libs/RateLimited.sol index 4dfb8b262..c58c60379 100644 --- a/solidity/contracts/libs/RateLimited.sol +++ b/solidity/contracts/libs/RateLimited.sol @@ -1,5 +1,19 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity >=0.8.0; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +// ============ External Imports ============ import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /** @@ -7,16 +21,26 @@ import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Own * @notice A contract used to keep track of an address sender's token amount limits. * @dev Implements a modified token bucket algorithm where the bucket is full in the beginning and gradually refills * See: https://dev.to/satrobit/rate-limiting-using-the-token-bucket-algorithm-3cjh - **/ + * + */ contract RateLimited is OwnableUpgradeable { uint256 public constant DURATION = 1 days; // 86400 - uint256 public filledLevel; /// @notice Current filled level - uint256 public refillRate; /// @notice Tokens per second refill rate - uint256 public lastUpdated; /// @notice Timestamp of the last time an action has been taken TODO prob can be uint40 + /// @notice Current filled level + uint256 public filledLevel; + /// @notice Tokens per second refill rate + uint256 public refillRate; + /// @notice Timestamp of the last time an action has been taken + uint256 public lastUpdated; event RateLimitSet(uint256 _oldCapacity, uint256 _newCapacity); + event ConsumedFilledLevel(uint256 filledLevel, uint256 lastUpdated); + constructor(uint256 _capacity) { + require( + _capacity >= DURATION, + "Capacity must be greater than DURATION" + ); _transferOwnership(msg.sender); setRefillRate(_capacity); filledLevel = _capacity; @@ -88,20 +112,22 @@ contract RateLimited is OwnableUpgradeable { /** * Validate an amount and decreases the currentCapacity - * @param _newAmount The amount to consume the fill level + * @param _consumedAmount The amount to consume the fill level * @return The new filled level */ function validateAndConsumeFilledLevel( - uint256 _newAmount + uint256 _consumedAmount ) public returns (uint256) { uint256 adjustedFilledLevel = calculateCurrentLevel(); - require(_newAmount <= adjustedFilledLevel, "RateLimitExceeded"); + require(_consumedAmount <= adjustedFilledLevel, "RateLimitExceeded"); // Reduce the filledLevel and update lastUpdated - uint256 _filledLevel = adjustedFilledLevel - _newAmount; + uint256 _filledLevel = adjustedFilledLevel - _consumedAmount; filledLevel = _filledLevel; lastUpdated = block.timestamp; + emit ConsumedFilledLevel(filledLevel, lastUpdated); + return _filledLevel; } } diff --git a/solidity/contracts/mock/MockMailbox.sol b/solidity/contracts/mock/MockMailbox.sol index ad212dcef..c4b4b63e9 100644 --- a/solidity/contracts/mock/MockMailbox.sol +++ b/solidity/contracts/mock/MockMailbox.sol @@ -77,4 +77,9 @@ contract MockMailbox is Mailbox { Mailbox(address(this)).process{value: msg.value}("", _message); inboundProcessedNonce++; } + + function processInboundMessage(uint32 _nonce) public { + bytes memory _message = inboundMessages[_nonce]; + Mailbox(address(this)).process("", _message); + } } diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol index 2252696fa..9ceb5536b 100644 --- a/solidity/contracts/token/extensions/HypERC4626.sol +++ b/solidity/contracts/token/extensions/HypERC4626.sol @@ -1,13 +1,28 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity >=0.8.0; +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +// ============ Internal Imports ============ import {IXERC20} from "../interfaces/IXERC20.sol"; import {HypERC20} from "../HypERC20.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Message} from "../../libs/Message.sol"; import {TokenMessage} from "../libs/TokenMessage.sol"; import {TokenRouter} from "../libs/TokenRouter.sol"; +// ============ External Imports ============ +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + /** * @title Hyperlane ERC20 Rebasing Token * @author Abacus Works @@ -17,9 +32,12 @@ contract HypERC4626 is HypERC20 { using Message for bytes; using TokenMessage for bytes; + event ExchangeRateUpdated(uint256 newExchangeRate, uint32 rateUpdateNonce); + uint256 public constant PRECISION = 1e10; uint32 public immutable collateralDomain; uint256 public exchangeRate; // 1e10 + uint32 public previousNonce; constructor( uint8 _decimals, @@ -66,7 +84,16 @@ contract HypERC4626 is HypERC20 { bytes calldata _message ) internal virtual override { if (_origin == collateralDomain) { - exchangeRate = abi.decode(_message.metadata(), (uint256)); + (uint256 newExchangeRate, uint32 rateUpdateNonce) = abi.decode( + _message.metadata(), + (uint256, uint32) + ); + // only update if the nonce is greater than the previous nonce + if (rateUpdateNonce > previousNonce) { + exchangeRate = newExchangeRate; + previousNonce = rateUpdateNonce; + emit ExchangeRateUpdated(exchangeRate, rateUpdateNonce); + } } super._handle(_origin, _sender, _message); } diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index 8a084134c..87528b109 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -1,11 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity >=0.8.0; -import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +// ============ Internal Imports ============ import {TokenMessage} from "../libs/TokenMessage.sol"; import {HypERC20Collateral} from "../HypERC20Collateral.sol"; import {TypeCasts} from "../../libs/TypeCasts.sol"; +// ============ External Imports ============ +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; + /** * @title Hyperlane ERC4626 Token Collateral with deposits collateral to a vault * @author Abacus Works @@ -17,9 +32,13 @@ contract HypERC4626Collateral is HypERC20Collateral { // Address of the ERC4626 compatible vault ERC4626 public immutable vault; + // Precision for the exchange rate uint256 public constant PRECISION = 1e10; + // Null recipient for rebase transfer bytes32 public constant NULL_RECIPIENT = 0x0000000000000000000000000000000000000000000000000000000000000001; + // Nonce for the rate update, to ensure sequential updates + uint32 public rateUpdateNonce; constructor( ERC4626 _vault, @@ -52,7 +71,12 @@ contract HypERC4626Collateral is HypERC20Collateral { vault.totalSupply(), Math.Rounding.Down ); - bytes memory _tokenMetadata = abi.encode(_exchangeRate); + + rateUpdateNonce++; + bytes memory _tokenMetadata = abi.encode( + _exchangeRate, + rateUpdateNonce + ); bytes memory _tokenMessage = TokenMessage.format( _recipient, diff --git a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol index 1d4d64b0b..42d52f42c 100644 --- a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol @@ -1,9 +1,24 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity >=0.8.0; -import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +// ============ Internal Imports ============ import {HypERC20Collateral} from "../HypERC20Collateral.sol"; +// ============ External Imports ============ +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; + /** * @title Hyperlane ERC20 Token Collateral with deposits collateral to a vault, the yield goes to the owner * @author ltyu @@ -11,9 +26,12 @@ import {HypERC20Collateral} from "../HypERC20Collateral.sol"; contract HypERC4626OwnerCollateral is HypERC20Collateral { // Address of the ERC4626 compatible vault ERC4626 public immutable vault; - + // standby precision for exchange rate + uint256 public constant PRECISION = 1e10; // Internal balance of total asset deposited uint256 public assetDeposited; + // Nonce for the rate update, to ensure sequential updates (not necessary for Owner variant but for compatibility with HypERC4626) + uint32 public rateUpdateNonce; event ExcessSharesSwept(uint256 amount, uint256 assetsRedeemed); @@ -40,8 +58,11 @@ contract HypERC4626OwnerCollateral is HypERC20Collateral { function _transferFromSender( uint256 _amount ) internal override returns (bytes memory metadata) { - metadata = super._transferFromSender(_amount); + super._transferFromSender(_amount); _depositIntoVault(_amount); + rateUpdateNonce++; + + return abi.encode(PRECISION, rateUpdateNonce); } /** diff --git a/solidity/test/isms/ERC5164ISM.t.sol b/solidity/test/isms/ERC5164ISM.t.sol index 75c79fb82..8063979f4 100644 --- a/solidity/test/isms/ERC5164ISM.t.sol +++ b/solidity/test/isms/ERC5164ISM.t.sol @@ -150,6 +150,8 @@ contract ERC5164IsmTest is ExternalBridgeTest { function test_verify_valueAlreadyClaimed(uint256) public override {} + function test_verify_false_arbitraryCall() public override {} + /* ============ helper functions ============ */ function _externalBridgeDestinationCall( diff --git a/solidity/test/isms/ExternalBridgeTest.sol b/solidity/test/isms/ExternalBridgeTest.sol index 8db043fcf..344e001af 100644 --- a/solidity/test/isms/ExternalBridgeTest.sol +++ b/solidity/test/isms/ExternalBridgeTest.sol @@ -135,14 +135,14 @@ abstract contract ExternalBridgeTest is Test { 1 ether, messageId ); - ism.verify(externalCalldata, encodedMessage); + assertTrue(ism.verify(externalCalldata, encodedMessage)); assertEq(address(testRecipient).balance, 1 ether); } function test_verify_revertsWhen_invalidIsm() public virtual { bytes memory externalCalldata = _encodeExternalDestinationBridgeCall( address(hook), - address(this), + address(hook), 0, messageId ); @@ -217,6 +217,19 @@ abstract contract ExternalBridgeTest is Test { assertEq(address(testRecipient).balance, _msgValue); } + function test_verify_false_arbitraryCall() public virtual { + bytes memory incorrectCalldata = _encodeExternalDestinationBridgeCall( + address(hook), + address(this), + 0, + messageId + ); + + vm.expectRevert(); + ism.verify(incorrectCalldata, encodedMessage); + assertFalse(ism.isVerified(encodedMessage)); + } + /* ============ helper functions ============ */ function _encodeTestMessage() internal view returns (bytes memory) { @@ -265,4 +278,7 @@ abstract contract ExternalBridgeTest is Test { function _setExternalOriginSender( address _sender ) internal virtual returns (bytes memory) {} + + // meant to mock an arbitrary successful call made by the external bridge + function verifyMessageId(bytes32 /*messageId*/) public payable {} } diff --git a/solidity/test/isms/MultisigIsm.t.sol b/solidity/test/isms/MultisigIsm.t.sol index 37b1e3e14..16384d9d0 100644 --- a/solidity/test/isms/MultisigIsm.t.sol +++ b/solidity/test/isms/MultisigIsm.t.sol @@ -189,6 +189,32 @@ abstract contract AbstractMultisigIsmTest is Test { metadata[index] = ~metadata[index]; assertFalse(ism.verify(metadata, message)); } + + function test_verify_revertWhen_duplicateSignatures( + uint32 destination, + bytes32 recipient, + bytes calldata body, + uint8 m, + uint8 n, + bytes32 seed + ) public virtual { + vm.assume(1 < m && m <= n && n < 10); + bytes memory message = getMessage(destination, recipient, body); + bytes memory metadata = getMetadata(m, n, seed, message); + + bytes memory duplicateMetadata = new bytes(metadata.length); + for (uint256 i = 0; i < metadata.length - 65; i++) { + duplicateMetadata[i] = metadata[i]; + } + for (uint256 i = 0; i < 65; i++) { + duplicateMetadata[metadata.length - 65 + i] = metadata[ + metadata.length - 130 + i + ]; + } + + vm.expectRevert("!threshold"); + ism.verify(duplicateMetadata, message); + } } contract MerkleRootMultisigIsmTest is AbstractMultisigIsmTest { diff --git a/solidity/test/isms/OPStackIsm.t.sol b/solidity/test/isms/OPStackIsm.t.sol index 45c818ec3..3230e59b8 100644 --- a/solidity/test/isms/OPStackIsm.t.sol +++ b/solidity/test/isms/OPStackIsm.t.sol @@ -133,10 +133,10 @@ contract OPStackIsmTest is ExternalBridgeTest { } function _encodeExternalDestinationBridgeCall( - address _from, - address _to, - uint256 _msgValue, - bytes32 _messageId + address /*_from*/, + address /*_to*/, + uint256 /*_msgValue*/, + bytes32 /*_messageId*/ ) internal pure override returns (bytes memory) { return new bytes(0); } @@ -148,6 +148,8 @@ contract OPStackIsmTest is ExternalBridgeTest { function test_verify_revertsWhen_invalidIsm() public override {} + function test_verify_false_arbitraryCall() public override {} + /* ============ ISM.verifyMessageId ============ */ function test_verify_revertsWhen_notAuthorizedHook() public override { diff --git a/solidity/test/isms/TrustedRelayerIsm.t.sol b/solidity/test/isms/TrustedRelayerIsm.t.sol index 51c574ba1..f630b6474 100644 --- a/solidity/test/isms/TrustedRelayerIsm.t.sol +++ b/solidity/test/isms/TrustedRelayerIsm.t.sol @@ -29,6 +29,13 @@ contract TrustedRelayerIsmTest is Test { recipient.setInterchainSecurityModule(address(ism)); } + function test_revertsWhen_invalidMailboxOrRelayer() public { + vm.expectRevert("TrustedRelayerIsm: invalid relayer"); + new TrustedRelayerIsm(address(mailbox), address(0)); + vm.expectRevert("TrustedRelayerIsm: invalid mailbox"); + new TrustedRelayerIsm(relayer, relayer); + } + function test_verify( uint32 origin, bytes32 sender, diff --git a/solidity/test/isms/WeightedMultisigIsm.t.sol b/solidity/test/isms/WeightedMultisigIsm.t.sol index df2a3d0ea..0c5fd7ee4 100644 --- a/solidity/test/isms/WeightedMultisigIsm.t.sol +++ b/solidity/test/isms/WeightedMultisigIsm.t.sol @@ -65,7 +65,6 @@ abstract contract AbstractStaticWeightedMultisigIsmTest is } } - // ism = IInterchainSecurityModule(deployedIsm); ism = IInterchainSecurityModule( weightedFactory.deploy(validators, threshold) ); @@ -136,7 +135,7 @@ abstract contract AbstractStaticWeightedMultisigIsmTest is return metadata; } - function testVerify_revertInsufficientWeight( + function test_verify_revertInsufficientWeight( uint32 destination, bytes32 recipient, bytes calldata body, @@ -161,6 +160,34 @@ abstract contract AbstractStaticWeightedMultisigIsmTest is vm.expectRevert("Insufficient validator weight"); ism.verify(insufficientMetadata, message); } + + function test_verify_revertWhen_duplicateSignatures( + uint32 destination, + bytes32 recipient, + bytes calldata body, + uint8 m, + uint8 n, + bytes32 seed + ) public virtual override { + vm.assume(1 < m && m <= n && n < 10); + bytes memory message = getMessage(destination, recipient, body); + bytes memory metadata = getMetadata(m, n, seed, message); + + bytes memory duplicateMetadata = new bytes(metadata.length); + for (uint256 i = 0; i < metadata.length - 65; i++) { + duplicateMetadata[i] = metadata[i]; + } + for (uint256 i = 0; i < 65; i++) { + duplicateMetadata[metadata.length - 65 + i] = metadata[ + metadata.length - 130 + i + ]; + } + + if (weightedIsm.signatureCount(metadata) >= 2) { + vm.expectRevert("Invalid signer"); + ism.verify(duplicateMetadata, message); + } + } } contract StaticMerkleRootWeightedMultisigIsmTest is @@ -194,6 +221,28 @@ contract StaticMerkleRootWeightedMultisigIsmTest is message ); } + + function test_verify_revertWhen_duplicateSignatures( + uint32 destination, + bytes32 recipient, + bytes calldata body, + uint8 m, + uint8 n, + bytes32 seed + ) + public + override(AbstractMultisigIsmTest, AbstractStaticWeightedMultisigIsmTest) + { + AbstractStaticWeightedMultisigIsmTest + .test_verify_revertWhen_duplicateSignatures( + destination, + recipient, + body, + m, + n, + seed + ); + } } contract StaticMessageIdWeightedMultisigIsmTest is @@ -227,4 +276,26 @@ contract StaticMessageIdWeightedMultisigIsmTest is message ); } + + function test_verify_revertWhen_duplicateSignatures( + uint32 destination, + bytes32 recipient, + bytes calldata body, + uint8 m, + uint8 n, + bytes32 seed + ) + public + override(AbstractMultisigIsmTest, AbstractStaticWeightedMultisigIsmTest) + { + AbstractStaticWeightedMultisigIsmTest + .test_verify_revertWhen_duplicateSignatures( + destination, + recipient, + body, + m, + n, + seed + ); + } } diff --git a/solidity/test/lib/RateLimited.t.sol b/solidity/test/lib/RateLimited.t.sol index 1276c86af..d0ea273de 100644 --- a/solidity/test/lib/RateLimited.t.sol +++ b/solidity/test/lib/RateLimited.t.sol @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT or Apache-2.0 pragma solidity ^0.8.13; + import {Test} from "forge-std/Test.sol"; import {RateLimited} from "../../contracts/libs/RateLimited.sol"; @@ -13,8 +14,13 @@ contract RateLimitLibTest is Test { rateLimited = new RateLimited(MAX_CAPACITY); } + function testConstructor_revertsWhen_lowCapacity() public { + vm.expectRevert("Capacity must be greater than DURATION"); + new RateLimited(1 days - 1); + } + function testRateLimited_setsNewLimit() external { - rateLimited.setRefillRate(2 ether); + assert(rateLimited.setRefillRate(2 ether) > 0); assertApproxEqRel(rateLimited.maxCapacity(), 2 ether, ONE_PERCENT); assertEq(rateLimited.refillRate(), uint256(2 ether) / 1 days); // 2 ether / 1 day } @@ -45,6 +51,25 @@ contract RateLimitLibTest is Test { rateLimited.setRefillRate(1 ether); } + function testConsumedFilledLevelEvent() public { + uint256 consumeAmount = 0.5 ether; + + vm.expectEmit(true, true, false, true); + emit RateLimited.ConsumedFilledLevel( + 499999999999993600, + block.timestamp + ); // precision loss + rateLimited.validateAndConsumeFilledLevel(consumeAmount); + + assertApproxEqRelDecimal( + rateLimited.filledLevel(), + MAX_CAPACITY - consumeAmount, + 1e14, + 0 + ); + assertEq(rateLimited.lastUpdated(), block.timestamp); + } + function testRateLimited_neverReturnsGtMaxLimit( uint256 _newAmount, uint40 _newTime @@ -104,4 +129,24 @@ contract RateLimitLibTest is Test { currentTargetLimit = rateLimited.calculateCurrentLevel(); assertApproxEqRel(currentTargetLimit, MAX_CAPACITY, ONE_PERCENT); } + + function testCalculateCurrentLevel_revertsWhenCapacityIsZero() public { + rateLimited.setRefillRate(0); + + vm.expectRevert("RateLimitNotSet"); + rateLimited.calculateCurrentLevel(); + } + + function testValidateAndConsumeFilledLevel_revertsWhenExceedingLimit() + public + { + vm.warp(1 days); + uint256 initialLevel = rateLimited.calculateCurrentLevel(); + + uint256 excessAmount = initialLevel + 1 ether; + + vm.expectRevert("RateLimitExceeded"); + rateLimited.validateAndConsumeFilledLevel(excessAmount); + assertEq(rateLimited.calculateCurrentLevel(), initialLevel); + } } diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol index 3dba941b3..8d2f9226e 100644 --- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -16,8 +16,11 @@ pragma solidity ^0.8.13; import "forge-std/Test.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {HypERC4626} from "../../contracts/token/extensions/HypERC4626.sol"; + import {ERC4626Test} from "../../contracts/test/ERC4626/ERC4626Test.sol"; import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; +import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol"; import {HypTokenTest} from "./HypERC20.t.sol"; import {HypERC4626OwnerCollateral} from "../../contracts/token/extensions/HypERC4626OwnerCollateral.sol"; @@ -227,6 +230,20 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { ); } + function testERC4626VaultDeposit_TransferFromSender_CorrectMetadata() + public + { + remoteToken = new HypERC4626(18, address(remoteMailbox), ORIGIN); + _enrollRemoteTokenRouter(); + vm.prank(ALICE); + + primaryToken.approve(address(localToken), TRANSFER_AMT); + _performRemoteTransfer(0, TRANSFER_AMT, 1); + + assertEq(HypERC4626(address(remoteToken)).exchangeRate(), 1e10); + assertEq(HypERC4626(address(remoteToken)).previousNonce(), 1); + } + function testBenchmark_overheadGasUsage() public override { vm.prank(ALICE); primaryToken.approve(address(localToken), TRANSFER_AMT); @@ -243,4 +260,34 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { uint256 gasAfter = gasleft(); console.log("Overhead gas usage: %d", gasBefore - gasAfter); } + + function _performRemoteTransfer( + uint256 _msgValue, + uint256 _amount, + uint32 _nonce + ) internal { + vm.prank(ALICE); + localToken.transferRemote{value: _msgValue}( + DESTINATION, + BOB.addressToBytes32(), + _amount + ); + + vm.expectEmit(true, true, false, true); + emit ReceivedTransferRemote(ORIGIN, BOB.addressToBytes32(), _amount); + bytes memory _tokenMessage = TokenMessage.format( + BOB.addressToBytes32(), + _amount, + abi.encode(uint256(1e10), _nonce) + ); + + vm.prank(address(remoteMailbox)); + remoteToken.handle( + ORIGIN, + address(localToken).addressToBytes32(), + _tokenMessage + ); + + assertEq(remoteToken.balanceOf(BOB), _amount); + } } diff --git a/solidity/test/token/HypERC4626Test.t.sol b/solidity/test/token/HypERC4626Test.t.sol index d09e0aae6..338d39b75 100644 --- a/solidity/test/token/HypERC4626Test.t.sol +++ b/solidity/test/token/HypERC4626Test.t.sol @@ -43,6 +43,8 @@ contract HypERC4626CollateralTest is HypTokenTest { HypERC4626 remoteRebasingToken; HypERC4626 peerRebasingToken; + event ExchangeRateUpdated(uint256 newExchangeRate, uint32 rateUpdateNonce); + function setUp() public override { super.setUp(); @@ -95,6 +97,7 @@ contract HypERC4626CollateralTest is HypTokenTest { peerRebasingToken = HypERC4626(address(peerToken)); primaryToken.transfer(ALICE, 1000e18); + primaryToken.transfer(BOB, 1000e18); uint32[] memory domains = new uint32[](3); domains[0] = ORIGIN; @@ -146,6 +149,47 @@ contract HypERC4626CollateralTest is HypTokenTest { ); } + function testRebase_exchangeRateUpdateInSequence() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + _accrueYield(); + + uint256 exchangeRateInitially = remoteRebasingToken.exchangeRate(); + + vm.startPrank(BOB); + primaryToken.approve(address(localToken), transferAmount); + localToken.transferRemote( + DESTINATION, + BOB.addressToBytes32(), + transferAmount + ); + vm.stopPrank(); + + _accrueYield(); + + vm.startPrank(ALICE); + primaryToken.approve(address(localToken), transferAmount); + localToken.transferRemote( + DESTINATION, + BOB.addressToBytes32(), + transferAmount + ); + vm.stopPrank(); + + // process ALICE's transfer + + vm.expectEmit(true, true, true, true); + emit ExchangeRateUpdated(10721400472, 3); + remoteMailbox.processInboundMessage(2); + uint256 exchangeRateBefore = remoteRebasingToken.exchangeRate(); + + // process BOB's transfer + remoteMailbox.processInboundMessage(1); + uint256 exchangeRateAfter = remoteRebasingToken.exchangeRate(); + + assertLt(exchangeRateInitially, exchangeRateBefore); // updates bc nonce=2 is after nonce=0 + assertEq(exchangeRateBefore, exchangeRateAfter); // doesn't update bc nonce=1 is before nonce=0 + } + function testSyntheticTransfers_withRebase() public { _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -173,6 +217,7 @@ contract HypERC4626CollateralTest is HypTokenTest { } function testWithdrawalWithoutYield() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -183,10 +228,14 @@ contract HypERC4626CollateralTest is HypTokenTest { transferAmount ); localMailbox.processNextInboundMessage(); - assertEq(primaryToken.balanceOf(BOB), transferAmount); + assertEq( + primaryToken.balanceOf(BOB) - bobPrimaryBefore, + transferAmount + ); } function testWithdrawalWithYield() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -205,13 +254,22 @@ contract HypERC4626CollateralTest is HypTokenTest { uint256 _expectedBal = transferAmount + _discountedYield(); // BOB gets the yield even though it didn't rebase - assertApproxEqRelDecimal(_bobBal, _expectedBal, 1e14, 0); - assertTrue(_bobBal < _expectedBal, "Transfer remote should round down"); + assertApproxEqRelDecimal( + _bobBal - bobPrimaryBefore, + _expectedBal, + 1e14, + 0 + ); + assertTrue( + _bobBal - bobPrimaryBefore < _expectedBal, + "Transfer remote should round down" + ); assertEq(vault.accumulatedFees(), YIELD / 10); } function testWithdrawalAfterYield() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -230,7 +288,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); localMailbox.processNextInboundMessage(); assertApproxEqRelDecimal( - primaryToken.balanceOf(BOB), + primaryToken.balanceOf(BOB) - bobPrimaryBefore, transferAmount + _discountedYield(), 1e14, 0 @@ -287,6 +345,7 @@ contract HypERC4626CollateralTest is HypTokenTest { } function testWithdrawalAfterDrawdown() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -306,7 +365,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); localMailbox.processNextInboundMessage(); assertApproxEqRelDecimal( - primaryToken.balanceOf(BOB), + primaryToken.balanceOf(BOB) - bobPrimaryBefore, transferAmount - drawdown, 1e14, 0 diff --git a/typescript/cli/ci-advanced-test.sh b/typescript/cli/ci-advanced-test.sh deleted file mode 100755 index 5fd16676f..000000000 --- a/typescript/cli/ci-advanced-test.sh +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env bash - -_main() { - export LOG_LEVEL=DEBUG - - # set script location as repo root - cd "$(dirname "$0")/../.." - - TEST_TYPE_PRESET_HOOK="preset_hook_enabled" - TEST_TYPE_CONFIGURED_HOOK="configure_hook_enabled" - TEST_TYPE_PI_CORE="pi_with_core_chain" - - # set the first arg to 'configured_hook' to set the hook config as part of core deployment - # motivation is to test both the bare bone deployment (included in the docs) and the deployment - # with the routing over igp hook (which is closer to production deployment) - TEST_TYPE=$1 - if [ -z "$TEST_TYPE" ]; then - echo "Usage: ci-advanced-test.sh <$TEST_TYPE_PRESET_HOOK | $TEST_TYPE_CONFIGURED_HOOK | $TEST_TYPE_PI_CORE>" - exit 1 - fi - - prepare_environment_vars; - - prepare_anvil; - - DEPLOYER=$(cast rpc eth_accounts | jq -r '.[0]'); - - # TODO: fix `resetFork` after a dry-run. Related: https://github.com/foundry-rs/foundry/pull/8768 - # run_hyperlane_deploy_core_dry_run; - # run_hyperlane_deploy_warp_dry_run; - # reset_anvil; - - run_hyperlane_deploy_core; - run_hyperlane_deploy_warp; - run_hyperlane_send_message; - - kill_anvil; - - echo "Done"; -} - -prepare_environment_vars() { - ANVIL_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - CHAIN1=anvil1 - CHAIN2=anvil2 - EXAMPLES_PATH=./examples - TEST_CONFIGS_PATH=./test-configs - CLI_PATH=./typescript/cli - REGISTRY_PATH="$TEST_CONFIGS_PATH/anvil" - CORE_ISM_PATH="$EXAMPLES_PATH/ism.yaml" - WARP_DEPLOY_CONFIG_PATH="$EXAMPLES_PATH/warp-route-deployment.yaml" - DEPLOY_ERC20_PATH=./src/tests/deployTestErc20.ts - - # use different chain names and config for pi<>core test - if [ "$TEST_TYPE" == $TEST_TYPE_PI_CORE ]; then - CHAIN2=ethereum - REGISTRY_PATH="$TEST_CONFIGS_PATH/fork" - CORE_ISM_PATH="$REGISTRY_PATH/ism.yaml" - WARP_DEPLOY_CONFIG_PATH="$REGISTRY_PATH/warp-route-deployment.yaml" - fi - - CHAIN1_CAPS=$(echo "${CHAIN1}" | tr '[:lower:]' '[:upper:]') - CHAIN2_CAPS=$(echo "${CHAIN2}" | tr '[:lower:]' '[:upper:]') - - HOOK_FLAG=false - if [ "$TEST_TYPE" == $TEST_TYPE_CONFIGURED_HOOK ]; then - HOOK_FLAG=true - fi -} - -prepare_anvil() { - - CHAIN1_PORT=8545 - CHAIN2_PORT=8555 - - # Optional cleanup for previous runs, useful when running locally - pkill -f anvil - rm -rf /tmp/${CHAIN1}* - rm -rf /tmp/${CHAIN2}* - rm -rf /tmp/relayer - rm -f $CLI_PATH/$TEST_CONFIGS_PATH/*/chains/*/addresses.yaml - rm -rf $CLI_PATH/$TEST_CONFIGS_PATH/*/deployments - - if [[ $OSTYPE == 'darwin'* ]]; then - # kill child processes on exit, but only locally because - # otherwise it causes the script exit code to be non-zero - trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT - fi - - # Setup directories for anvil chains - for CHAIN in ${CHAIN1} ${CHAIN2} - do - mkdir -p /tmp/$CHAIN /tmp/$CHAIN/state /tmp/$CHAIN/validator /tmp/relayer - chmod -R 777 /tmp/relayer /tmp/$CHAIN - done - - # run the PI chain - anvil --chain-id 31337 -p ${CHAIN1_PORT} --state /tmp/${CHAIN1}/state --gas-price 1 > /dev/null & - sleep 1 - - # use different chain names for pi<>core test - if [ "$TEST_TYPE" == $TEST_TYPE_PI_CORE ]; then - # Fetch the RPC of chain to fork - cd typescript/infra - RPC_URL=$(LOG_LEVEL=error yarn tsx scripts/print-chain-metadatas.ts -e mainnet3 | jq -r ".${CHAIN2}.rpcUrls[0].http") - cd ../../ - - # run the fork chain - anvil -p ${CHAIN2_PORT} --state /tmp/${CHAIN2}/state --gas-price 1 --fork-url $RPC_URL --fork-retry-backoff 3 --compute-units-per-second 200 > /dev/null & - - # wait for fork to be ready - while ! cast bn --rpc-url http://127.0.0.1:${CHAIN2_PORT} &> /dev/null; do - sleep 1 - done - else - # run a second PI chain - anvil --chain-id 31338 -p ${CHAIN2_PORT} --state /tmp/${CHAIN2}/state --gas-price 1 > /dev/null & - sleep 1 - fi - - set -e -} - -reset_anvil() { - prepare_anvil -} - -kill_anvil() { - pkill -f anvil -} - -run_hyperlane_deploy_core_dry_run() { - if [ "$TEST_TYPE" == $TEST_TYPE_PI_CORE ]; then - return; - fi - - update_deployer_balance; - - echo -e "\nDry-running contract deployments to Alfajores" - yarn workspace @hyperlane-xyz/cli run hyperlane core deploy \ - --dry-run alfajores \ - --registry ${TEST_CONFIGS_PATH}/dry-run \ - --overrides " " \ - --config ${EXAMPLES_PATH}/core-config.yaml \ - --from-address 0xfaD1C94469700833717Fa8a3017278BC1cA8031C \ - --yes - - check_deployer_balance; -} - -run_hyperlane_deploy_warp_dry_run() { - if [ "$TEST_TYPE" == $TEST_TYPE_PI_CORE ]; then - return; - fi - - update_deployer_balance; - - echo -e "\nDry-running warp route deployments to Alfajores" - 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 \ - --from-address 0xfaD1C94469700833717Fa8a3017278BC1cA8031C \ - --yes - - check_deployer_balance; -} - -run_hyperlane_deploy_core() { - update_deployer_balance; - - 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 " " \ - --config ${EXAMPLES_PATH}/core-config.yaml \ - --chain $CHAIN2 \ - --key $ANVIL_KEY \ - --yes - - check_deployer_balance; -} - -run_hyperlane_deploy_warp() { - update_deployer_balance; - - echo -e "\nDeploying hypNative warp route" - yarn workspace @hyperlane-xyz/cli run hyperlane warp deploy \ - --registry $REGISTRY_PATH \ - --overrides " " \ - --config $WARP_DEPLOY_CONFIG_PATH \ - --key $ANVIL_KEY \ - --yes - - yarn workspace @hyperlane-xyz/cli run tsx $DEPLOY_ERC20_PATH \ - http://127.0.0.1:$CHAIN1_PORT \ - $CHAIN1 $CHAIN2 $ANVIL_KEY \ - /tmp/warp-collateral-deployment.json \ - - echo "Deploying hypCollateral warp route" - yarn workspace @hyperlane-xyz/cli run hyperlane warp deploy \ - --registry $REGISTRY_PATH \ - --overrides " " \ - --config /tmp/warp-collateral-deployment.json \ - --key $ANVIL_KEY \ - --yes - - check_deployer_balance; -} - -run_hyperlane_send_message() { - update_deployer_balance; - - echo -e "\nSending test message" - yarn workspace @hyperlane-xyz/cli run hyperlane send message \ - --registry $REGISTRY_PATH \ - --overrides " " \ - --origin ${CHAIN1} \ - --destination ${CHAIN2} \ - --body "Howdy!" \ - --quick \ - --key $ANVIL_KEY \ - | tee /tmp/message1 - - check_deployer_balance; - - MESSAGE1_ID=`cat /tmp/message1 | grep "Message ID" | grep -E -o '0x[0-9a-f]+'` - echo "Message 1 ID: $MESSAGE1_ID" - - 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 warp send \ - --registry $REGISTRY_PATH \ - --overrides " " \ - --origin ${CHAIN1} \ - --destination ${CHAIN2} \ - --warp ${WARP_CONFIG_FILE} \ - --quick \ - --relay \ - --key $ANVIL_KEY \ - | tee /tmp/message2 - - MESSAGE2_ID=`cat /tmp/message2 | grep "Message ID" | grep -E -o '0x[0-9a-f]+'` - echo "Message 2 ID: $MESSAGE2_ID" -} - -update_deployer_balance() { - OLD_BALANCE=$(cast balance $DEPLOYER --rpc-url http://127.0.0.1:${CHAIN1_PORT}); -} - -check_deployer_balance() { - NEW_BALANCE=$(cast balance $DEPLOYER --rpc-url http://127.0.0.1:${CHAIN1_PORT}) - GAS_PRICE=$(cast gas-price --rpc-url http://127.0.0.1:${CHAIN1_PORT}) - GAS_USED=$(bc <<< "($OLD_BALANCE - $NEW_BALANCE) / $GAS_PRICE") - echo "Gas used: $GAS_USED" -} - -_main "$@"; - -exit; diff --git a/typescript/cli/package.json b/typescript/cli/package.json index 136cdfe62..fb08c132a 100644 --- a/typescript/cli/package.json +++ b/typescript/cli/package.json @@ -9,6 +9,7 @@ "@hyperlane-xyz/sdk": "workspace:^", "@hyperlane-xyz/utils": "workspace:^", "@inquirer/prompts": "^3.0.0", + "ansi-escapes": "^7.0.0", "asn1.js": "^5.4.1", "bignumber.js": "^9.1.1", "chalk": "^5.3.0", @@ -25,12 +26,14 @@ "devDependencies": { "@ethersproject/abi": "*", "@ethersproject/providers": "*", + "@types/chai-as-promised": "^8", "@types/mocha": "^10.0.1", "@types/node": "^18.14.5", "@types/yargs": "^17.0.24", "@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/parser": "^7.4.0", - "chai": "4.5.0", + "chai": "^4.5.0", + "chai-as-promised": "^8.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "mocha": "^10.2.0", diff --git a/typescript/cli/src/check/warp.ts b/typescript/cli/src/check/warp.ts new file mode 100644 index 000000000..a31fac62e --- /dev/null +++ b/typescript/cli/src/check/warp.ts @@ -0,0 +1,41 @@ +import { stringify as yamlStringify } from 'yaml'; + +import { WarpRouteDeployConfig, normalizeConfig } from '@hyperlane-xyz/sdk'; +import { ObjectDiff, diffObjMerge } from '@hyperlane-xyz/utils'; + +import { log, logGreen } from '../logger.js'; +import '../utils/output.js'; +import { formatYamlViolationsOutput } from '../utils/output.js'; + +export async function runWarpRouteCheck({ + warpRouteConfig, + onChainWarpConfig, +}: { + warpRouteConfig: WarpRouteDeployConfig; + onChainWarpConfig: WarpRouteDeployConfig; +}): Promise { + // Go through each chain and only add to the output the chains that have mismatches + const [violations, isInvalid] = Object.keys(warpRouteConfig).reduce( + (acc, chain) => { + const { mergedObject, isInvalid } = diffObjMerge( + normalizeConfig(onChainWarpConfig[chain]), + normalizeConfig(warpRouteConfig[chain]), + ); + + if (isInvalid) { + acc[0][chain] = mergedObject; + acc[1] ||= isInvalid; + } + + return acc; + }, + [{}, false] as [{ [index: string]: ObjectDiff }, boolean], + ); + + if (isInvalid) { + log(formatYamlViolationsOutput(yamlStringify(violations, null, 2))); + process.exit(1); + } + + logGreen(`No violations found`); +} diff --git a/typescript/cli/src/commands/config.ts b/typescript/cli/src/commands/config.ts index 7b145ca44..e72b72452 100644 --- a/typescript/cli/src/commands/config.ts +++ b/typescript/cli/src/commands/config.ts @@ -41,7 +41,7 @@ const validateChainCommand: CommandModuleWithContext<{ path: string }> = { command: 'chain', describe: 'Validate a chain config file', builder: { - path: inputFileCommandOption, + path: inputFileCommandOption(), }, handler: async ({ path }) => { readChainConfigs(path); @@ -54,7 +54,7 @@ const validateIsmCommand: CommandModuleWithContext<{ path: string }> = { command: 'ism', describe: 'Validate the basic ISM config file', builder: { - path: inputFileCommandOption, + path: inputFileCommandOption(), }, handler: async ({ path }) => { readMultisigConfig(path); @@ -67,7 +67,7 @@ const validateIsmAdvancedCommand: CommandModuleWithContext<{ path: string }> = { command: 'ism-advanced', describe: 'Validate the advanced ISM config file', builder: { - path: inputFileCommandOption, + path: inputFileCommandOption(), }, handler: async ({ path }) => { readIsmConfig(path); @@ -80,7 +80,7 @@ const validateWarpCommand: CommandModuleWithContext<{ path: string }> = { command: 'warp', describe: 'Validate a Warp Route deployment config file', builder: { - path: inputFileCommandOption, + path: inputFileCommandOption(), }, handler: async ({ path }) => { await readWarpRouteDeployConfig(path); diff --git a/typescript/cli/src/commands/options.ts b/typescript/cli/src/commands/options.ts index a251445be..218671509 100644 --- a/typescript/cli/src/commands/options.ts +++ b/typescript/cli/src/commands/options.ts @@ -91,11 +91,14 @@ export const hookCommandOption: Options = { 'A path to a JSON or YAML file with Hook configs (for every chain)', }; +export const DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH = + './configs/warp-route-deployment.yaml'; + export const warpDeploymentConfigCommandOption: Options = { type: 'string', description: 'A path to a JSON or YAML file with a warp route deployment config.', - default: './configs/warp-route-deployment.yaml', + default: DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH, alias: 'wd', }; @@ -134,12 +137,23 @@ export const outputFileCommandOption = ( demandOption, }); -export const inputFileCommandOption: Options = { +interface InputFileCommandOptionConfig + extends Pick { + defaultPath?: string; +} + +export const inputFileCommandOption = ({ + defaultPath, + demandOption = true, + description = 'Input file path', + alias = 'i', +}: InputFileCommandOptionConfig = {}): Options => ({ type: 'string', - description: 'Input file path', - alias: 'i', - demandOption: true, -}; + description, + default: defaultPath, + alias, + demandOption, +}); export const fromAddressCommandOption: Options = { type: 'string', diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index d3bee1e00..b7fb45624 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -1,23 +1,11 @@ -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 { ChainSubmissionStrategySchema } from '@hyperlane-xyz/sdk'; +import { runWarpRouteCheck } from '../check/warp.js'; import { createWarpRouteDeployConfig, - readWarpCoreConfig, readWarpRouteDeployConfig, } from '../config/warp.js'; import { @@ -26,20 +14,24 @@ import { } from '../context/types.js'; import { evaluateIfDryRunFailure } from '../deploy/dry-run.js'; import { runWarpRouteApply, runWarpRouteDeploy } from '../deploy/warp.js'; -import { log, logGray, logGreen, logRed, logTable } from '../logger.js'; +import { log, logCommandHeader, logGreen } from '../logger.js'; +import { runWarpRouteRead } from '../read/warp.js'; import { sendTestTransfer } from '../send/transfer.js'; import { indentYamlOrJson, + readYamlOrJson, removeEndingSlash, writeYamlOrJson, } from '../utils/files.js'; -import { selectRegistryWarpRoute } from '../utils/tokens.js'; +import { getWarpCoreConfigOrExit } from '../utils/input.js'; import { + DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH, addressCommandOption, chainCommandOption, dryRunCommandOption, fromAddressCommandOption, + inputFileCommandOption, outputFileCommandOption, strategyCommandOption, symbolCommandOption, @@ -57,6 +49,7 @@ export const warpCommand: CommandModule = { builder: (yargs) => yargs .command(apply) + .command(check) .command(deploy) .command(init) .command(read) @@ -102,17 +95,16 @@ export const apply: CommandModuleWithWriteContext<{ strategy: strategyUrl, receiptsDir, }) => { - logGray(`Hyperlane Warp Apply`); - logGray('--------------------'); // @TODO consider creating a helper function for these dashes - 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); - } + logCommandHeader('Hyperlane Warp Apply'); + + const warpCoreConfig = await getWarpCoreConfigOrExit({ + symbol, + warp, + context, + }); + + if (strategyUrl) + ChainSubmissionStrategySchema.parse(readYamlOrJson(strategyUrl)); const warpDeployConfig = await readWarpRouteDeployConfig(config); await runWarpRouteApply({ @@ -139,8 +131,9 @@ export const deploy: CommandModuleWithWriteContext<{ 'from-address': fromAddressCommandOption, }, handler: async ({ context, config, dryRun }) => { - logGray(`Hyperlane Warp Route Deployment${dryRun ? ' Dry-Run' : ''}`); - logGray('------------------------------------------------'); + logCommandHeader( + `Hyperlane Warp Route Deployment${dryRun ? ' Dry-Run' : ''}`, + ); try { await runWarpRouteDeploy({ @@ -167,11 +160,10 @@ export const init: CommandModuleWithContext<{ describe: 'Create an advanced ISM', default: false, }, - out: outputFileCommandOption('./configs/warp-route-deployment.yaml'), + out: outputFileCommandOption(DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH), }, handler: async ({ context, advanced, out }) => { - logGray('Hyperlane Warp Configure'); - logGray('------------------------'); + logCommandHeader('Hyperlane Warp Configure'); await createWarpRouteDeployConfig({ context, @@ -204,7 +196,7 @@ export const read: CommandModuleWithContext<{ false, ), config: outputFileCommandOption( - './configs/warp-route-deployment.yaml', + DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH, false, 'The path to output a Warp Config JSON or YAML file.', ), @@ -216,75 +208,14 @@ export const read: CommandModuleWithContext<{ config: configFilePath, 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(); + logCommandHeader('Hyperlane Warp Reader'); - 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, - ), - ), - ); + const config = await runWarpRouteRead({ + context, + chain, + address, + symbol, + }); if (configFilePath) { writeYamlOrJson(configFilePath, config, 'yaml'); @@ -342,15 +273,11 @@ const send: CommandModuleWithWriteContext< 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); - } + const warpCoreConfig = await getWarpCoreConfigOrExit({ + symbol, + warp, + context, + }); await sendTestTransfer({ context, @@ -366,3 +293,44 @@ const send: CommandModuleWithWriteContext< process.exit(0); }, }; + +export const check: CommandModuleWithContext<{ + config: string; + symbol?: string; + warp?: string; +}> = { + command: 'check', + describe: + 'Verifies that a warp route configuration matches the on chain configuration.', + builder: { + symbol: { + ...symbolCommandOption, + demandOption: false, + }, + warp: { + ...warpCoreConfigCommandOption, + demandOption: false, + }, + config: inputFileCommandOption({ + defaultPath: DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH, + description: 'The path to a warp route deployment configuration file', + }), + }, + handler: async ({ context, config, symbol, warp }) => { + logCommandHeader('Hyperlane Warp Check'); + + const warpRouteConfig = await readWarpRouteDeployConfig(config, context); + const onChainWarpConfig = await runWarpRouteRead({ + context, + warp, + symbol, + }); + + await runWarpRouteCheck({ + onChainWarpConfig, + warpRouteConfig, + }); + + process.exit(0); + }, +}; diff --git a/typescript/cli/src/config/hooks.ts b/typescript/cli/src/config/hooks.ts index 30294d19d..a075d6655 100644 --- a/typescript/cli/src/config/hooks.ts +++ b/typescript/cli/src/config/hooks.ts @@ -265,11 +265,11 @@ export const createRoutingConfig = callWithConfigCreationLogs( message: 'Enter owner address for routing Hook', }); const ownerAddress = owner; - const chains = await runMultiChainSelectionStep( - context.chainMetadata, - 'Select chains for routing Hook', - 1, - ); + const chains = await runMultiChainSelectionStep({ + chainMetadata: context.chainMetadata, + message: 'Select chains for routing Hook', + requireNumber: 1, + }); const domainsMap: ChainMap = {}; for (const chain of chains) { diff --git a/typescript/cli/src/config/ism.ts b/typescript/cli/src/config/ism.ts index 2e47aa191..f7f6bab9a 100644 --- a/typescript/cli/src/config/ism.ts +++ b/typescript/cli/src/config/ism.ts @@ -214,11 +214,11 @@ export const createRoutingConfig = callWithConfigCreationLogs( message: 'Enter owner address for routing ISM', }); const ownerAddress = owner; - const chains = await runMultiChainSelectionStep( - context.chainMetadata, - 'Select chains to configure routing ISM for', - 1, - ); + const chains = await runMultiChainSelectionStep({ + chainMetadata: context.chainMetadata, + message: 'Select chains to configure routing ISM for', + requireNumber: 1, + }); const domainsMap: ChainMap = {}; for (const chain of chains) { @@ -237,11 +237,11 @@ export const createRoutingConfig = callWithConfigCreationLogs( export const createFallbackRoutingConfig = callWithConfigCreationLogs( async (context: CommandContext): Promise => { - const chains = await runMultiChainSelectionStep( - context.chainMetadata, - 'Select chains to configure fallback routing ISM for', - 1, - ); + const chains = await runMultiChainSelectionStep({ + chainMetadata: context.chainMetadata, + message: 'Select chains to configure fallback routing ISM for', + requireNumber: 1, + }); const domainsMap: ChainMap = {}; for (const chain of chains) { diff --git a/typescript/cli/src/config/multisig.ts b/typescript/cli/src/config/multisig.ts index bb2e0ebbf..28648b271 100644 --- a/typescript/cli/src/config/multisig.ts +++ b/typescript/cli/src/config/multisig.ts @@ -72,7 +72,9 @@ export async function createMultisigConfig({ log( 'Select your own chain below to run your own validators. If you want to reuse existing Hyperlane validators instead of running your own, do not select additional mainnet or testnet chains.', ); - const chains = await runMultiChainSelectionStep(context.chainMetadata); + const chains = await runMultiChainSelectionStep({ + chainMetadata: context.chainMetadata, + }); const chainAddresses = await context.registry.getAddresses(); const result: MultisigConfigMap = {}; diff --git a/typescript/cli/src/config/warp.ts b/typescript/cli/src/config/warp.ts index 6ce1f6665..dd3a23713 100644 --- a/typescript/cli/src/config/warp.ts +++ b/typescript/cli/src/config/warp.ts @@ -34,12 +34,15 @@ import { createAdvancedIsmConfig } from './ism.js'; const TYPE_DESCRIPTIONS: Record = { [TokenType.synthetic]: 'A new ERC20 with remote transfer functionality', + [TokenType.syntheticRebase]: `A rebasing ERC20 with remote transfer functionality. Must be paired with ${TokenType.collateralVaultRebase}`, [TokenType.collateral]: 'Extends an existing ERC20 with remote transfer functionality', [TokenType.native]: 'Extends the native token with remote transfer functionality', [TokenType.collateralVault]: - 'Extends an existing ERC4626 with remote transfer functionality', + 'Extends an existing ERC4626 with remote transfer functionality. Yields are manually claimed by owner.', + [TokenType.collateralVaultRebase]: + 'Extends an existing ERC4626 with remote transfer functionality. Rebases yields to token holders.', [TokenType.collateralFiat]: 'Extends an existing FiatToken with remote transfer functionality', [TokenType.XERC20]: @@ -122,13 +125,15 @@ export async function createWarpRouteDeployConfig({ 'signer', ); - const warpChains = await runMultiChainSelectionStep( - context.chainMetadata, - 'Select chains to connect', - 1, - ); + const warpChains = await runMultiChainSelectionStep({ + chainMetadata: context.chainMetadata, + message: 'Select chains to connect', + requireNumber: 1, + requiresConfirmation: true, + }); const result: WarpRouteDeployConfig = {}; + let typeChoices = TYPE_CHOICES; for (const chain of warpChains) { logBlue(`${chain}: Configuring warp route...`); @@ -167,7 +172,7 @@ export async function createWarpRouteDeployConfig({ const type = await select({ message: `Select ${chain}'s token type`, - choices: TYPE_CHOICES, + choices: typeChoices, }); // TODO: restore NFT prompting @@ -192,6 +197,34 @@ export async function createWarpRouteDeployConfig({ }), }; break; + case TokenType.syntheticRebase: + result[chain] = { + mailbox, + type, + owner, + isNft, + collateralChainName: '', // This will be derived correctly by zod.parse() below + interchainSecurityModule, + }; + typeChoices = restrictChoices([ + TokenType.syntheticRebase, + TokenType.collateralVaultRebase, + ]); + break; + case TokenType.collateralVaultRebase: + result[chain] = { + mailbox, + type, + owner, + isNft, + interchainSecurityModule, + token: await input({ + message: `Enter the ERC-4626 vault address on chain ${chain}`, + }), + }; + + typeChoices = restrictChoices([TokenType.syntheticRebase]); + break; case TokenType.collateralVault: result[chain] = { mailbox, @@ -229,6 +262,10 @@ export async function createWarpRouteDeployConfig({ } } +function restrictChoices(typeChoices: TokenType[]) { + return TYPE_CHOICES.filter((choice) => typeChoices.includes(choice.name)); +} + // 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 readWarpCoreConfig(filePath: string): WarpCoreConfig { diff --git a/typescript/cli/src/deploy/agent.ts b/typescript/cli/src/deploy/agent.ts index 5b93e10c4..ca490fc5f 100644 --- a/typescript/cli/src/deploy/agent.ts +++ b/typescript/cli/src/deploy/agent.ts @@ -28,11 +28,11 @@ export async function runKurtosisAgentDeploy({ ); } if (!relayChains) { - const selectedRelayChains = await runMultiChainSelectionStep( - context.chainMetadata, - 'Select chains to relay between', - 2, - ); + const selectedRelayChains = await runMultiChainSelectionStep({ + chainMetadata: context.chainMetadata, + message: 'Select chains to relay between', + requireNumber: 2, + }); relayChains = selectedRelayChains.join(','); } diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index 872edb400..36bbc2ad8 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -848,11 +848,6 @@ async function submitWarpApplyTransactions( `Transactions receipts successfully written to ${receiptPath}`, ); } - - logGreen( - `✅ Warp route update success with ${submitter.txSubmitterType} on ${chain}:\n\n`, - indentYamlOrJson(yamlStringify(transactionReceipts, null, 2), 0), - ); }), ); } diff --git a/typescript/cli/src/logger.ts b/typescript/cli/src/logger.ts index d5347c66d..621d70e4d 100644 --- a/typescript/cli/src/logger.ts +++ b/typescript/cli/src/logger.ts @@ -57,5 +57,9 @@ export const errorRed = (...args: any) => logColor('error', chalk.red, ...args); export const logDebug = (msg: string, ...args: any) => logger.debug(msg, ...args); +export const logCommandHeader = (msg: string) => { + logGray(`${msg}\n${'_'.repeat(msg.length)}`); +}; + // 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/read/warp.ts b/typescript/cli/src/read/warp.ts new file mode 100644 index 000000000..9139d890c --- /dev/null +++ b/typescript/cli/src/read/warp.ts @@ -0,0 +1,117 @@ +import { ethers } from 'ethers'; + +import { + HypXERC20Lockbox__factory, + HypXERC20__factory, + IXERC20__factory, +} from '@hyperlane-xyz/core'; +import { + ChainMap, + ChainName, + EvmERC20WarpRouteReader, + TokenStandard, +} from '@hyperlane-xyz/sdk'; +import { isAddressEvm, objMap, promiseObjAll } from '@hyperlane-xyz/utils'; + +import { CommandContext } from '../context/types.js'; +import { logGray, logRed, logTable } from '../logger.js'; +import { getWarpCoreConfigOrExit } from '../utils/input.js'; + +export async function runWarpRouteRead({ + context, + chain, + address, + warp, + symbol, +}: { + context: CommandContext; + chain?: ChainName; + warp?: string; + address?: string; + symbol?: string; +}): Promise> { + const { multiProvider } = context; + + let addresses: ChainMap; + if (symbol || warp) { + const warpCoreConfig = await getWarpCoreConfigOrExit({ + context, + warp, + 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 { + logRed(`Please specify either a symbol, chain and address or warp file`); + process.exit(1); + } + + // Check if there any non-EVM chains in the config and exit + const nonEvmChains = Object.entries(addresses) + .filter(([_, address]) => !isAddressEvm(address)) + .map(([chain]) => chain); + if (nonEvmChains.length > 0) { + const chainList = nonEvmChains.join(', '); + logRed( + `${chainList} ${ + nonEvmChains.length > 1 ? 'are' : 'is' + } non-EVM and not compatible with the cli`, + ); + process.exit(1); + } + + const config = await promiseObjAll( + objMap(addresses, async (chain, address) => + new EvmERC20WarpRouteReader(multiProvider, chain).deriveWarpRouteConfig( + address, + ), + ), + ); + + return config; +} diff --git a/typescript/cli/src/send/transfer.ts b/typescript/cli/src/send/transfer.ts index a2f56ef29..a89eb6aa9 100644 --- a/typescript/cli/src/send/transfer.ts +++ b/typescript/cli/src/send/transfer.ts @@ -23,6 +23,10 @@ import { indentYamlOrJson } from '../utils/files.js'; import { stubMerkleTreeConfig } from '../utils/relay.js'; import { runTokenSelectionStep } from '../utils/tokens.js'; +export const WarpSendLogs = { + SUCCESS: 'Transfer was self-relayed!', +}; + export async function sendTestTransfer({ context, warpCoreConfig, @@ -183,7 +187,7 @@ async function executeDelivery({ log('Attempting self-relay of transfer...'); await relayer.relayMessage(transferTxReceipt, messageIndex, message); - logGreen('Transfer was self-relayed!'); + logGreen(WarpSendLogs.SUCCESS); return; } diff --git a/typescript/cli/src/tests/commands/helpers.ts b/typescript/cli/src/tests/commands/helpers.ts index 8f12dc0ca..bed6c8796 100644 --- a/typescript/cli/src/tests/commands/helpers.ts +++ b/typescript/cli/src/tests/commands/helpers.ts @@ -1,3 +1,4 @@ +import { ERC20Test__factory, ERC4626Test__factory } from '@hyperlane-xyz/core'; import { ChainAddresses } from '@hyperlane-xyz/registry'; import { TokenRouterConfig, @@ -10,7 +11,11 @@ import { getContext } from '../../context/context.js'; import { readYamlOrJson, writeYamlOrJson } from '../../utils/files.js'; import { hyperlaneCoreDeploy } from './core.js'; -import { hyperlaneWarpApply, readWarpConfig } from './warp.js'; +import { + hyperlaneWarpApply, + hyperlaneWarpSendRelay, + readWarpConfig, +} from './warp.js'; export const TEST_CONFIGS_PATH = './test-configs'; export const REGISTRY_PATH = `${TEST_CONFIGS_PATH}/anvil`; @@ -171,3 +176,54 @@ export async function getChainId( const chainMetadata = await registry.getChainMetadata(chainName); return String(chainMetadata?.chainId); } + +export async function deployToken(privateKey: string, chain: string) { + const { multiProvider } = await getContext({ + registryUri: REGISTRY_PATH, + registryOverrideUri: '', + key: privateKey, + }); + + const token = await new ERC20Test__factory( + multiProvider.getSigner(chain), + ).deploy('token', 'token', '100000000000000000000', 18); + await token.deployed(); + + return token; +} + +export async function deploy4626Vault( + privateKey: string, + chain: string, + tokenAddress: string, +) { + const { multiProvider } = await getContext({ + registryUri: REGISTRY_PATH, + registryOverrideUri: '', + key: privateKey, + }); + + const vault = await new ERC4626Test__factory( + multiProvider.getSigner(chain), + ).deploy(tokenAddress, 'VAULT', 'VAULT'); + await vault.deployed(); + + return vault; +} + +/** + * Performs a round-trip warp relay between two chains using the specified warp core config. + * + * @param chain1 - The first chain to send the warp relay from. + * @param chain2 - The second chain to send the warp relay to and back from. + * @param warpCoreConfigPath - The path to the warp core config file. + * @returns A promise that resolves when the round-trip warp relay is complete. + */ +export async function sendWarpRouteMessageRoundTrip( + chain1: string, + chain2: string, + warpCoreConfigPath: string, +) { + await hyperlaneWarpSendRelay(chain1, chain2, warpCoreConfigPath); + return hyperlaneWarpSendRelay(chain2, chain1, warpCoreConfigPath); +} diff --git a/typescript/cli/src/tests/commands/warp.ts b/typescript/cli/src/tests/commands/warp.ts index b526b0dcd..a55eae092 100644 --- a/typescript/cli/src/tests/commands/warp.ts +++ b/typescript/cli/src/tests/commands/warp.ts @@ -18,6 +18,7 @@ export async function hyperlaneWarpDeploy( ) { return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp deploy \ --registry ${registryPath ?? REGISTRY_PATH} \ + --overrides " " \ --config ${warpCorePath} \ --key ${key ?? ANVIL_KEY} \ --verbosity debug \ @@ -36,6 +37,7 @@ export async function hyperlaneWarpApply( ) { return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp apply \ --registry ${registryPath ?? REGISTRY_PATH} \ + --overrides " " \ --config ${warpDeployPath} \ --warp ${warpCorePath} \ --key ${key ?? ANVIL_KEY} \ @@ -53,6 +55,7 @@ export async function hyperlaneWarpRead( ) { return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp read \ --registry ${registryPath ?? REGISTRY_PATH} \ + --overrides " " \ --address ${warpAddress} \ --chain ${chain} \ --key ${key ?? ANVIL_KEY} \ @@ -60,6 +63,23 @@ export async function hyperlaneWarpRead( --config ${warpDeployPath}`; } +export async function hyperlaneWarpSendRelay( + origin: string, + destination: string, + warpCorePath: string, +) { + return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp send \ + --relay \ + --registry ${REGISTRY_PATH} \ + --overrides " " \ + --origin ${origin} \ + --destination ${destination} \ + --warp ${warpCorePath} \ + --key ${ANVIL_KEY} \ + --verbosity debug \ + --yes`; +} + /** * Reads the Warp route deployment config to specified output path. * @param warpCorePath path to warp core diff --git a/typescript/cli/src/tests/warp-apply.e2e-test.ts b/typescript/cli/src/tests/warp-apply.e2e-test.ts index 7b0c20d91..2346038d6 100644 --- a/typescript/cli/src/tests/warp-apply.e2e-test.ts +++ b/typescript/cli/src/tests/warp-apply.e2e-test.ts @@ -36,7 +36,7 @@ const TEMP_PATH = '/tmp'; // /temp gets removed at the end of all-test.sh const WARP_CONFIG_PATH_2 = `${TEMP_PATH}/anvil2/warp-route-deployment-anvil2.yaml`; const WARP_CORE_CONFIG_PATH_2 = `${REGISTRY_PATH}/deployments/warp_routes/ETH/anvil2-config.yaml`; -const TEST_TIMEOUT = 60_000; // Long timeout since these tests can take a while +const TEST_TIMEOUT = 100_000; // Long timeout since these tests can take a while describe('WarpApply e2e tests', async function () { let chain2Addresses: ChainAddresses = {}; this.timeout(TEST_TIMEOUT); diff --git a/typescript/cli/src/tests/warp-deploy.e2e-test.ts b/typescript/cli/src/tests/warp-deploy.e2e-test.ts new file mode 100644 index 000000000..6263f70cb --- /dev/null +++ b/typescript/cli/src/tests/warp-deploy.e2e-test.ts @@ -0,0 +1,114 @@ +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { ChainAddresses } from '@hyperlane-xyz/registry'; +import { TokenType, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; + +import { WarpSendLogs } from '../send/transfer.js'; +import { writeYamlOrJson } from '../utils/files.js'; + +import { + ANVIL_KEY, + REGISTRY_PATH, + deploy4626Vault, + deployOrUseExistingCore, + deployToken, + sendWarpRouteMessageRoundTrip, +} from './commands/helpers.js'; +import { hyperlaneWarpDeploy, readWarpConfig } from './commands/warp.js'; + +chai.use(chaiAsPromised); +const expect = chai.expect; +chai.should(); + +const CHAIN_NAME_2 = 'anvil2'; +const CHAIN_NAME_3 = 'anvil3'; + +const EXAMPLES_PATH = './examples'; +const TEMP_PATH = '/tmp'; // /temp gets removed at the end of all-test.sh + +const CORE_CONFIG_PATH = `${EXAMPLES_PATH}/core-config.yaml`; +const WARP_CONFIG_PATH = `${TEMP_PATH}/warp-route-deployment-deploy.yaml`; +const WARP_CORE_CONFIG_PATH_2_3 = `${REGISTRY_PATH}/deployments/warp_routes/VAULT/anvil2-anvil3-config.yaml`; + +const TEST_TIMEOUT = 60_000; // Long timeout since these tests can take a while +describe('WarpDeploy e2e tests', async function () { + let chain2Addresses: ChainAddresses = {}; + let token: any; + let vault: any; + + this.timeout(TEST_TIMEOUT); + + before(async function () { + chain2Addresses = await deployOrUseExistingCore( + CHAIN_NAME_2, + CORE_CONFIG_PATH, + ANVIL_KEY, + ); + + await deployOrUseExistingCore(CHAIN_NAME_3, CORE_CONFIG_PATH, ANVIL_KEY); + + token = await deployToken(ANVIL_KEY, CHAIN_NAME_2); + vault = await deploy4626Vault(ANVIL_KEY, CHAIN_NAME_2, token.address); + }); + + it('should only allow rebasing yield route to be deployed with rebasing synthetic', async function () { + const warpConfig: WarpRouteDeployConfig = { + [CHAIN_NAME_2]: { + type: TokenType.collateralVaultRebase, + token: vault.address, + mailbox: chain2Addresses.mailbox, + owner: chain2Addresses.mailbox, + }, + [CHAIN_NAME_3]: { + type: TokenType.synthetic, + mailbox: chain2Addresses.mailbox, + owner: chain2Addresses.mailbox, + }, + }; + + writeYamlOrJson(WARP_CONFIG_PATH, warpConfig); + await hyperlaneWarpDeploy(WARP_CONFIG_PATH).should.be.rejected; // TODO: revisit this to figure out how to parse the error. + }); + + it(`should be able to bridge between ${TokenType.collateralVaultRebase} and ${TokenType.syntheticRebase}`, async function () { + const warpConfig: WarpRouteDeployConfig = { + [CHAIN_NAME_2]: { + type: TokenType.collateralVaultRebase, + token: vault.address, + mailbox: chain2Addresses.mailbox, + owner: chain2Addresses.mailbox, + }, + [CHAIN_NAME_3]: { + type: TokenType.syntheticRebase, + mailbox: chain2Addresses.mailbox, + owner: chain2Addresses.mailbox, + collateralChainName: CHAIN_NAME_2, + }, + }; + + writeYamlOrJson(WARP_CONFIG_PATH, warpConfig); + await hyperlaneWarpDeploy(WARP_CONFIG_PATH); + + // Check collateralRebase + const collateralRebaseConfig = ( + await readWarpConfig( + CHAIN_NAME_2, + WARP_CORE_CONFIG_PATH_2_3, + WARP_CONFIG_PATH, + ) + )[CHAIN_NAME_2]; + + expect(collateralRebaseConfig.type).to.equal( + TokenType.collateralVaultRebase, + ); + + // Try to send a transaction + const { stdout } = await sendWarpRouteMessageRoundTrip( + CHAIN_NAME_2, + CHAIN_NAME_3, + WARP_CORE_CONFIG_PATH_2_3, + ); + expect(stdout).to.include(WarpSendLogs.SUCCESS); + }); +}); diff --git a/typescript/cli/src/utils/chains.ts b/typescript/cli/src/utils/chains.ts index fe226b1b8..09975aca3 100644 --- a/typescript/cli/src/utils/chains.ts +++ b/typescript/cli/src/utils/chains.ts @@ -1,13 +1,14 @@ -import { Separator, checkbox } from '@inquirer/prompts'; +import { Separator, confirm } from '@inquirer/prompts'; import select from '@inquirer/select'; import chalk from 'chalk'; import { ChainMap, ChainMetadata } from '@hyperlane-xyz/sdk'; import { toTitleCase } from '@hyperlane-xyz/utils'; -import { log, logRed, logTip } from '../logger.js'; +import { log } from '../logger.js'; import { calculatePageSize } from './cli-options.js'; +import { SearchableCheckboxChoice, searchableCheckBox } from './input.js'; // A special value marker to indicate user selected // a new chain in the list @@ -18,37 +19,101 @@ export async function runSingleChainSelectionStep( message = 'Select chain', ) { const networkType = await selectNetworkType(); - const choices = getChainChoices(chainMetadata, networkType); + const { choices, networkTypeSeparator } = getChainChoices( + chainMetadata, + networkType, + ); const chain = (await select({ message, - choices, + choices: [networkTypeSeparator, ...choices], pageSize: calculatePageSize(2), })) as string; handleNewChain([chain]); return chain; } -export async function runMultiChainSelectionStep( - chainMetadata: ChainMap, +type RunMultiChainSelectionStepOptions = { + /** + * The metadata of the chains that will be displayed to the user + */ + chainMetadata: ChainMap; + + /** + * The message to display to the user + * + * @default 'Select chains' + */ + message?: string; + + /** + * The minimum number of chains that must be selected + * + * @default 0 + */ + requireNumber?: number; + + /** + * Whether to ask for confirmation after the selection + * + * @default false + */ + requiresConfirmation?: boolean; +}; + +export async function runMultiChainSelectionStep({ + chainMetadata, message = 'Select chains', requireNumber = 0, -) { + requiresConfirmation = false, +}: RunMultiChainSelectionStepOptions) { const networkType = await selectNetworkType(); - const choices = getChainChoices(chainMetadata, networkType); + const { choices, networkTypeSeparator } = getChainChoices( + chainMetadata, + networkType, + ); + + let currentChoiceSelection = new Set(); while (true) { - logTip( - `Use SPACE key to select at least ${requireNumber} chains, then press ENTER`, - ); - const chains = (await checkbox({ + const chains = await searchableCheckBox({ message, - choices, + selectableOptionsSeparator: networkTypeSeparator, + choices: choices.map((choice) => + currentChoiceSelection.has(choice.name) + ? { ...choice, checked: true } + : choice, + ), + instructions: `Use TAB key to select at least ${requireNumber} chains, then press ENTER to proceed. Type to search for a specific chain.`, + theme: { + style: { + // The leading space is needed because the help tip will be tightly close to the message header + helpTip: (text: string) => ` ${chalk.bgYellow(text)}`, + }, + helpMode: 'always', + }, pageSize: calculatePageSize(2), - })) as string[]; + validate: (answer): string | boolean => { + if (answer.length < requireNumber) { + return `Please select at least ${requireNumber} chains`; + } + + return true; + }, + }); + handleNewChain(chains); - if (chains?.length < requireNumber) { - logRed(`Please select at least ${requireNumber} chains`); + + const confirmed = requiresConfirmation + ? await confirm({ + message: `Is this chain selection correct?: ${chalk.cyan( + chains.join(', '), + )}`, + }) + : true; + if (!confirmed) { + currentChoiceSelection = new Set(chains); continue; } + return chains; } } @@ -75,12 +140,17 @@ function getChainChoices( const filteredChains = chains.filter((c) => networkType === 'mainnet' ? !c.isTestnet : !!c.isTestnet, ); - const choices: Parameters['0']['choices'] = [ + const choices: SearchableCheckboxChoice[] = [ { name: '(New custom chain)', value: NEW_CHAIN_MARKER }, - new Separator(`--${toTitleCase(networkType)} Chains--`), ...chainsToChoices(filteredChains), ]; - return choices; + + return { + choices, + networkTypeSeparator: new Separator( + `--${toTitleCase(networkType)} Chains--`, + ), + }; } function handleNewChain(chainNames: string[]) { diff --git a/typescript/cli/src/utils/input.ts b/typescript/cli/src/utils/input.ts index 0f8c9ef66..251c6c0c5 100644 --- a/typescript/cli/src/utils/input.ts +++ b/typescript/cli/src/utils/input.ts @@ -1,8 +1,31 @@ -import { confirm, input } from '@inquirer/prompts'; +import { + Separator, + type Theme, + createPrompt, + isEnterKey, + makeTheme, + useEffect, + useKeypress, + useMemo, + usePagination, + usePrefix, + useRef, + useState, +} from '@inquirer/core'; +import figures from '@inquirer/figures'; +import { KeypressEvent, confirm, input } from '@inquirer/prompts'; +import type { PartialDeep } from '@inquirer/type'; +import ansiEscapes from 'ansi-escapes'; +import chalk from 'chalk'; -import { logGray } from '../logger.js'; +import { WarpCoreConfig } from '@hyperlane-xyz/sdk'; + +import { readWarpCoreConfig } from '../config/warp.js'; +import { CommandContext } from '../context/types.js'; +import { logGray, logRed } from '../logger.js'; import { indentYamlOrJson } from './files.js'; +import { selectRegistryWarpRoute } from './tokens.js'; export async function detectAndConfirmOrPrompt( detect: () => Promise, @@ -53,3 +76,520 @@ export async function inputWithInfo({ } while (answer === INFO_COMMAND); return answer; } + +/** + * Gets a {@link WarpCoreConfig} based on the provided path or prompts the user to choose one: + * - if `symbol` is provided the user will have to select one of the available warp routes. + * - if `warp` is provided the config will be read by the provided file path. + * - if none is provided the CLI will exit. + */ +export async function getWarpCoreConfigOrExit({ + context, + symbol, + warp, +}: { + context: CommandContext; + symbol?: string; + warp?: string; +}): Promise { + 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); + } + + return warpCoreConfig; +} + +/** + * Searchable checkbox code + * + * Note that the code below hab been implemented by taking inspiration from + * the @inquirer/prompt package search and checkbox prompts + * + * - https://github.com/SBoudrias/Inquirer.js/blob/main/packages/search/src/index.mts + * - https://github.com/SBoudrias/Inquirer.js/blob/main/packages/checkbox/src/index.mts + */ + +type Status = 'loading' | 'idle' | 'done'; + +type SearchableCheckboxTheme = { + icon: { + checked: string; + unchecked: string; + cursor: string; + }; + style: { + disabledChoice: (text: string) => string; + renderSelectedChoices: ( + selectedChoices: ReadonlyArray>, + allChoices: ReadonlyArray | Separator>, + ) => string; + description: (text: string) => string; + helpTip: (text: string) => string; + }; + helpMode: 'always' | 'never' | 'auto'; +}; + +const checkboxTheme: SearchableCheckboxTheme = { + icon: { + checked: chalk.green(figures.circleFilled), + unchecked: figures.circle, + cursor: figures.pointer, + }, + style: { + disabledChoice: (text: string) => chalk.dim(`- ${text}`), + renderSelectedChoices: (selectedChoices) => + selectedChoices.map((choice) => choice.short).join(', '), + description: (text: string) => chalk.cyan(text), + helpTip: (text) => ` ${text}`, + }, + helpMode: 'always', +}; + +export type SearchableCheckboxChoice = { + value: Value; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; + checked?: boolean; +}; + +type NormalizedChoice = Required< + Omit, 'description'> +> & { + description?: string; +}; + +type SearchableCheckboxConfig = { + message: string; + prefix?: string; + pageSize?: number; + instructions?: string; + choices: ReadonlyArray>; + loop?: boolean; + required?: boolean; + selectableOptionsSeparator?: Separator; + validate?: ( + choices: ReadonlyArray>, + ) => boolean | string | Promise; + theme?: PartialDeep>; +}; + +type Item = NormalizedChoice | Separator; + +type SearchableCheckboxState = { + options: Item[]; + currentOptionState: Record>; +}; + +function isSelectable( + item: Item, +): item is NormalizedChoice { + return !Separator.isSeparator(item) && !item.disabled; +} + +function isChecked(item: Item): item is NormalizedChoice { + return isSelectable(item) && Boolean(item.checked); +} + +function toggle(item: Item): Item { + return isSelectable(item) ? { ...item, checked: !item.checked } : item; +} + +function normalizeChoices( + choices: ReadonlyArray>, +): NormalizedChoice[] { + return choices.map((choice) => { + const name = choice.name ?? String(choice.value); + return { + value: choice.value, + name, + short: choice.short ?? name, + description: choice.description, + disabled: choice.disabled ?? false, + checked: choice.checked ?? false, + }; + }); +} + +function sortNormalizedItems( + a: NormalizedChoice, + b: NormalizedChoice, +): number { + return a.name.localeCompare(b.name); +} + +function organizeItems( + items: Array>, + selectableOptionsSeparator?: Separator, +): Array | Separator> { + const orderedItems = []; + + const checkedItems = items.filter( + (item) => !Separator.isSeparator(item) && item.checked, + ) as NormalizedChoice[]; + + if (checkedItems.length !== 0) { + orderedItems.push(new Separator('--Selected Options--')); + + orderedItems.push(...checkedItems.sort(sortNormalizedItems)); + } + + orderedItems.push( + selectableOptionsSeparator ?? new Separator('--Available Options--'), + ); + + const nonCheckedItems = items.filter( + (item) => !Separator.isSeparator(item) && !item.checked, + ) as NormalizedChoice[]; + + orderedItems.push(...nonCheckedItems.sort(sortNormalizedItems)); + + if (orderedItems.length === 1) { + return []; + } + + return orderedItems; +} + +interface BuildViewOptions { + theme: Readonly>; + pageSize: number; + firstRender: { current: boolean }; + page: string; + currentOptions: ReadonlyArray>; + prefix: string; + message: string; + errorMsg?: string; + status: Status; + searchTerm: string; + description?: string; + instructions?: string; +} + +interface GetErrorMessageOptions + extends Pick< + BuildViewOptions, + 'theme' | 'errorMsg' | 'status' | 'searchTerm' + > { + currentItemCount: number; +} + +function getErrorMessage({ + theme, + errorMsg, + currentItemCount, + status, + searchTerm, +}: GetErrorMessageOptions): string { + if (errorMsg) { + return `${theme.style.error(errorMsg)}`; + } else if (currentItemCount === 0 && searchTerm !== '' && status === 'idle') { + return theme.style.error('No results found'); + } + + return ''; +} + +interface GetHelpTipsOptions + extends Pick< + BuildViewOptions, + 'theme' | 'pageSize' | 'firstRender' | 'instructions' + > { + currentItemCount: number; +} + +function getHelpTips({ + theme, + instructions, + currentItemCount, + pageSize, + firstRender, +}: GetHelpTipsOptions): { helpTipTop: string; helpTipBottom: string } { + let helpTipTop = ''; + let helpTipBottom = ''; + const defaultTopHelpTip = + instructions ?? + `(Press ${theme.style.key('tab')} to select, and ${theme.style.key( + 'enter', + )} to proceed`; + const defaultBottomHelpTip = `\n${theme.style.help( + '(Use arrow keys to reveal more choices)', + )}`; + + if (theme.helpMode === 'always') { + helpTipTop = theme.style.helpTip(defaultTopHelpTip); + helpTipBottom = currentItemCount > pageSize ? defaultBottomHelpTip : ''; + firstRender.current = false; + } else if (theme.helpMode === 'auto' && firstRender.current) { + helpTipTop = theme.style.helpTip(defaultTopHelpTip); + helpTipBottom = currentItemCount > pageSize ? defaultBottomHelpTip : ''; + firstRender.current = false; + } + + return { helpTipBottom, helpTipTop }; +} + +function formatRenderedItem( + item: Readonly>, + isActive: boolean, + theme: Readonly>, +): string { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return theme.style.disabledChoice(`${item.name} ${disabledLabel}`); + } + + const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked; + const color = isActive ? theme.style.highlight : (x: string) => x; + const cursor = isActive ? theme.icon.cursor : ' '; + return color(`${cursor}${checkbox} ${item.name}`); +} + +function getListBounds(items: ReadonlyArray>): { + first: number; + last: number; +} { + const first = items.findIndex(isSelectable); + // findLastIndex replacement as the project must support older ES versions + let last = -1; + for (let i = items.length; i >= 0; --i) { + if (items[i] && isSelectable(items[i])) { + last = i; + break; + } + } + + return { first, last }; +} + +function buildView({ + page, + prefix, + theme, + status, + message, + errorMsg, + pageSize, + firstRender, + searchTerm, + description, + instructions, + currentOptions, +}: BuildViewOptions): string { + message = theme.style.message(message); + if (status === 'done') { + const selection = currentOptions.filter(isChecked); + const answer = theme.style.answer( + theme.style.renderSelectedChoices(selection, currentOptions), + ); + + return `${prefix} ${message} ${answer}`; + } + + const currentItemCount = currentOptions.length; + const { helpTipBottom, helpTipTop } = getHelpTips({ + theme, + instructions, + currentItemCount, + pageSize, + firstRender, + }); + + const choiceDescription = description + ? `\n${theme.style.description(description)}` + : ``; + + const error = getErrorMessage({ + theme, + errorMsg, + currentItemCount, + status, + searchTerm, + }); + + return `${prefix} ${message}${helpTipTop} ${searchTerm}\n${page}${helpTipBottom}${choiceDescription}${error}${ansiEscapes.cursorHide}`; +} + +// the isUpKey function from the inquirer package is not used +// because it detects k and p as custom keybindings that cause +// the option selection to go up instead of writing the letters +// in the search string +function isUpKey(key: KeypressEvent): boolean { + return key.name === 'up'; +} + +// the isDownKey function from the inquirer package is not used +// because it detects j and n as custom keybindings that cause +// the option selection to go down instead of writing the letters +// in the search string +function isDownKey(key: KeypressEvent): boolean { + return key.name === 'down'; +} + +export const searchableCheckBox = createPrompt( + ( + config: SearchableCheckboxConfig, + done: (value: Array) => void, + ) => { + const { + instructions, + pageSize = 7, + loop = true, + required, + validate = () => true, + selectableOptionsSeparator, + } = config; + const theme = makeTheme( + checkboxTheme, + config.theme, + ); + const firstRender = useRef(true); + const [status, setStatus] = useState('idle'); + const prefix = usePrefix({ theme }); + const [searchTerm, setSearchTerm] = useState(''); + const [errorMsg, setError] = useState(); + + const normalizedChoices = normalizeChoices(config.choices); + const [optionState, setOptionState] = useState< + SearchableCheckboxState + >({ + options: normalizedChoices, + currentOptionState: Object.fromEntries( + normalizedChoices.map((item) => [item.name, item]), + ), + }); + + const bounds = useMemo( + () => getListBounds(optionState.options), + [optionState.options], + ); + + const [active, setActive] = useState(bounds.first); + + useEffect(() => { + let filteredItems; + if (!searchTerm) { + filteredItems = Object.values(optionState.currentOptionState); + } else { + filteredItems = Object.values(optionState.currentOptionState).filter( + (item) => + Separator.isSeparator(item) || + item.name.includes(searchTerm) || + item.checked, + ); + } + + setActive(0); + setError(undefined); + setOptionState({ + currentOptionState: optionState.currentOptionState, + options: organizeItems(filteredItems, selectableOptionsSeparator), + }); + }, [searchTerm]); + + useKeypress(async (key, rl) => { + if (isEnterKey(key)) { + const selection = optionState.options.filter(isChecked); + const isValid = await validate(selection); + if (required && !optionState.options.some(isChecked)) { + setError('At least one choice must be selected'); + } else if (isValid === true) { + setStatus('done'); + done(selection.map((choice) => choice.value)); + } else { + setError(isValid || 'You must select a valid value'); + setSearchTerm(''); + } + } else if (isUpKey(key) || isDownKey(key)) { + if ( + loop || + (isUpKey(key) && active !== bounds.first) || + (isDownKey(key) && active !== bounds.last) + ) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = + (next + offset + optionState.options.length) % + optionState.options.length; + } while ( + optionState.options[next] && + !isSelectable(optionState.options[next]) + ); + setActive(next); + } + } else if (key.name === 'tab' && optionState.options.length > 0) { + // Avoid the message header to be printed again in the console + rl.clearLine(0); + + const currentElement = optionState.options[active]; + if ( + currentElement && + !Separator.isSeparator(currentElement) && + optionState.currentOptionState[currentElement.name] + ) { + const updatedDataMap: Record> = { + ...optionState.currentOptionState, + [currentElement.name]: toggle( + optionState.currentOptionState[currentElement.name], + ) as NormalizedChoice, + }; + + setError(undefined); + setOptionState({ + options: organizeItems( + Object.values(updatedDataMap), + selectableOptionsSeparator, + ), + currentOptionState: updatedDataMap, + }); + setSearchTerm(''); + } + } else { + setSearchTerm(rl.line); + } + }); + + let description; + const page = usePagination({ + items: optionState.options, + active, + renderItem({ item, isActive }) { + if (isActive && !Separator.isSeparator(item)) { + description = item.description; + } + + return formatRenderedItem(item, isActive, theme); + }, + pageSize, + loop, + }); + + return buildView({ + page, + theme, + prefix, + status, + pageSize, + errorMsg, + firstRender, + searchTerm, + description, + instructions, + currentOptions: optionState.options, + message: theme.style.message(config.message), + }); + }, +); diff --git a/typescript/cli/src/utils/output.ts b/typescript/cli/src/utils/output.ts new file mode 100644 index 000000000..442b8a090 --- /dev/null +++ b/typescript/cli/src/utils/output.ts @@ -0,0 +1,56 @@ +import chalk from 'chalk'; + +export enum ViolationDiffType { + None, + Expected, + Actual, +} + +type FormatterByDiffType = Record string>; + +const defaultDiffFormatter: FormatterByDiffType = { + [ViolationDiffType.Actual]: chalk.red, + [ViolationDiffType.Expected]: chalk.green, + [ViolationDiffType.None]: (text: string) => text, +}; + +/** + * Takes a yaml formatted string and highlights differences by looking at `expected` and `actual` properties. + */ +export function formatYamlViolationsOutput( + yamlString: string, + formatters: FormatterByDiffType = defaultDiffFormatter, +): string { + const lines = yamlString.split('\n'); + + let curr: ViolationDiffType = ViolationDiffType.None; + let lastDiffIndent = 0; + const highlightedLines = lines.map((line) => { + // Get how many white space/tabs we have before the property name + const match = line.match(/^(\s*)/); + const currentIndent = match ? match[0].length : 0; + + let formattedLine = line; + // if the current indentation is smaller than the previous diff one + // we just got out of a diff property and we reset the formatting + if (currentIndent < lastDiffIndent) { + curr = ViolationDiffType.None; + } + + if (line.includes('expected:')) { + lastDiffIndent = currentIndent; + curr = ViolationDiffType.Expected; + formattedLine = line.replace('expected:', 'EXPECTED:'); + } + + if (line.includes('actual:')) { + lastDiffIndent = currentIndent; + curr = ViolationDiffType.Actual; + formattedLine = line.replace('actual:', 'ACTUAL:'); + } + + return formatters[curr](formattedLine); + }); + + return highlightedLines.join('\n'); +} diff --git a/typescript/infra/config/environments/mainnet3/agent.ts b/typescript/infra/config/environments/mainnet3/agent.ts index 40464a562..6b6f2bae5 100644 --- a/typescript/infra/config/environments/mainnet3/agent.ts +++ b/typescript/infra/config/environments/mainnet3/agent.ts @@ -418,7 +418,7 @@ const hyperlane: RootAgentConfig = { rpcConsensusType: RpcConsensusType.Fallback, docker: { repo, - tag: '18c29c8-20241014-133718', + tag: 'b1ff48b-20241016-183301', }, gasPaymentEnforcement: gasPaymentEnforcement, metricAppContexts, @@ -427,7 +427,7 @@ const hyperlane: RootAgentConfig = { validators: { docker: { repo, - tag: '18c29c8-20241014-133718', + tag: 'efd438f-20241016-101828', }, rpcConsensusType: RpcConsensusType.Quorum, chains: validatorChainConfig(Contexts.Hyperlane), @@ -437,7 +437,7 @@ const hyperlane: RootAgentConfig = { rpcConsensusType: RpcConsensusType.Fallback, docker: { repo, - tag: '18c29c8-20241014-133718', + tag: '9c0c4bb-20241018-113820', }, resources: scraperResources, }, @@ -452,7 +452,7 @@ const releaseCandidate: RootAgentConfig = { rpcConsensusType: RpcConsensusType.Fallback, docker: { repo, - tag: '5cb1787-20240924-192934', + tag: 'b1ff48b-20241016-183301', }, // We're temporarily (ab)using the RC relayer as a way to increase // message throughput. @@ -485,7 +485,7 @@ const neutron: RootAgentConfig = { rpcConsensusType: RpcConsensusType.Fallback, docker: { repo, - tag: '5a0d68b-20240916-144115', + tag: 'b1ff48b-20241016-183301', }, gasPaymentEnforcement: [ { diff --git a/typescript/infra/config/environments/mainnet3/chains.ts b/typescript/infra/config/environments/mainnet3/chains.ts index e7ff32d46..b32fdeff1 100644 --- a/typescript/infra/config/environments/mainnet3/chains.ts +++ b/typescript/infra/config/environments/mainnet3/chains.ts @@ -31,6 +31,14 @@ export const chainMetadataOverrides: ChainMap> = { gasPrice: 1 * 10 ** 9, // 1 gwei }, }, + scroll: { + transactionOverrides: { + // Scroll doesn't use EIP 1559 and the gas price that's returned is sometimes + // too low for the transaction to be included in a reasonable amount of time - + // this often leads to transaction underpriced issues. + gasPrice: 2 * 10 ** 8, // 0.2 gwei + }, + }, sei: { // Sei's `eth_feeHistory` is not to spec and incompatible with ethers-rs, // so we force legacy transactions by setting a gas price. @@ -51,12 +59,17 @@ export const chainMetadataOverrides: ChainMap> = { // gasLimit: 6800000, // set when deploying contracts }, }, - // set when deploying contracts + // Deploy-only overrides, set when deploying contracts // chiliz: { // transactionOverrides: { // maxFeePerGas: 100000 * 10 ** 9, // 100,000 gwei // }, // }, + // zircuit: { + // blocks: { + // confirmations: 3, + // }, + // }, }; export const getRegistry = async (useSecrets = true): Promise => diff --git a/typescript/infra/config/environments/mainnet3/funding.ts b/typescript/infra/config/environments/mainnet3/funding.ts index 2dcd8d134..dafee524d 100644 --- a/typescript/infra/config/environments/mainnet3/funding.ts +++ b/typescript/infra/config/environments/mainnet3/funding.ts @@ -10,7 +10,7 @@ export const keyFunderConfig: KeyFunderConfig< > = { docker: { repo: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', - tag: '18c29c8-20241014-133714', + tag: '436988a-20241017-151047', }, // We're currently using the same deployer/key funder key as mainnet2. // To minimize nonce clobbering we offset the key funder cron diff --git a/typescript/infra/config/environments/mainnet3/gasPrices.json b/typescript/infra/config/environments/mainnet3/gasPrices.json index 81e8ae1ef..d68a6d290 100644 --- a/typescript/infra/config/environments/mainnet3/gasPrices.json +++ b/typescript/infra/config/environments/mainnet3/gasPrices.json @@ -24,7 +24,7 @@ "decimals": 9 }, "base": { - "amount": "0.008669818", + "amount": "0.015396226", "decimals": 9 }, "bitlayer": { @@ -32,7 +32,7 @@ "decimals": 9 }, "blast": { - "amount": "0.004707204", + "amount": "0.005712307", "decimals": 9 }, "bob": { @@ -72,15 +72,15 @@ "decimals": 9 }, "eclipsemainnet": { - "amount": "0.001", - "decimals": 9 + "amount": "0.0000001", + "decimals": 1 }, "endurance": { "amount": "3.0756015", "decimals": 9 }, "ethereum": { - "amount": "21.610477208", + "amount": "14.852716956", "decimals": 9 }, "everclear": { @@ -88,11 +88,11 @@ "decimals": 9 }, "flare": { - "amount": "29.55878872", + "amount": "49.455765643", "decimals": 9 }, "flow": { - "amount": "0.0000001", + "amount": "0.1", "decimals": 9 }, "fraxtal": { @@ -104,11 +104,11 @@ "decimals": 9 }, "gnosis": { - "amount": "2.000000007", + "amount": "1.500000007", "decimals": 9 }, "immutablezkevm": { - "amount": "10.00000005", + "amount": "10.1", "decimals": 9 }, "inevm": { @@ -124,7 +124,7 @@ "decimals": 9 }, "linea": { - "amount": "0.240000007", + "amount": "0.243", "decimals": 9 }, "lisk": { @@ -156,7 +156,7 @@ "decimals": 9 }, "metis": { - "amount": "1.247735823", + "amount": "1.278943587", "decimals": 9 }, "mint": { @@ -236,12 +236,12 @@ "decimals": 9 }, "shibarium": { - "amount": "28.138673121", + "amount": "39.319461243", "decimals": 9 }, "solanamainnet": { - "amount": "0.001", - "decimals": 9 + "amount": "0.5", + "decimals": 1 }, "superposition": { "amount": "0.01", diff --git a/typescript/infra/config/environments/mainnet3/owners.ts b/typescript/infra/config/environments/mainnet3/owners.ts index 66cd8d17d..e32d8961c 100644 --- a/typescript/infra/config/environments/mainnet3/owners.ts +++ b/typescript/infra/config/environments/mainnet3/owners.ts @@ -68,13 +68,14 @@ export const icas: Partial< inevm: '0xFDF9EDcb2243D51f5f317b9CEcA8edD2bEEE036e', // Jul 26, 2024 batch - // ------------------------------------- + // ---------------------------------------------------------- xlayer: '0x1571c482fe9E76bbf50829912b1c746792966369', cheesechain: '0xEe2C5320BE9bC7A1492187cfb289953b53E3ff1b', worldchain: '0x1996DbFcFB433737fE404F58D2c32A7f5f334210', // zircuit: '0x0d67c56E818a02ABa58cd2394b95EF26db999aA3', // already has a safe // Aug 5, 2024 batch + // ---------------------------------------------------------- cyber: '0x984Fe5a45Ac4aaeC4E4655b50f776aB79c9Be19F', degenchain: '0x22d952d3b9F493442731a3c7660aCaD98e55C00A', kroma: '0xc1e20A0D78E79B94D71d4bDBC8FD0Af7c856Dd7A', @@ -88,9 +89,10 @@ export const icas: Partial< sanko: '0x5DAcd2f1AafC749F2935A160865Ab1568eC23752', tangle: '0xCC2aeb692197C7894E561d31ADFE8F79746f7d9F', xai: '0x22d952d3b9F493442731a3c7660aCaD98e55C00A', - // taiko: '0x483D218D2FEe7FC7204ba15F00C7901acbF9697D', // already has a safe + // taiko: '0x483D218D2FEe7FC7204ba15F00C7901acbF9697D', // renzo chain // Aug 26, 2024 batch + // ---------------------------------------------------------- astar: '0x6b241544eBa7d89B51b72DF85a0342dAa37371Ca', astarzkevm: '0x526c6DAee1175A1A2337E703B63593acb327Dde4', bitlayer: '0xe6239316cA60814229E801fF0B9DD71C9CA29008', @@ -101,9 +103,41 @@ export const icas: Partial< shibarium: '0x6348FAe3a8374dbAAaE84EEe5458AE4063Fe2be7', // Sep 9, 2024 batch - // ---------------------------- + // ---------------------------------------------------------- everclear: '0x63B2075372D6d0565F51210D0A296D3c8a773aB6', oortmainnet: '0x7021D11F9fAe455AB2f45D72dbc2C64d116Cb657', + + // Sep 19, 2024 SAFE --> ICA v1 Migration + // ---------------------------------------------------------- + celo: '0x3fA264c58E1365f1d5963B831b864EcdD2ddD19b', + avalanche: '0x8c8695cD9905e22d84E466804ABE55408A87e595', + polygon: '0xBDD25dd5203fedE33FD631e30fEF9b9eF2598ECE', + moonbeam: '0x480e5b5De6a29F07fe8295C60A1845d36b7BfdE6', + gnosis: '0xD42125a4889A7A36F32d7D12bFa0ae52B0AD106b', + scroll: '0x2a3fe2513F4A7813683d480724AB0a3683EfF8AC', + polygonzkevm: '0x66037De438a59C966214B78c1d377c4e93a5C7D1', + ancient8: '0xA9FD5BeB556AB1859D7625B381110a257f56F98C', + redstone: '0x5DAcd2f1AafC749F2935A160865Ab1568eC23752', + mantle: '0x08C880b88335CA3e85Ebb4E461245a7e899863c9', + bob: '0xc99e58b9A4E330e2E4d09e2c94CD3c553904F588', + zetachain: '0xc876B8e63c3ff5b636d9492715BE375644CaD345', + zoramainnet: '0x84977Eb15E0ff5824a6129c789F70e88352C230b', + fusemainnet: '0xbBdb1682B2922C282b56DD716C29db5EFbdb5632', + endurance: '0x470E04D8a3b7938b385093B93CeBd8Db7A1E557C', + // sei: '0xabad187003EdeDd6C720Fc633f929EA632996567', // renzo chain + + // Oct 16, 2024 batch + // ---------------------------------------------------------- + immutablezkevm: '0x8483e1480B62cB9f0aCecEbF42469b9f4013577a', + rari: '0x1124D54E989570A798769E534eAFbE1444f40AF6', + rootstock: '0x69350aeA98c5195F2c3cC6E6A065d0d8B12F659A', + alephzeroevm: '0x004a4C2e4Cd4F5Bd564fe0A6Ab2Da56258aE576f', + chiliz: '0xb52D281aD2BA9761c16f400d755837493e2baDB7', + lumia: '0x418E10Ac9e0b84022d0636228d05bc74172e0e41', + superposition: '0x34b57ff8fBA8da0cFdA795CC0F874FfaB14B1DE9', + flow: '0xf48377f8A3ddA7AAD7C2460C81d939434c829b45', + metall2: '0x2f1b1B0Fb7652E621316460f6c3b019F61d8dC9a', + polynomial: '0xC20eFa1e5A378af9233e9b24515eb3408d43f900', } as const; export const DEPLOYER = '0xa7ECcdb9Be08178f896c26b7BbD8C3D4E844d9Ba'; diff --git a/typescript/infra/config/environments/mainnet3/safe/safeSigners.json b/typescript/infra/config/environments/mainnet3/safe/safeSigners.json index d66b992ab..fca35815f 100644 --- a/typescript/infra/config/environments/mainnet3/safe/safeSigners.json +++ b/typescript/infra/config/environments/mainnet3/safe/safeSigners.json @@ -3,7 +3,7 @@ "0xa7ECcdb9Be08178f896c26b7BbD8C3D4E844d9Ba", "0xc3E966E79eF1aA4751221F55fB8A36589C24C0cA", "0x3b7f8f68A4FD0420FeA2F42a1eFc53422f205599", - "0x88436919fAa2310d32A36D20d13E0a441D24fAc3", + "0x478be6076f31E9666123B9721D0B6631baD944AF", "0x003DDD9eEAb62013b7332Ab4CC6B10077a8ca961", "0xd00d6A31485C93c597D1d8231eeeE0ed17B9844B", "0x483fd7284A696343FEc0819DDF2cf7E06E8A06E5", diff --git a/typescript/infra/config/environments/mainnet3/tokenPrices.json b/typescript/infra/config/environments/mainnet3/tokenPrices.json index 5cbf8f157..df245702e 100644 --- a/typescript/infra/config/environments/mainnet3/tokenPrices.json +++ b/typescript/infra/config/environments/mainnet3/tokenPrices.json @@ -1,73 +1,73 @@ { - "ancient8": "2437.96", - "alephzeroevm": "0.36741", - "arbitrum": "2437.96", - "astar": "0.059165", - "astarzkevm": "2437.96", - "avalanche": "26.77", - "base": "2437.96", - "bitlayer": "62244", - "blast": "2437.96", - "bob": "2437.96", - "bsc": "572.12", - "celo": "0.764821", - "cheesechain": "0.00448064", - "chiliz": "0.069844", - "coredao": "0.912209", - "cyber": "2437.96", - "degenchain": "0.00934571", - "dogechain": "0.110085", - "eclipsemainnet": "2437.96", - "endurance": "2.11", - "ethereum": "2437.96", - "everclear": "2437.96", - "flare": "0.01456139", - "flow": "0.533589", - "fraxtal": "2434.37", - "fusemainnet": "0.02952521", - "gnosis": "1.009", - "immutablezkevm": "1.48", - "inevm": "20.24", - "injective": "20.24", - "kroma": "2437.96", - "linea": "2437.96", - "lisk": "2437.96", - "lukso": "1.51", - "lumia": "0.954153", - "mantapacific": "2437.96", - "mantle": "0.59813", - "merlin": "62293", - "metall2": "2437.96", - "metis": "34.53", - "mint": "2437.96", - "mode": "2437.96", - "molten": "0.632429", - "moonbeam": "0.163919", - "neutron": "0.390086", - "oortmainnet": "0.11645", - "optimism": "2437.96", - "osmosis": "0.521323", - "polygon": "0.371959", - "polygonzkevm": "2437.96", - "polynomial": "2437.96", - "proofofplay": "2437.96", - "rari": "2437.96", + "ancient8": "2629.74", + "alephzeroevm": "0.381786", + "arbitrum": "2629.74", + "astar": "0.061114", + "astarzkevm": "2629.74", + "avalanche": "27.96", + "base": "2629.74", + "bitlayer": "67813", + "blast": "2629.74", + "bob": "2629.74", + "bsc": "597.89", + "celo": "0.817141", + "cheesechain": "0.00556724", + "chiliz": "0.079288", + "coredao": "0.98348", + "cyber": "2629.74", + "degenchain": "0.00882961", + "dogechain": "0.126177", + "eclipsemainnet": "2629.74", + "endurance": "2.16", + "ethereum": "2629.74", + "everclear": "2629.74", + "flare": "0.01493582", + "flow": "0.558323", + "fraxtal": "2629.35", + "fusemainnet": "0.02901498", + "gnosis": "0.997404", + "immutablezkevm": "1.54", + "inevm": "21.05", + "injective": "21.05", + "kroma": "2629.74", + "linea": "2629.74", + "lisk": "2629.74", + "lukso": "1.47", + "lumia": "0.969511", + "mantapacific": "2629.74", + "mantle": "0.636484", + "merlin": "67781", + "metall2": "2629.74", + "metis": "45.78", + "mint": "2629.74", + "mode": "2629.74", + "molten": "0.436605", + "moonbeam": "0.169406", + "neutron": "0.408859", + "oortmainnet": "0.114304", + "optimism": "2629.74", + "osmosis": "0.558566", + "polygon": "0.371646", + "polygonzkevm": "2629.74", + "polynomial": "2629.74", + "proofofplay": "2629.74", + "rari": "2629.74", "real": "1", - "redstone": "2437.96", - "rootstock": "61812", - "sanko": "41.59", - "scroll": "2437.96", - "sei": "0.444401", - "shibarium": "0.404651", - "solanamainnet": "144.84", - "superposition": "2437.96", - "taiko": "2437.96", + "redstone": "2629.74", + "rootstock": "67219", + "sanko": "70.7", + "scroll": "2629.74", + "sei": "0.447635", + "shibarium": "0.410927", + "solanamainnet": "155.35", + "superposition": "2629.74", + "taiko": "2629.74", "tangle": "1", - "viction": "0.359062", - "worldchain": "2437.96", - "xai": "0.215315", - "xlayer": "42.29", - "zetachain": "0.581304", - "zircuit": "2437.96", - "zoramainnet": "2437.96" + "viction": "0.369839", + "worldchain": "2629.74", + "xai": "0.216438", + "xlayer": "41.56", + "zetachain": "0.617959", + "zircuit": "2629.74", + "zoramainnet": "2629.74" } diff --git a/typescript/infra/config/environments/mainnet3/warp/EZETH-deployments.yaml b/typescript/infra/config/environments/mainnet3/warp/EZETH-deployments.yaml index bc90c54bb..b83663645 100644 --- a/typescript/infra/config/environments/mainnet3/warp/EZETH-deployments.yaml +++ b/typescript/infra/config/environments/mainnet3/warp/EZETH-deployments.yaml @@ -9,7 +9,7 @@ data: name: Renzo Restaked ETH symbol: ezETH hypAddress: '0xC59336D8edDa9722B4f1Ec104007191Ec16f7087' - tokenAddress: '0x2416092f143378750bb29b79eD961ab195CcEea5' + tokenAddress: '0xbf5495Efe5DB9ce00f80364C8B423567e58d2110' decimals: 18 bsc: protocolType: ethereum diff --git a/typescript/infra/config/environments/mainnet3/warp/bsc-lumia-LUMIA-deployments.yaml b/typescript/infra/config/environments/mainnet3/warp/bsc-lumia-LUMIA-deployments.yaml new file mode 100644 index 000000000..9db075248 --- /dev/null +++ b/typescript/infra/config/environments/mainnet3/warp/bsc-lumia-LUMIA-deployments.yaml @@ -0,0 +1,29 @@ +# Configs and artifacts for the deployment of Hyperlane Warp Routes +# Between Ethereum and Binance Smart Chain and Lumia +description: Hyperlane Warp Route artifacts +timestamp: '2024-10-18T14:00:00.000Z' +deployer: Abacus Works (Hyperlane) +data: + config: + ethereum: + protocolType: ethereum + type: collateral + hypAddress: '0xdD313D475f8A9d81CBE2eA953a357f52e10BA357' + tokenAddress: '0xd9343a049d5dbd89cd19dc6bca8c48fb3a0a42a7' + name: Lumia Token + symbol: LUMIA + decimals: 18 + bsc: + protocolType: ethereum + type: synthetic + hypAddress: '0x7F39BcdCa8E0E581c1d43aaa1cB862AA1c8C2047' + name: Lumia Token + symbol: LUMIA + decimals: 18 + lumia: + protocolType: ethereum + type: native + hypAddress: '0x6a77331cE28E47c3Cb9Fea48AB6cD1e9594ce0A9' + name: Lumia Token + symbol: LUMIA + decimals: 18 diff --git a/typescript/infra/config/environments/mainnet3/warp/configGetters/getEthereumSeiFastUSDWarpConfig.ts b/typescript/infra/config/environments/mainnet3/warp/configGetters/getEthereumSeiFastUSDWarpConfig.ts new file mode 100644 index 000000000..5bd4938d2 --- /dev/null +++ b/typescript/infra/config/environments/mainnet3/warp/configGetters/getEthereumSeiFastUSDWarpConfig.ts @@ -0,0 +1,47 @@ +import { ethers } from 'ethers'; + +import { + ChainMap, + RouterConfig, + TokenRouterConfig, + TokenType, +} from '@hyperlane-xyz/sdk'; + +import { tokens } from '../../../../../src/config/warp.js'; + +// Elixir +const owner = '0x00000000F51340906F767C6999Fe512b1275955C'; + +export const getEthereumSeiFastUSDWarpConfig = async ( + routerConfig: ChainMap, +): Promise> => { + const sei: TokenRouterConfig = { + ...routerConfig.viction, + type: TokenType.XERC20, + name: 'fastUSD', + symbol: 'fastUSD', + decimals: 18, + token: tokens.sei.fastUSD, + interchainSecurityModule: ethers.constants.AddressZero, + owner, + ownerOverrides: { + proxyAdmin: owner, + }, + }; + + const ethereum: TokenRouterConfig = { + ...routerConfig.ethereum, + type: TokenType.collateral, + token: tokens.ethereum.deUSD, + owner, + interchainSecurityModule: ethers.constants.AddressZero, + ownerOverrides: { + proxyAdmin: owner, + }, + }; + + return { + sei, + ethereum, + }; +}; diff --git a/typescript/infra/config/environments/mainnet3/warp/sei-FASTUSD-deployments.yaml b/typescript/infra/config/environments/mainnet3/warp/sei-FASTUSD-deployments.yaml new file mode 100644 index 000000000..885c1a4db --- /dev/null +++ b/typescript/infra/config/environments/mainnet3/warp/sei-FASTUSD-deployments.yaml @@ -0,0 +1,21 @@ +description: Hyperlane Warp Route artifacts +timestamp: '2024-10-17T14:00:00.000Z' +deployer: Abacus Works (Hyperlane) +data: + config: + ethereum: + protocolType: ethereum + type: collateral + hypAddress: '0x9AD81058c6C3Bf552C9014CB30E824717A0ee21b' + tokenAddress: '0x15700B564Ca08D9439C58cA5053166E8317aa138' + name: fastUSD + symbol: fastUSD + decimals: 18 + sei: + protocolType: ethereum + type: xERC20 + hypAddress: '0xeA895A7Ff45d8d3857A04c1E38A362f3bd9a076f' + tokenAddress: '0x37a4dD9CED2b19Cfe8FAC251cd727b5787E45269' + name: fastUSD + symbol: fastUSD + decimals: 18 diff --git a/typescript/infra/config/environments/mainnet3/warp/warpIds.ts b/typescript/infra/config/environments/mainnet3/warp/warpIds.ts index 5e8052d42..9ff51677e 100644 --- a/typescript/infra/config/environments/mainnet3/warp/warpIds.ts +++ b/typescript/infra/config/environments/mainnet3/warp/warpIds.ts @@ -1,6 +1,6 @@ export enum WarpRouteIds { Ancient8EthereumUSDC = 'USDC/ancient8-ethereum', - ArbitrumBaseBlastBscEthereumFraxtalLineaModeOptimismZircuitEZETH = 'EZETH/arbitrum-base-blast-bsc-ethereum-fraxtal-linea-mode-optimism-zircuit', + ArbitrumBaseBlastBscEthereumFraxtalLineaModeOptimismSeiTaikoZircuitEZETH = 'EZETH/arbitrum-base-blast-bsc-ethereum-fraxtal-linea-mode-optimism-sei-taiko-zircuit', ArbitrumNeutronEclip = 'ECLIP/arbitrum-neutron', ArbitrumNeutronTIA = 'TIA/arbitrum-neutron', EclipseSolanaSOL = 'SOL/eclipsemainnet-solanamainnet', @@ -9,9 +9,11 @@ export enum WarpRouteIds { EthereumInevmUSDT = 'USDT/ethereum-inevm', EthereumEclipseTETH = 'tETH/eclipsemainnet-ethereum', EthereumEclipseUSDC = 'USDC/eclipsemainnet-ethereum-solanamainnet', + EthereumSeiFastUSD = 'FASTUSD/ethereum-sei', EthereumVictionETH = 'ETH/ethereum-viction', EthereumVictionUSDC = 'USDC/ethereum-viction', EthereumVictionUSDT = 'USDT/ethereum-viction', + EthereumZircuitPZETH = 'PZETH/ethereum-zircuit', InevmInjectiveINJ = 'INJ/inevm-injective', MantapacificNeutronTIA = 'TIA/mantapacific-neutron', } diff --git a/typescript/infra/config/warp.ts b/typescript/infra/config/warp.ts index 994c9fa9c..447183e8c 100644 --- a/typescript/infra/config/warp.ts +++ b/typescript/infra/config/warp.ts @@ -11,16 +11,16 @@ import { EnvironmentConfig } from '../src/config/environment.js'; import { getAncient8EthereumUSDCWarpConfig } from './environments/mainnet3/warp/configGetters/getAncient8EthereumUSDCWarpConfig.js'; import { getArbitrumNeutronEclipWarpConfig } from './environments/mainnet3/warp/configGetters/getArbitrumNeutronEclipWarpConfig.js'; import { getArbitrumNeutronTiaWarpConfig } from './environments/mainnet3/warp/configGetters/getArbitrumNeutronTiaWarpConfig.js'; -import { getEthereumEclipseTETHWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumEclipseTETHWarpConfig.js'; -import { getEthereumEclipseUSDCWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumEclipseUSDCWarpConfig.js'; import { getEthereumInevmUSDCWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumInevmUSDCWarpConfig.js'; import { getEthereumInevmUSDTWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumInevmUSDTWarpConfig.js'; +import { getEthereumSeiFastUSDWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumSeiFastUSDWarpConfig.js'; import { getEthereumVictionETHWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumVictionETHWarpConfig.js'; import { getEthereumVictionUSDCWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumVictionUSDCWarpConfig.js'; import { getEthereumVictionUSDTWarpConfig } from './environments/mainnet3/warp/configGetters/getEthereumVictionUSDTWarpConfig.js'; import { getInevmInjectiveINJWarpConfig } from './environments/mainnet3/warp/configGetters/getInevmInjectiveINJWarpConfig.js'; import { getMantapacificNeutronTiaWarpConfig } from './environments/mainnet3/warp/configGetters/getMantapacificNeutronTiaWarpConfig.js'; import { getRenzoEZETHWarpConfig } from './environments/mainnet3/warp/configGetters/getRenzoEZETHWarpConfig.js'; +import { getRenzoPZETHWarpConfig } from './environments/mainnet3/warp/configGetters/getRenzoPZETHWarpConfig.js'; import { WarpRouteIds } from './environments/mainnet3/warp/warpIds.js'; type WarpConfigGetterWithConfig = ( @@ -38,15 +38,15 @@ export const warpConfigGetterMap: Record< [WarpRouteIds.EthereumInevmUSDT]: getEthereumInevmUSDTWarpConfig, [WarpRouteIds.ArbitrumNeutronEclip]: getArbitrumNeutronEclipWarpConfig, [WarpRouteIds.ArbitrumNeutronTIA]: getArbitrumNeutronTiaWarpConfig, - [WarpRouteIds.ArbitrumBaseBlastBscEthereumFraxtalLineaModeOptimismZircuitEZETH]: + [WarpRouteIds.ArbitrumBaseBlastBscEthereumFraxtalLineaModeOptimismSeiTaikoZircuitEZETH]: getRenzoEZETHWarpConfig, [WarpRouteIds.InevmInjectiveINJ]: getInevmInjectiveINJWarpConfig, + [WarpRouteIds.EthereumSeiFastUSD]: getEthereumSeiFastUSDWarpConfig, [WarpRouteIds.EthereumVictionETH]: getEthereumVictionETHWarpConfig, [WarpRouteIds.EthereumVictionUSDC]: getEthereumVictionUSDCWarpConfig, [WarpRouteIds.EthereumVictionUSDT]: getEthereumVictionUSDTWarpConfig, + [WarpRouteIds.EthereumZircuitPZETH]: getRenzoPZETHWarpConfig, [WarpRouteIds.MantapacificNeutronTIA]: getMantapacificNeutronTiaWarpConfig, - [WarpRouteIds.EthereumEclipseTETH]: getEthereumEclipseTETHWarpConfig, - [WarpRouteIds.EthereumEclipseUSDC]: getEthereumEclipseUSDCWarpConfig, }; export async function getWarpConfig( @@ -59,7 +59,11 @@ export async function getWarpConfig( const warpConfigGetter = warpConfigGetterMap[warpRouteId]; if (!warpConfigGetter) { - throw new Error(`Unknown warp route: ${warpRouteId}`); + throw new Error( + `Unknown warp route: ${warpRouteId}, must be one of: ${Object.keys( + warpConfigGetterMap, + ).join(', ')}`, + ); } if (warpConfigGetter.length === 1) { diff --git a/typescript/infra/scripts/check/check-warp-deploy.ts b/typescript/infra/scripts/check/check-warp-deploy.ts index 66334b8cf..aa51c7016 100644 --- a/typescript/infra/scripts/check/check-warp-deploy.ts +++ b/typescript/infra/scripts/check/check-warp-deploy.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { Gauge, Registry } from 'prom-client'; -import { WarpRouteIds } from '../../config/environments/mainnet3/warp/warpIds.js'; +import { warpConfigGetterMap } from '../../config/warp.js'; import { submitMetrics } from '../../src/utils/metrics.js'; import { Modules } from '../agent-utils.js'; @@ -25,7 +25,7 @@ async function main() { const failedWarpRoutesChecks: string[] = []; // TODO: consider retrying this if check throws an error - for (const warpRouteId of Object.values(WarpRouteIds)) { + for (const warpRouteId of Object.keys(warpConfigGetterMap)) { console.log(`\nChecking warp route ${warpRouteId}...`); const warpModule = Modules.WARP; diff --git a/typescript/infra/scripts/get-owner-ica.ts b/typescript/infra/scripts/get-owner-ica.ts index e4ea61a7b..91c7ec9a8 100644 --- a/typescript/infra/scripts/get-owner-ica.ts +++ b/typescript/infra/scripts/get-owner-ica.ts @@ -1,11 +1,13 @@ import { AccountConfig, InterchainAccount } from '@hyperlane-xyz/sdk'; -import { Address, assert, eqAddress } from '@hyperlane-xyz/utils'; +import { Address, eqAddress } from '@hyperlane-xyz/utils'; -import { getArgs as getEnvArgs, withChainsRequired } from './agent-utils.js'; +import { isEthereumProtocolChain } from '../src/utils/utils.js'; + +import { getArgs as getEnvArgs, withChains } from './agent-utils.js'; import { getEnvironmentConfig, getHyperlaneCore } from './core-utils.js'; function getArgs() { - return withChainsRequired(getEnvArgs()) + return withChains(getEnvArgs()) .option('ownerChain', { type: 'string', description: 'Origin chain where the governing owner lives', @@ -51,20 +53,47 @@ async function main() { owner: originOwner, }; + const getOwnerIcaChains = ( + chains?.length ? chains : config.supportedChainNames + ).filter(isEthereumProtocolChain); + const results: Record = {}; - for (const chain of chains) { - const account = await ica.getAccount(chain, ownerConfig); - results[chain] = { ICA: account }; + const settledResults = await Promise.allSettled( + getOwnerIcaChains.map(async (chain) => { + try { + const account = await ica.getAccount(chain, ownerConfig); + const result: { ICA: Address; Deployed?: string } = { ICA: account }; + + if (deploy) { + const deployedAccount = await ica.deployAccount(chain, ownerConfig); + result.Deployed = eqAddress(account, deployedAccount) ? '✅' : '❌'; + if (result.Deployed === '❌') { + console.warn( + `Mismatch between account and deployed account for ${chain}`, + ); + } + } - if (deploy) { - const deployedAccount = await ica.deployAccount(chain, ownerConfig); - assert( - eqAddress(account, deployedAccount), - 'Fatal mismatch between account and deployed account', - ); - results[chain].Deployed = '✅'; + return { chain, result }; + } catch (error) { + console.error(`Error processing chain ${chain}:`, error); + return { chain, error }; + } + }), + ); + + settledResults.forEach((settledResult) => { + if (settledResult.status === 'fulfilled') { + const { chain, result, error } = settledResult.value; + if (error || !result) { + console.error(`Failed to process ${chain}:`, error); + } else { + results[chain] = result; + } + } else { + console.error(`Promise rejected:`, settledResult.reason); } - } + }); console.table(results); } diff --git a/typescript/infra/scripts/print-gas-prices.ts b/typescript/infra/scripts/print-gas-prices.ts index 805122ad1..bbe15c7b1 100644 --- a/typescript/infra/scripts/print-gas-prices.ts +++ b/typescript/infra/scripts/print-gas-prices.ts @@ -7,8 +7,10 @@ import { ProtocolType } from '@hyperlane-xyz/utils'; // Intentionally circumvent `mainnet3/index.ts` and `getEnvironmentConfig('mainnet3')` // to avoid circular dependencies. import { getRegistry as getMainnet3Registry } from '../config/environments/mainnet3/chains.js'; +import mainnet3GasPrices from '../config/environments/mainnet3/gasPrices.json' assert { type: 'json' }; import { supportedChainNames as mainnet3SupportedChainNames } from '../config/environments/mainnet3/supportedChainNames.js'; import { getRegistry as getTestnet4Registry } from '../config/environments/testnet4/chains.js'; +import testnet4GasPrices from '../config/environments/testnet4/gasPrices.json' assert { type: 'json' }; import { supportedChainNames as testnet4SupportedChainNames } from '../config/environments/testnet4/supportedChainNames.js'; import { GasPriceConfig, @@ -19,15 +21,17 @@ import { getArgs } from './agent-utils.js'; async function main() { const { environment } = await getArgs().argv; - const { registry, supportedChainNames } = + const { registry, supportedChainNames, gasPrices } = environment === 'mainnet3' ? { registry: await getMainnet3Registry(), supportedChainNames: mainnet3SupportedChainNames, + gasPrices: mainnet3GasPrices, } : { registry: await getTestnet4Registry(), supportedChainNames: testnet4SupportedChainNames, + gasPrices: testnet4GasPrices, }; const chainMetadata = await registry.getMetadata(); @@ -37,7 +41,11 @@ async function main() { await Promise.all( supportedChainNames.map(async (chain) => [ chain, - await getGasPrice(mpp, chain), + await getGasPrice( + mpp, + chain, + gasPrices[chain as keyof typeof gasPrices], + ), ]), ), ); @@ -48,6 +56,7 @@ async function main() { async function getGasPrice( mpp: MultiProtocolProvider, chain: string, + currentGasPrice?: GasPriceConfig, ): Promise { const protocolType = mpp.getProtocol(chain); switch (protocolType) { @@ -68,11 +77,14 @@ async function getGasPrice( }; } case ProtocolType.Sealevel: + // Return the gas price from the config if it exists, otherwise return some default // TODO get a reasonable value - return { - amount: '0.001', - decimals: 9, - }; + return ( + currentGasPrice ?? { + amount: 'PLEASE SET A GAS PRICE FOR SEALEVEL', + decimals: 1, + } + ); default: throw new Error(`Unsupported protocol type: ${protocolType}`); } diff --git a/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts b/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts index 160a777f5..3d946a9e6 100644 --- a/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts +++ b/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts @@ -55,10 +55,11 @@ const xERC20LimitsGauge = new Gauge({ name: 'hyperlane_xerc20_limits', help: 'Current minting and burning limits of xERC20 tokens', registers: [metricsRegister], - labelNames: ['chain_name', 'limit_type'], + labelNames: ['chain_name', 'limit_type', 'token_name'], }); interface xERC20Limit { + tokenName: string; mint: number; burn: number; mintMax: number; @@ -102,18 +103,7 @@ async function main(): Promise { const registry = await envConfig.getRegistry(); const chainMetadata = await registry.getMetadata(); - // TODO: eventually support token balance checks for xERC20 token type also - if ( - Object.values(tokenConfig).some( - (token) => - token.type === TokenType.XERC20 || - token.type === TokenType.XERC20Lockbox, - ) - ) { - await checkXERC20Limits(checkFrequency, tokenConfig, chainMetadata); - } else { - await checkTokenBalances(checkFrequency, tokenConfig, chainMetadata); - } + await checkWarpRouteMetrics(checkFrequency, tokenConfig, chainMetadata); return true; } @@ -136,7 +126,7 @@ async function checkBalance( ethers.utils.formatUnits(nativeBalance, token.decimals), ); } - case ProtocolType.Sealevel: + case ProtocolType.Sealevel: { const adapter = new SealevelHypNativeAdapter( chain, multiProtocolProvider, @@ -155,6 +145,7 @@ async function checkBalance( return parseFloat( ethers.utils.formatUnits(balance, token.decimals), ); + } case ProtocolType.Cosmos: { if (!token.ibcDenom) throw new Error('IBC denom missing for native token'); @@ -245,7 +236,7 @@ async function checkBalance( ethers.utils.formatUnits(syntheticBalance, token.decimals), ); } - case ProtocolType.Sealevel: + case ProtocolType.Sealevel: { if (!token.tokenAddress) throw new Error('Token address missing for synthetic token'); const adapter = new SealevelHypSyntheticAdapter( @@ -265,12 +256,67 @@ async function checkBalance( return parseFloat( ethers.utils.formatUnits(syntheticBalance, token.decimals), ); + } case ProtocolType.Cosmos: // TODO - cosmos synthetic return 0; } break; } + case TokenType.XERC20: { + switch (token.protocolType) { + case ProtocolType.Ethereum: { + const provider = multiProtocolProvider.getEthersV5Provider(chain); + const hypXERC20 = HypXERC20__factory.connect( + token.hypAddress, + provider, + ); + const xerc20Address = await hypXERC20.wrappedToken(); + const xerc20 = IXERC20__factory.connect(xerc20Address, provider); + const syntheticBalance = await xerc20.totalSupply(); + + return parseFloat( + ethers.utils.formatUnits(syntheticBalance, token.decimals), + ); + } + default: + throw new Error( + `Unsupported protocol type ${token.protocolType} for token type ${token.type}`, + ); + } + } + case TokenType.XERC20Lockbox: { + switch (token.protocolType) { + case ProtocolType.Ethereum: { + if (!token.tokenAddress) + throw new Error( + 'Token address missing for xERC20Lockbox token', + ); + const provider = multiProtocolProvider.getEthersV5Provider(chain); + const hypXERC20Lockbox = HypXERC20Lockbox__factory.connect( + token.hypAddress, + provider, + ); + const xerc20LockboxAddress = await hypXERC20Lockbox.lockbox(); + const tokenContract = ERC20__factory.connect( + token.tokenAddress, + provider, + ); + + const collateralBalance = await tokenContract.balanceOf( + xerc20LockboxAddress, + ); + + return parseFloat( + ethers.utils.formatUnits(collateralBalance, token.decimals), + ); + } + default: + throw new Error( + `Unsupported protocol type ${token.protocolType} for token type ${token.type}`, + ); + } + } } return 0; }, @@ -301,46 +347,51 @@ export function updateTokenBalanceMetrics( }); } -export function updateXERC20LimitsMetrics(xERC20Limits: ChainMap) { - objMap(xERC20Limits, (chain: ChainName, limit: xERC20Limit) => { - xERC20LimitsGauge - .labels({ - chain_name: chain, - limit_type: 'mint', - }) - .set(limit.mint); - xERC20LimitsGauge - .labels({ - chain_name: chain, - limit_type: 'burn', - }) - .set(limit.burn); - xERC20LimitsGauge - .labels({ - chain_name: chain, - limit_type: 'mintMax', - }) - .set(limit.mintMax); - xERC20LimitsGauge - .labels({ - chain_name: chain, - limit_type: 'burnMax', - }) - .set(limit.burnMax); - logger.info('xERC20 limits updated for chain', { - chain, - mint: limit.mint, - burn: limit.burn, - mintMax: limit.mintMax, - burnMax: limit.burnMax, - }); +export function updateXERC20LimitsMetrics( + xERC20Limits: ChainMap, +) { + objMap(xERC20Limits, (chain: ChainName, limits: xERC20Limit | undefined) => { + if (limits) { + xERC20LimitsGauge + .labels({ + chain_name: chain, + limit_type: 'mint', + token_name: limits.tokenName, + }) + .set(limits.mint); + xERC20LimitsGauge + .labels({ + chain_name: chain, + limit_type: 'burn', + token_name: limits.tokenName, + }) + .set(limits.burn); + xERC20LimitsGauge + .labels({ + chain_name: chain, + limit_type: 'mintMax', + token_name: limits.tokenName, + }) + .set(limits.mintMax); + xERC20LimitsGauge + .labels({ + chain_name: chain, + limit_type: 'burnMax', + token_name: limits.tokenName, + }) + .set(limits.burnMax); + logger.info('xERC20 limits updated for chain', { + chain, + limits, + }); + } }); } async function getXERC20Limits( tokenConfig: WarpRouteConfig, chainMetadata: ChainMap, -): Promise> { +): Promise> { const multiProtocolProvider = new MultiProtocolProvider(chainMetadata); const output = objMap( @@ -358,7 +409,12 @@ async function getXERC20Limits( ); const xerc20Address = await lockbox.xERC20(); const xerc20 = IXERC20__factory.connect(xerc20Address, provider); - return getXERC20Limit(routerAddress, xerc20, token.decimals); + return getXERC20Limit( + routerAddress, + xerc20, + token.decimals, + token.name, + ); } case TokenType.XERC20: { const provider = multiProtocolProvider.getEthersV5Provider(chain); @@ -369,10 +425,19 @@ async function getXERC20Limits( ); const xerc20Address = await hypXERC20.wrappedToken(); const xerc20 = IXERC20__factory.connect(xerc20Address, provider); - return getXERC20Limit(routerAddress, xerc20, token.decimals); + return getXERC20Limit( + routerAddress, + xerc20, + token.decimals, + token.name, + ); } default: - throw new Error(`Unsupported token type ${token.type}`); + logger.info( + `Unsupported token type ${token.type} for xERC20 limits check on protocol type ${token.protocolType}`, + ); + + return undefined; } } default: @@ -388,12 +453,14 @@ const getXERC20Limit = async ( routerAddress: string, xerc20: IXERC20, decimals: number, + tokenName: string, ): Promise => { const mintCurrent = await xerc20.mintingCurrentLimitOf(routerAddress); const mintMax = await xerc20.mintingMaxLimitOf(routerAddress); const burnCurrent = await xerc20.burningCurrentLimitOf(routerAddress); const burnMax = await xerc20.burningMaxLimitOf(routerAddress); return { + tokenName, mint: parseFloat(ethers.utils.formatUnits(mintCurrent, decimals)), mintMax: parseFloat(ethers.utils.formatUnits(mintMax, decimals)), burn: parseFloat(ethers.utils.formatUnits(burnCurrent, decimals)), @@ -401,37 +468,27 @@ const getXERC20Limit = async ( }; }; -async function checkXERC20Limits( +async function checkWarpRouteMetrics( checkFrequency: number, tokenConfig: WarpRouteConfig, chainMetadata: ChainMap, ) { setInterval(async () => { try { - const xERC20Limits = await getXERC20Limits(tokenConfig, chainMetadata); - logger.info('xERC20 Limits:', xERC20Limits); - updateXERC20LimitsMetrics(xERC20Limits); + const multiProtocolProvider = new MultiProtocolProvider(chainMetadata); + const balances = await checkBalance(tokenConfig, multiProtocolProvider); + logger.info('Token Balances:', balances); + updateTokenBalanceMetrics(tokenConfig, balances); } catch (e) { logger.error('Error checking balances', e); } - }, checkFrequency); -} - -async function checkTokenBalances( - checkFrequency: number, - tokenConfig: WarpRouteConfig, - chainMetadata: ChainMap, -) { - logger.info('Starting Warp Route balance monitor'); - const multiProtocolProvider = new MultiProtocolProvider(chainMetadata); - setInterval(async () => { try { - logger.debug('Checking balances'); - const balances = await checkBalance(tokenConfig, multiProtocolProvider); - updateTokenBalanceMetrics(tokenConfig, balances); + const xERC20Limits = await getXERC20Limits(tokenConfig, chainMetadata); + logger.info('xERC20 Limits:', xERC20Limits); + updateXERC20LimitsMetrics(xERC20Limits); } catch (e) { - logger.error('Error checking balances', e); + logger.error('Error checking xERC20 limits', e); } }, checkFrequency); } diff --git a/typescript/infra/src/config/agent/relayer.ts b/typescript/infra/src/config/agent/relayer.ts index aebcac245..cd7e4a46d 100644 --- a/typescript/infra/src/config/agent/relayer.ts +++ b/typescript/infra/src/config/agent/relayer.ts @@ -169,16 +169,26 @@ export class RelayerConfigHelper extends AgentConfigHelper { }), ); - return allSanctionedAddresses.flat().filter((address) => { - if (!isValidAddressEvm(address)) { - this.logger.debug( - { address }, - 'Invalid sanctioned address, throwing out', - ); - return false; - } - return true; - }); + const sanctionedEthereumAdresses = allSanctionedAddresses + .flat() + .filter((address) => { + if (!isValidAddressEvm(address)) { + this.logger.debug( + { address }, + 'Invalid sanctioned address, throwing out', + ); + return false; + } + return true; + }); + + const radiantExploiter = [ + '0xA0e768A68ba1BFffb9F4366dfC8D9195EE7217d1', + '0x0629b1048298AE9deff0F4100A31967Fb3f98962', + '0x97a05beCc2e7891D07F382457Cd5d57FD242e4e8', + ]; + + return [...sanctionedEthereumAdresses, ...radiantExploiter]; } // Returns whether the relayer requires AWS credentials diff --git a/typescript/infra/src/config/warp.ts b/typescript/infra/src/config/warp.ts index cf9d125d8..25bd40818 100644 --- a/typescript/infra/src/config/warp.ts +++ b/typescript/infra/src/config/warp.ts @@ -6,5 +6,9 @@ export const tokens: ChainMap> = { ethereum: { USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', USDT: '0xdac17f958d2ee523a2206206994597c13d831ec7', + deUSD: '0x15700B564Ca08D9439C58cA5053166E8317aa138', + }, + sei: { + fastUSD: '0x37a4dD9CED2b19Cfe8FAC251cd727b5787E45269', }, }; diff --git a/typescript/infra/src/govern/HyperlaneAppGovernor.ts b/typescript/infra/src/govern/HyperlaneAppGovernor.ts index 6df9d3551..d35a1499f 100644 --- a/typescript/infra/src/govern/HyperlaneAppGovernor.ts +++ b/typescript/infra/src/govern/HyperlaneAppGovernor.ts @@ -26,6 +26,8 @@ import { retryAsync, } from '@hyperlane-xyz/utils'; +import { getSafeAndService, updateSafeOwner } from '../utils/safe.js'; + import { ManualMultiSend, MultiSend, @@ -159,7 +161,22 @@ export abstract class HyperlaneAppGovernor< submissionType: SubmissionType, multiSend: MultiSend, ) => { - const callsForSubmissionType = filterCalls(submissionType) || []; + const callsForSubmissionType = []; + const filteredCalls = filterCalls(submissionType); + + // If calls are being submitted via a safe, we need to check for any safe owner changes first + if (submissionType === SubmissionType.SAFE) { + const { safeSdk } = await getSafeAndService( + chain, + this.checker.multiProvider, + (multiSend as SafeMultiSend).safeAddress, + ); + const updateOwnerCalls = await updateSafeOwner(safeSdk); + callsForSubmissionType.push(...updateOwnerCalls, ...filteredCalls); + } else { + callsForSubmissionType.push(...filteredCalls); + } + if (callsForSubmissionType.length > 0) { this.printSeparator(); const confirmed = await summarizeCalls( @@ -257,7 +274,6 @@ export abstract class HyperlaneAppGovernor< protected async inferCallSubmissionTypes() { const newCalls: ChainMap = {}; - const pushNewCall = (inferredCall: InferredCall) => { newCalls[inferredCall.chain] = newCalls[inferredCall.chain] || []; newCalls[inferredCall.chain].push({ @@ -267,20 +283,29 @@ export abstract class HyperlaneAppGovernor< }); }; - for (const chain of Object.keys(this.calls)) { - try { - for (const call of this.calls[chain]) { - const inferredCall = await this.inferCallSubmissionType(chain, call); - pushNewCall(inferredCall); + const results: ChainMap = {}; + await Promise.all( + Object.keys(this.calls).map(async (chain) => { + try { + results[chain] = await Promise.all( + this.calls[chain].map((call) => + this.inferCallSubmissionType(chain, call), + ), + ); + } catch (error) { + console.error( + chalk.red( + `Error inferring call submission types for chain ${chain}: ${error}`, + ), + ); + results[chain] = []; } - } catch (error) { - console.error( - chalk.red( - `Error inferring call submission types for chain ${chain}: ${error}`, - ), - ); - } - } + }), + ); + + Object.entries(results).forEach(([_, inferredCalls]) => { + inferredCalls.forEach(pushNewCall); + }); this.calls = newCalls; } diff --git a/typescript/infra/src/govern/HyperlaneCoreGovernor.ts b/typescript/infra/src/govern/HyperlaneCoreGovernor.ts index b6df69f4f..f4b9c7455 100644 --- a/typescript/infra/src/govern/HyperlaneCoreGovernor.ts +++ b/typescript/infra/src/govern/HyperlaneCoreGovernor.ts @@ -84,7 +84,11 @@ export class HyperlaneCoreGovernor extends HyperlaneAppGovernor< return this.handleProxyAdminViolation(violation as ProxyAdminViolation); } default: - throw new Error(`Unsupported violation type ${violation.type}`); + throw new Error( + `Unsupported violation type ${violation.type}: ${JSON.stringify( + violation, + )}`, + ); } } } diff --git a/typescript/infra/src/govern/HyperlaneIgpGovernor.ts b/typescript/infra/src/govern/HyperlaneIgpGovernor.ts index 592301d9b..30528d230 100644 --- a/typescript/infra/src/govern/HyperlaneIgpGovernor.ts +++ b/typescript/infra/src/govern/HyperlaneIgpGovernor.ts @@ -29,7 +29,11 @@ export class HyperlaneIgpGovernor extends HyperlaneAppGovernor< return super.handleOwnerViolation(violation as OwnerViolation); } default: - throw new Error(`Unsupported violation type ${violation.type}`); + throw new Error( + `Unsupported violation type ${violation.type}: ${JSON.stringify( + violation, + )}`, + ); } } diff --git a/typescript/infra/src/utils/safe.ts b/typescript/infra/src/utils/safe.ts index ad2104ddb..fc03e9366 100644 --- a/typescript/infra/src/utils/safe.ts +++ b/typescript/infra/src/utils/safe.ts @@ -6,7 +6,7 @@ import { SafeTransaction, } from '@safe-global/safe-core-sdk-types'; import chalk from 'chalk'; -import { ethers } from 'ethers'; +import { BigNumber, ethers } from 'ethers'; import { ChainNameOrId, @@ -14,7 +14,10 @@ import { getSafe, getSafeService, } from '@hyperlane-xyz/sdk'; -import { Address, CallData } from '@hyperlane-xyz/utils'; +import { Address, CallData, eqAddress } from '@hyperlane-xyz/utils'; + +import safeSigners from '../../config/environments/mainnet3/safe/safeSigners.json' assert { type: 'json' }; +import { AnnotatedCallData } from '../govern/HyperlaneAppGovernor.js'; export async function getSafeAndService( chain: ChainNameOrId, @@ -222,3 +225,50 @@ export async function deleteSafeTx( ); } } + +export async function updateSafeOwner( + safeSdk: Safe.default, +): Promise { + const threshold = await safeSdk.getThreshold(); + const owners = await safeSdk.getOwners(); + const newOwners = safeSigners.signers; + const ownersToRemove = owners.filter( + (owner) => !newOwners.some((newOwner) => eqAddress(owner, newOwner)), + ); + const ownersToAdd = newOwners.filter( + (newOwner) => !owners.some((owner) => eqAddress(newOwner, owner)), + ); + + console.log(chalk.magentaBright('Owners to remove:', ownersToRemove)); + console.log(chalk.magentaBright('Owners to add:', ownersToAdd)); + + const transactions: AnnotatedCallData[] = []; + + for (const ownerToRemove of ownersToRemove) { + const { data: removeTxData } = await safeSdk.createRemoveOwnerTx({ + ownerAddress: ownerToRemove, + threshold, + }); + transactions.push({ + to: removeTxData.to, + data: removeTxData.data, + value: BigNumber.from(removeTxData.value), + description: `Remove safe owner ${ownerToRemove}`, + }); + } + + for (const ownerToAdd of ownersToAdd) { + const { data: addTxData } = await safeSdk.createAddOwnerTx({ + ownerAddress: ownerToAdd, + threshold, + }); + transactions.push({ + to: addTxData.to, + data: addTxData.data, + value: BigNumber.from(addTxData.value), + description: `Add safe owner ${ownerToAdd}`, + }); + } + + return transactions; +} diff --git a/typescript/sdk/src/consts/multisigIsm.ts b/typescript/sdk/src/consts/multisigIsm.ts index 0a8806bde..5184a4b9e 100644 --- a/typescript/sdk/src/consts/multisigIsm.ts +++ b/typescript/sdk/src/consts/multisigIsm.ts @@ -4,8 +4,12 @@ import { ChainMap } from '../types.js'; // TODO: consider migrating these to the registry too export const defaultMultisigConfigs: ChainMap = { alephzeroevm: { - threshold: 1, - validators: ['0xcae8fab142adc4e434bb7409e40dd932cc3851aa'], + threshold: 2, + validators: [ + '0xcae8fab142adc4e434bb7409e40dd932cc3851aa', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, alfajores: { @@ -175,8 +179,12 @@ export const defaultMultisigConfigs: ChainMap = { }, chiliz: { - threshold: 1, - validators: ['0x82d024f453b1a3f3f6606226f06b038da27596f3'], + threshold: 2, + validators: [ + '0x82d024f453b1a3f3f6606226f06b038da27596f3', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, citreatestnet: { @@ -286,8 +294,12 @@ export const defaultMultisigConfigs: ChainMap = { }, flow: { - threshold: 1, - validators: ['0x3aee1090318e9c54d1d23194dcd0f2bee00ddc97'], + threshold: 2, + validators: [ + '0x3aee1090318e9c54d1d23194dcd0f2bee00ddc97', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, formtestnet: { @@ -343,8 +355,12 @@ export const defaultMultisigConfigs: ChainMap = { }, immutablezkevm: { - threshold: 1, - validators: ['0xa787c2952a4d22f776ee6e87e828e6f75de24330'], + threshold: 2, + validators: [ + '0xa787c2952a4d22f776ee6e87e828e6f75de24330', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, inevm: { @@ -402,8 +418,12 @@ export const defaultMultisigConfigs: ChainMap = { }, lumia: { - threshold: 1, - validators: ['0x9e283254ed2cd2c80f007348c2822fc8e5c2fa5f'], + threshold: 2, + validators: [ + '0x9e283254ed2cd2c80f007348c2822fc8e5c2fa5f', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, mantapacific: { @@ -438,8 +458,12 @@ export const defaultMultisigConfigs: ChainMap = { }, metall2: { - threshold: 1, - validators: ['0x1b000e1e1f0a032ed382c6d69a2d58f6fe773c09'], + threshold: 2, + validators: [ + '0x1b000e1e1f0a032ed382c6d69a2d58f6fe773c09', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, metis: { @@ -567,8 +591,12 @@ export const defaultMultisigConfigs: ChainMap = { }, polynomial: { - threshold: 1, - validators: ['0xa63ad0891e921ad5947d57e05831fabb9816eca7'], + threshold: 2, + validators: [ + '0xa63ad0891e921ad5947d57e05831fabb9816eca7', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, proofofplay: { @@ -581,8 +609,12 @@ export const defaultMultisigConfigs: ChainMap = { }, rari: { - threshold: 1, - validators: ['0x989d6862e09de21337078efbd86843a3eb1133e3'], + threshold: 2, + validators: [ + '0x989d6862e09de21337078efbd86843a3eb1133e3', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, real: { @@ -604,8 +636,12 @@ export const defaultMultisigConfigs: ChainMap = { }, rootstock: { - threshold: 1, - validators: ['0xcb8e3a72cf427feff27416d0e2ec375a052eaaee'], + threshold: 2, + validators: [ + '0xcb8e3a72cf427feff27416d0e2ec375a052eaaee', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, sanko: { @@ -721,8 +757,12 @@ export const defaultMultisigConfigs: ChainMap = { }, superposition: { - threshold: 1, - validators: ['0x5978d0e6afa9270ddb87cff43a8fa7a763a5dfc4'], + threshold: 2, + validators: [ + '0x5978d0e6afa9270ddb87cff43a8fa7a763a5dfc4', + '0xCF0211faFBb91FD9D06D7E306B30032DC3A1934f', // merkly + '0x4f977a59fdc2d9e39f6d780a84d5b4add1495a36', // mitosis + ], }, superpositiontestnet: { diff --git a/typescript/sdk/src/core/HyperlaneCoreChecker.ts b/typescript/sdk/src/core/HyperlaneCoreChecker.ts index cb7bb9046..82a26c405 100644 --- a/typescript/sdk/src/core/HyperlaneCoreChecker.ts +++ b/typescript/sdk/src/core/HyperlaneCoreChecker.ts @@ -1,10 +1,12 @@ import { ethers, utils as ethersUtils } from 'ethers'; +import { Ownable__factory } from '@hyperlane-xyz/core'; import { assert, eqAddress, rootLogger } from '@hyperlane-xyz/utils'; import { BytecodeHash } from '../consts/bytecode.js'; import { HyperlaneAppChecker } from '../deploy/HyperlaneAppChecker.js'; import { proxyImplementation } from '../deploy/proxy.js'; +import { OwnerViolation, ViolationType } from '../deploy/types.js'; import { DerivedIsmConfig, EvmIsmReader } from '../ism/EvmIsmReader.js'; import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; import { collectValidators, moduleMatchesConfig } from '../ism/utils.js'; @@ -66,6 +68,31 @@ export class HyperlaneCoreChecker extends HyperlaneAppChecker< return this.checkOwnership(chain, config.owner, config.ownerOverrides); } + async checkHook( + chain: ChainName, + hookName: string, + hookAddress: string, + expectedHookOwner: string, + ): Promise { + const hook = Ownable__factory.connect( + hookAddress, + this.multiProvider.getProvider(chain), + ); + const hookOwner = await hook.owner(); + + if (!eqAddress(hookOwner, expectedHookOwner)) { + const violation: OwnerViolation = { + type: ViolationType.Owner, + chain, + name: hookName, + actual: hookOwner, + expected: expectedHookOwner, + contract: hook, + }; + this.addViolation(violation); + } + } + async checkMailbox(chain: ChainName): Promise { const contracts = this.app.getContracts(chain); const mailbox = contracts.mailbox; @@ -77,9 +104,27 @@ export class HyperlaneCoreChecker extends HyperlaneAppChecker< )} for ${chain}`, ); - const actualIsmAddress = await mailbox.defaultIsm(); - const config = this.configMap[chain]; + const expectedHookOwner = this.getOwner( + config.owner, + 'fallbackRoutingHook', + config.ownerOverrides, + ); + + await this.checkHook( + chain, + 'defaultHook', + await mailbox.defaultHook(), + expectedHookOwner, + ); + await this.checkHook( + chain, + 'requiredHook', + await mailbox.requiredHook(), + expectedHookOwner, + ); + + const actualIsmAddress = await mailbox.defaultIsm(); const matches = await moduleMatchesConfig( chain, actualIsmAddress, diff --git a/typescript/sdk/src/deploy/HyperlaneAppChecker.ts b/typescript/sdk/src/deploy/HyperlaneAppChecker.ts index 871019a17..55f288f08 100644 --- a/typescript/sdk/src/deploy/HyperlaneAppChecker.ts +++ b/typescript/sdk/src/deploy/HyperlaneAppChecker.ts @@ -12,6 +12,7 @@ import { eqAddress, objMap, promiseObjAll, + rootLogger, } from '@hyperlane-xyz/utils'; import { HyperlaneApp } from '../app/HyperlaneApp.js'; @@ -82,6 +83,10 @@ export abstract class HyperlaneAppChecker< } addViolation(violation: CheckerViolation): void { + if (violation.type === ViolationType.BytecodeMismatch) { + rootLogger.warn({ violation }, `Found bytecode mismatch. Ignoring...`); + return; + } this.violations.push(violation); } @@ -139,7 +144,7 @@ export abstract class HyperlaneAppChecker< type: ViolationType.Owner, actual: actualProxyAdminOwner, expected: expectedOwner, - contract, + contract: actualProxyAdminContract, }; this.addViolation(violation); } @@ -208,7 +213,7 @@ export abstract class HyperlaneAppChecker< return bytecode.substring(0, bytecode.length - 90); } - private getOwner( + protected getOwner( owner: Address, contractName: string, ownableOverrides?: Record, diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index aa60bbb5f..4f74cab76 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -488,7 +488,7 @@ export { setFork, stopImpersonatingAccount, } from './utils/fork.js'; -export { multisigIsmVerificationCost } from './utils/ism.js'; +export { multisigIsmVerificationCost, normalizeConfig } from './utils/ism.js'; export { MultiGeneric } from './utils/MultiGeneric.js'; export { SealevelAccountDataWrapper, @@ -517,9 +517,11 @@ export { NativeConfig, TokenRouterConfigSchema, WarpRouteDeployConfigSchema, + WarpRouteDeployConfigSchemaErrors, isCollateralConfig, isNativeConfig, isSyntheticConfig, + isSyntheticRebaseConfig, isTokenMetadata, } from './token/schemas.js'; export { isCompliant } from './utils/schemas.js'; diff --git a/typescript/sdk/src/middleware/account/InterchainAccount.ts b/typescript/sdk/src/middleware/account/InterchainAccount.ts index 273efb587..d705fedf3 100644 --- a/typescript/sdk/src/middleware/account/InterchainAccount.ts +++ b/typescript/sdk/src/middleware/account/InterchainAccount.ts @@ -120,6 +120,8 @@ export class InterchainAccount extends RouterApp { .getProvider(destinationChain) .getCode(destinationAccount)) === '0x' ) { + const txOverrides = + this.multiProvider.getTransactionOverrides(destinationChain); await this.multiProvider.handleTx( destinationChain, destinationRouter[ @@ -129,6 +131,7 @@ export class InterchainAccount extends RouterApp { config.owner, originRouterAddress, destinationIsmAddress, + txOverrides, ), ); this.logger.debug(`Interchain account deployed at ${destinationAccount}`); diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts index 6db166c5b..467a150ad 100644 --- a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts @@ -111,7 +111,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { }); it('should create with a collateral config', async () => { - const config = { + const config: TokenRouterConfig = { ...baseConfig, type: TokenType.collateral, token: token.address, @@ -139,7 +139,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { TOKEN_NAME, TOKEN_NAME, ); - const config = { + const config: TokenRouterConfig = { type: TokenType.collateralVault, token: vault.address, hook: hookAddress, @@ -172,9 +172,8 @@ describe('EvmERC20WarpHyperlaneModule', async () => { }); it('should create with a synthetic config', async () => { - const config = { + const config: TokenRouterConfig = { type: TokenType.synthetic, - token: token.address, hook: hookAddress, name: TOKEN_NAME, symbol: TOKEN_NAME, diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.hardhat-test.ts index f086f3b83..c6795fc6c 100644 --- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.hardhat-test.ts @@ -14,14 +14,16 @@ import { HyperlaneContractsMap, RouterConfig, TestChainName, - TokenRouterConfig, + WarpRouteDeployConfig, test3, } from '@hyperlane-xyz/sdk'; +import { assert } from '@hyperlane-xyz/utils'; 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 { DerivedIsmConfig } from '../ism/EvmIsmReader.js'; import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; import { MultiProvider } from '../providers/MultiProvider.js'; import { ChainMap } from '../types.js'; @@ -122,12 +124,12 @@ describe('ERC20WarpRouterReader', async () => { it('should derive collateral config correctly', async () => { // Create config - const config = { + const config: WarpRouteDeployConfig = { [chain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), - interchainsecurityModule: await mailbox.defaultIsm(), + interchainSecurityModule: await mailbox.defaultIsm(), ...baseConfig, }, }; @@ -150,8 +152,10 @@ describe('ERC20WarpRouterReader', async () => { config[chain].hook as string, ), ); - // Check ism. should return undefined - expect(derivedConfig.interchainSecurityModule).to.be.undefined; + // Check ism + expect( + (derivedConfig.interchainSecurityModule as DerivedIsmConfig).address, + ).to.be.equal(await mailbox.defaultIsm()); // Check if token values matches if (derivedConfig.type === TokenType.collateral) { @@ -160,13 +164,45 @@ describe('ERC20WarpRouterReader', async () => { expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); } }); + it('should derive synthetic rebase config correctly', async () => { + // Create config + const config: WarpRouteDeployConfig = { + [chain]: { + type: TokenType.syntheticRebase, + collateralChainName: TestChainName.test4, + 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].syntheticRebase.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 synthetic config correctly', async () => { // Create config - const config = { + const config: WarpRouteDeployConfig = { [chain]: { type: TokenType.synthetic, - token: token.address, hook: await mailbox.defaultHook(), name: TOKEN_NAME, symbol: TOKEN_NAME, @@ -197,13 +233,13 @@ describe('ERC20WarpRouterReader', async () => { it('should derive native config correctly', async () => { // Create config - const config = { + const config: WarpRouteDeployConfig = { [chain]: { type: TokenType.native, hook: await mailbox.defaultHook(), ...baseConfig, }, - } as ChainMap; + }; // Deploy with config const warpRoute = await deployer.deploy(config); @@ -221,9 +257,61 @@ describe('ERC20WarpRouterReader', async () => { expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); }); + it('should derive collateral vault config correctly', async () => { + // Create config + const config: WarpRouteDeployConfig = { + [chain]: { + type: TokenType.collateralVault, + token: vault.address, + ...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].collateralVault.address, + ); + + assert( + derivedConfig.type === TokenType.collateralVault, + 'Must be collateralVault', + ); + expect(derivedConfig.type).to.equal(config[chain].type); + expect(derivedConfig.mailbox).to.equal(config[chain].mailbox); + expect(derivedConfig.owner).to.equal(config[chain].owner); + expect(derivedConfig.token).to.equal(token.address); + }); + + it('should derive rebase collateral vault config correctly', async () => { + // Create config + const config: WarpRouteDeployConfig = { + [chain]: { + type: TokenType.collateralVaultRebase, + token: vault.address, + ...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].collateralVaultRebase.address, + ); + + assert( + derivedConfig.type === TokenType.collateralVaultRebase, + 'Must be collateralVaultRebase', + ); + expect(derivedConfig.type).to.equal(config[chain].type); + expect(derivedConfig.mailbox).to.equal(config[chain].mailbox); + expect(derivedConfig.owner).to.equal(config[chain].owner); + expect(derivedConfig.token).to.equal(token.address); + }); + it('should return undefined if ism is not set onchain', async () => { // Create config - const config = { + const config: WarpRouteDeployConfig = { [chain]: { type: TokenType.collateral, token: token.address, @@ -246,7 +334,7 @@ describe('ERC20WarpRouterReader', async () => { // Create config const otherChain = TestChainName.test3; const otherChainMetadata = test3; - const config = { + const config: WarpRouteDeployConfig = { [chain]: { type: TokenType.collateral, token: token.address, diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts index 2eb3838d5..8ffbe82cc 100644 --- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts @@ -4,6 +4,8 @@ import { HypERC20Collateral__factory, HypERC20__factory, HypERC4626Collateral__factory, + HypERC4626OwnerCollateral__factory, + HypERC4626__factory, TokenRouter__factory, } from '@hyperlane-xyz/core'; import { @@ -81,15 +83,23 @@ export class EvmERC20WarpRouteReader extends HyperlaneReader { const contractTypes: Partial< Record > = { - collateralVault: { + [TokenType.collateralVaultRebase]: { factory: HypERC4626Collateral__factory, + method: 'NULL_RECIPIENT', + }, + [TokenType.collateralVault]: { + factory: HypERC4626OwnerCollateral__factory, method: 'vault', }, - collateral: { + [TokenType.collateral]: { factory: HypERC20Collateral__factory, method: 'wrappedToken', }, - synthetic: { + [TokenType.syntheticRebase]: { + factory: HypERC4626__factory, + method: 'collateralDomain', + }, + [TokenType.synthetic]: { factory: HypERC20__factory, method: 'decimals', }, @@ -106,11 +116,11 @@ export class EvmERC20WarpRouteReader extends HyperlaneReader { 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 { + this.setSmartProviderLogLevel(getLogLevel()); // returns to original level defined by rootLogger } } @@ -186,7 +196,10 @@ export class EvmERC20WarpRouteReader extends HyperlaneReader { await this.fetchERC20Metadata(token); return { name, symbol, decimals, totalSupply, token }; - } else if (type === TokenType.synthetic) { + } else if ( + type === TokenType.synthetic || + type === TokenType.syntheticRebase + ) { return this.fetchERC20Metadata(tokenAddress); } else if (type === TokenType.native) { const chainMetadata = this.multiProvider.getChainMetadata(this.chain); diff --git a/typescript/sdk/src/token/Token.test.ts b/typescript/sdk/src/token/Token.test.ts index cd232ebf7..1a6ac978a 100644 --- a/typescript/sdk/src/token/Token.test.ts +++ b/typescript/sdk/src/token/Token.test.ts @@ -48,6 +48,15 @@ const STANDARD_TO_TOKEN: Record = { symbol: 'USDC', name: 'USDC', }, + [TokenStandard.EvmHypRebaseCollateral]: { + chainName: TestChainName.test3, + standard: TokenStandard.EvmHypRebaseCollateral, + addressOrDenom: '0x31b5234A896FbC4b3e2F7237592D054716762131', + collateralAddressOrDenom: '0x64544969ed7ebf5f083679233325356ebe738930', + decimals: 18, + symbol: 'USDC', + name: 'USDC', + }, [TokenStandard.EvmHypOwnerCollateral]: { chainName: TestChainName.test3, standard: TokenStandard.EvmHypOwnerCollateral, @@ -74,6 +83,14 @@ const STANDARD_TO_TOKEN: Record = { symbol: 'USDC', name: 'USDC', }, + [TokenStandard.EvmHypSyntheticRebase]: { + chainName: TestChainName.test2, + standard: TokenStandard.EvmHypSyntheticRebase, + addressOrDenom: '0x8358D8291e3bEDb04804975eEa0fe9fe0fAfB147', + decimals: 6, + symbol: 'USDC', + name: 'USDC', + }, [TokenStandard.EvmHypXERC20]: { chainName: TestChainName.test2, standard: TokenStandard.EvmHypXERC20, diff --git a/typescript/sdk/src/token/Token.ts b/typescript/sdk/src/token/Token.ts index e2541db6b..527adc2b5 100644 --- a/typescript/sdk/src/token/Token.ts +++ b/typescript/sdk/src/token/Token.ts @@ -189,19 +189,19 @@ export class Token implements IToken { return new EvmHypNativeAdapter(chainName, multiProvider, { token: addressOrDenom, }); - } else if (standard === TokenStandard.EvmHypCollateral) { - return new EvmHypCollateralAdapter(chainName, multiProvider, { - token: addressOrDenom, - }); - } else if (standard === TokenStandard.EvmHypCollateralFiat) { - return new EvmHypCollateralAdapter(chainName, multiProvider, { - token: addressOrDenom, - }); - } else if (standard === TokenStandard.EvmHypOwnerCollateral) { + } else if ( + standard === TokenStandard.EvmHypCollateral || + standard === TokenStandard.EvmHypCollateralFiat || + standard === TokenStandard.EvmHypOwnerCollateral || + standard === TokenStandard.EvmHypRebaseCollateral + ) { return new EvmHypCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, }); - } else if (standard === TokenStandard.EvmHypSynthetic) { + } else if ( + standard === TokenStandard.EvmHypSynthetic || + standard === TokenStandard.EvmHypSyntheticRebase + ) { return new EvmHypSyntheticAdapter(chainName, multiProvider, { token: addressOrDenom, }); diff --git a/typescript/sdk/src/token/TokenStandard.ts b/typescript/sdk/src/token/TokenStandard.ts index 002501d32..ee434b777 100644 --- a/typescript/sdk/src/token/TokenStandard.ts +++ b/typescript/sdk/src/token/TokenStandard.ts @@ -15,8 +15,10 @@ export enum TokenStandard { EvmHypNative = 'EvmHypNative', EvmHypCollateral = 'EvmHypCollateral', EvmHypOwnerCollateral = 'EvmHypOwnerCollateral', + EvmHypRebaseCollateral = 'EvmHypRebaseCollateral', EvmHypCollateralFiat = 'EvmHypCollateralFiat', EvmHypSynthetic = 'EvmHypSynthetic', + EvmHypSyntheticRebase = 'EvmHypSyntheticRebase', EvmHypXERC20 = 'EvmHypXERC20', EvmHypXERC20Lockbox = 'EvmHypXERC20Lockbox', @@ -52,8 +54,10 @@ export const TOKEN_STANDARD_TO_PROTOCOL: Record = { EvmHypNative: ProtocolType.Ethereum, EvmHypCollateral: ProtocolType.Ethereum, EvmHypOwnerCollateral: ProtocolType.Ethereum, + EvmHypRebaseCollateral: ProtocolType.Ethereum, EvmHypCollateralFiat: ProtocolType.Ethereum, EvmHypSynthetic: ProtocolType.Ethereum, + EvmHypSyntheticRebase: ProtocolType.Ethereum, EvmHypXERC20: ProtocolType.Ethereum, EvmHypXERC20Lockbox: ProtocolType.Ethereum, @@ -114,7 +118,9 @@ export const TOKEN_HYP_STANDARDS = [ TokenStandard.EvmHypCollateral, TokenStandard.EvmHypCollateralFiat, TokenStandard.EvmHypOwnerCollateral, + TokenStandard.EvmHypRebaseCollateral, TokenStandard.EvmHypSynthetic, + TokenStandard.EvmHypSyntheticRebase, TokenStandard.EvmHypXERC20, TokenStandard.EvmHypXERC20Lockbox, TokenStandard.SealevelHypNative, @@ -148,9 +154,11 @@ export const TOKEN_TYPE_TO_STANDARD: Record = { [TokenType.XERC20]: TokenStandard.EvmHypXERC20, [TokenType.XERC20Lockbox]: TokenStandard.EvmHypXERC20Lockbox, [TokenType.collateralVault]: TokenStandard.EvmHypOwnerCollateral, + [TokenType.collateralVaultRebase]: TokenStandard.EvmHypRebaseCollateral, [TokenType.collateralUri]: TokenStandard.EvmHypCollateral, [TokenType.fastCollateral]: TokenStandard.EvmHypCollateral, [TokenType.synthetic]: TokenStandard.EvmHypSynthetic, + [TokenType.syntheticRebase]: TokenStandard.EvmHypSyntheticRebase, [TokenType.syntheticUri]: TokenStandard.EvmHypSynthetic, [TokenType.fastSynthetic]: TokenStandard.EvmHypSynthetic, [TokenType.nativeScaled]: TokenStandard.EvmHypNative, diff --git a/typescript/sdk/src/token/config.ts b/typescript/sdk/src/token/config.ts index a89264ee6..08fb750f2 100644 --- a/typescript/sdk/src/token/config.ts +++ b/typescript/sdk/src/token/config.ts @@ -1,9 +1,11 @@ export enum TokenType { synthetic = 'synthetic', + syntheticRebase = 'syntheticRebase', fastSynthetic = 'fastSynthetic', syntheticUri = 'syntheticUri', collateral = 'collateral', collateralVault = 'collateralVault', + collateralVaultRebase = 'collateralVaultRebase', XERC20 = 'xERC20', XERC20Lockbox = 'xERC20Lockbox', collateralFiat = 'collateralFiat', @@ -16,6 +18,7 @@ export enum TokenType { export const CollateralExtensions = [ TokenType.collateral, TokenType.collateralVault, + TokenType.collateralVaultRebase, ]; export const gasOverhead = (tokenType: TokenType): number => { diff --git a/typescript/sdk/src/token/contracts.ts b/typescript/sdk/src/token/contracts.ts index 281c084ff..a9ced3cb1 100644 --- a/typescript/sdk/src/token/contracts.ts +++ b/typescript/sdk/src/token/contracts.ts @@ -8,6 +8,8 @@ import { HypERC721URIStorage__factory, HypERC721__factory, HypERC4626Collateral__factory, + HypERC4626OwnerCollateral__factory, + HypERC4626__factory, HypFiatToken__factory, HypNativeScaled__factory, HypNative__factory, @@ -21,11 +23,13 @@ export const hypERC20contracts = { [TokenType.fastCollateral]: 'FastHypERC20Collateral', [TokenType.fastSynthetic]: 'FastHypERC20', [TokenType.synthetic]: 'HypERC20', + [TokenType.syntheticRebase]: 'HypERC4626', [TokenType.collateral]: 'HypERC20Collateral', [TokenType.collateralFiat]: 'HypFiatToken', [TokenType.XERC20]: 'HypXERC20', [TokenType.XERC20Lockbox]: 'HypXERC20Lockbox', - [TokenType.collateralVault]: 'HypERC20CollateralVaultDeposit', + [TokenType.collateralVault]: 'HypERC4626OwnerCollateral', + [TokenType.collateralVaultRebase]: 'HypERC4626Collateral', [TokenType.native]: 'HypNative', [TokenType.nativeScaled]: 'HypNativeScaled', }; @@ -36,7 +40,9 @@ export const hypERC20factories = { [TokenType.fastSynthetic]: new FastHypERC20__factory(), [TokenType.synthetic]: new HypERC20__factory(), [TokenType.collateral]: new HypERC20Collateral__factory(), - [TokenType.collateralVault]: new HypERC4626Collateral__factory(), + [TokenType.collateralVault]: new HypERC4626OwnerCollateral__factory(), + [TokenType.collateralVaultRebase]: new HypERC4626Collateral__factory(), + [TokenType.syntheticRebase]: new HypERC4626__factory(), [TokenType.collateralFiat]: new HypFiatToken__factory(), [TokenType.XERC20]: new HypXERC20__factory(), [TokenType.XERC20Lockbox]: new HypXERC20Lockbox__factory(), diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts index 843f933f1..ee2a9a399 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -33,6 +33,7 @@ import { isCollateralConfig, isNativeConfig, isSyntheticConfig, + isSyntheticRebaseConfig, isTokenMetadata, } from './schemas.js'; import { TokenMetadata, WarpRouteDeployConfig } from './types.js'; @@ -64,6 +65,11 @@ abstract class TokenDeployer< } else if (isSyntheticConfig(config)) { assert(config.decimals, 'decimals is undefined for config'); // decimals must be defined by this point return [config.decimals, config.mailbox]; + } else if (isSyntheticRebaseConfig(config)) { + const collateralDomain = this.multiProvider.getDomainId( + config.collateralChainName, + ); + return [config.decimals, config.mailbox, collateralDomain]; } else { throw new Error('Unknown token type when constructing arguments'); } @@ -82,7 +88,7 @@ abstract class TokenDeployer< ]; if (isCollateralConfig(config) || isNativeConfig(config)) { return defaultArgs; - } else if (isSyntheticConfig(config)) { + } else if (isSyntheticConfig(config) || isSyntheticRebaseConfig(config)) { return [config.totalSupply, config.name, config.symbol, ...defaultArgs]; } else { throw new Error('Unknown collateral type when initializing arguments'); diff --git a/typescript/sdk/src/token/schemas.test.ts b/typescript/sdk/src/token/schemas.test.ts index 65f780cd0..1a8d606ea 100644 --- a/typescript/sdk/src/token/schemas.test.ts +++ b/typescript/sdk/src/token/schemas.test.ts @@ -1,8 +1,15 @@ import { expect } from 'chai'; import { ethers } from 'ethers'; +import { assert } from '@hyperlane-xyz/utils'; + import { TokenType } from './config.js'; -import { WarpRouteDeployConfigSchema } from './schemas.js'; +import { + WarpRouteDeployConfigSchema, + WarpRouteDeployConfigSchemaErrors, + isCollateralConfig, +} from './schemas.js'; +import { WarpRouteDeployConfig } from './types.js'; const SOME_ADDRESS = ethers.Wallet.createRandom().address; const COLLATERAL_TYPES = [ @@ -19,7 +26,7 @@ const NON_COLLATERAL_TYPES = [ ]; describe('WarpRouteDeployConfigSchema refine', () => { - let config: any; + let config: WarpRouteDeployConfig; beforeEach(() => { config = { arbitrum: { @@ -33,18 +40,24 @@ describe('WarpRouteDeployConfigSchema refine', () => { it('should require token type', () => { expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.true; + + //@ts-ignore delete config.arbitrum.type; expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.false; }); it('should require token address', () => { expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.true; + + //@ts-ignore delete config.arbitrum.token; expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.false; }); it('should require mailbox address', () => { expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.true; + + //@ts-ignore delete config.arbitrum.mailbox; expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.false; }); @@ -52,6 +65,9 @@ describe('WarpRouteDeployConfigSchema refine', () => { it('should throw if collateral type and token is empty', async () => { for (const type of COLLATERAL_TYPES) { config.arbitrum.type = type; + assert(isCollateralConfig(config.arbitrum), 'must be collateral'); + + //@ts-ignore config.arbitrum.token = undefined; expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.false; @@ -61,13 +77,8 @@ describe('WarpRouteDeployConfigSchema refine', () => { } }); - it('should accept native type if token is empty', async () => { - config.arbitrum.type = TokenType.native; - config.arbitrum.token = undefined; - expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.true; - }); - it('should succeed if non-collateral type, token is empty, metadata is defined', async () => { + //@ts-ignore delete config.arbitrum.token; config.arbitrum.totalSupply = '0'; config.arbitrum.name = 'name'; @@ -81,4 +92,93 @@ describe('WarpRouteDeployConfigSchema refine', () => { expect(WarpRouteDeployConfigSchema.safeParse(config).success).to.be.true; } }); + + it(`should throw if deploying rebasing collateral with anything other than ${TokenType.syntheticRebase}`, async () => { + config = { + arbitrum: { + type: TokenType.collateralVaultRebase, + token: SOME_ADDRESS, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + }, + ethereum: { + type: TokenType.collateralVault, + token: SOME_ADDRESS, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + }, + optimism: { + type: TokenType.syntheticRebase, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + collateralChainName: '', + }, + }; + let parseResults = WarpRouteDeployConfigSchema.safeParse(config); + assert(!parseResults.success, 'must be false'); // Needed so 'message' shows up because parseResults is a discriminate union + expect(parseResults.error.issues[0].message).to.equal( + WarpRouteDeployConfigSchemaErrors.ONLY_SYNTHETIC_REBASE, + ); + + config.ethereum.type = TokenType.syntheticRebase; + //@ts-ignore + config.ethereum.collateralChainName = ''; + parseResults = WarpRouteDeployConfigSchema.safeParse(config); + //@ts-ignore + expect(parseResults.success).to.be.true; + }); + + it(`should throw if deploying only ${TokenType.collateralVaultRebase}`, async () => { + config = { + arbitrum: { + type: TokenType.collateralVaultRebase, + token: SOME_ADDRESS, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + }, + }; + let parseResults = WarpRouteDeployConfigSchema.safeParse(config); + expect(parseResults.success).to.be.false; + + config.ethereum = { + type: TokenType.collateralVaultRebase, + token: SOME_ADDRESS, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + }; + parseResults = WarpRouteDeployConfigSchema.safeParse(config); + expect(parseResults.success).to.be.false; + }); + + it(`should derive the collateral chain name for ${TokenType.syntheticRebase}`, async () => { + config = { + arbitrum: { + type: TokenType.collateralVaultRebase, + token: SOME_ADDRESS, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + }, + ethereum: { + type: TokenType.syntheticRebase, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + collateralChainName: '', + }, + optimism: { + type: TokenType.syntheticRebase, + owner: SOME_ADDRESS, + mailbox: SOME_ADDRESS, + collateralChainName: '', + }, + }; + const parseResults = WarpRouteDeployConfigSchema.safeParse(config); + assert(parseResults.success, 'must be true'); + const warpConfig: WarpRouteDeployConfig = parseResults.data; + + assert( + warpConfig.optimism.type === TokenType.syntheticRebase, + 'must be syntheticRebase', + ); + expect(warpConfig.optimism.collateralChainName).to.equal('arbitrum'); + }); }); diff --git a/typescript/sdk/src/token/schemas.ts b/typescript/sdk/src/token/schemas.ts index 8ce070c1e..1ef4a770b 100644 --- a/typescript/sdk/src/token/schemas.ts +++ b/typescript/sdk/src/token/schemas.ts @@ -1,10 +1,16 @@ import { z } from 'zod'; +import { objMap } from '@hyperlane-xyz/utils'; + import { GasRouterConfigSchema } from '../router/schemas.js'; import { isCompliant } from '../utils/schemas.js'; import { TokenType } from './config.js'; +export const WarpRouteDeployConfigSchemaErrors = { + ONLY_SYNTHETIC_REBASE: `Config with ${TokenType.collateralVaultRebase} must be deployed with ${TokenType.syntheticRebase}`, + NO_SYNTHETIC_ONLY: `Config must include Native or Collateral OR all synthetics must define token metadata`, +}; export const TokenMetadataSchema = z.object({ name: z.string(), symbol: z.string(), @@ -18,6 +24,7 @@ export const CollateralConfigSchema = TokenMetadataSchema.partial().extend({ type: z.enum([ TokenType.collateral, TokenType.collateralVault, + TokenType.collateralVaultRebase, TokenType.XERC20, TokenType.XERC20Lockbox, TokenType.collateralFiat, @@ -33,6 +40,18 @@ export const NativeConfigSchema = TokenMetadataSchema.partial().extend({ type: z.enum([TokenType.native, TokenType.nativeScaled]), }); +export const CollateralRebaseConfigSchema = + TokenMetadataSchema.partial().extend({ + type: z.literal(TokenType.collateralVaultRebase), + }); + +export const SyntheticRebaseConfigSchema = TokenMetadataSchema.partial().extend( + { + type: z.literal(TokenType.syntheticRebase), + collateralChainName: z.string(), + }, +); + export const SyntheticConfigSchema = TokenMetadataSchema.partial().extend({ type: z.enum([ TokenType.synthetic, @@ -50,6 +69,7 @@ export const TokenConfigSchema = z.discriminatedUnion('type', [ NativeConfigSchema, CollateralConfigSchema, SyntheticConfigSchema, + SyntheticRebaseConfigSchema, ]); export const TokenRouterConfigSchema = TokenConfigSchema.and( @@ -61,6 +81,10 @@ export type NativeConfig = z.infer; export type CollateralConfig = z.infer; export const isSyntheticConfig = isCompliant(SyntheticConfigSchema); +export const isSyntheticRebaseConfig = isCompliant(SyntheticRebaseConfigSchema); +export const isCollateralRebaseConfig = isCompliant( + CollateralRebaseConfigSchema, +); export const isCollateralConfig = isCompliant(CollateralConfigSchema); export const isNativeConfig = isCompliant(NativeConfigSchema); export const isTokenMetadata = isCompliant(TokenMetadataSchema); @@ -71,7 +95,49 @@ export const WarpRouteDeployConfigSchema = z const entries = Object.entries(configMap); return ( entries.some( - ([_, config]) => isCollateralConfig(config) || isNativeConfig(config), + ([_, config]) => + isCollateralConfig(config) || + isCollateralRebaseConfig(config) || + isNativeConfig(config), ) || entries.every(([_, config]) => isTokenMetadata(config)) ); - }, `Config must include Native or Collateral OR all synthetics must define token metadata`); + }, WarpRouteDeployConfigSchemaErrors.NO_SYNTHETIC_ONLY) + .transform((warpRouteDeployConfig, ctx) => { + const collateralRebaseEntry = Object.entries(warpRouteDeployConfig).find( + ([_, config]) => isCollateralRebaseConfig(config), + ); + if (!collateralRebaseEntry) return warpRouteDeployConfig; // Pass through for other token types + + if (isCollateralRebasePairedCorrectly(warpRouteDeployConfig)) { + const collateralChainName = collateralRebaseEntry[0]; + return objMap(warpRouteDeployConfig, (_, config) => { + if (config.type === TokenType.syntheticRebase) + config.collateralChainName = collateralChainName; + return config; + }) as Record; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: WarpRouteDeployConfigSchemaErrors.ONLY_SYNTHETIC_REBASE, + }); + + return z.NEVER; // Causes schema validation to throw with above issue + }); + +function isCollateralRebasePairedCorrectly( + warpRouteDeployConfig: Record, +): boolean { + // Filter out all the non-collateral rebase configs to check if they are only synthetic rebase tokens + const otherConfigs = Object.entries(warpRouteDeployConfig).filter( + ([_, config]) => !isCollateralRebaseConfig(config), + ); + + if (otherConfigs.length === 0) return false; + + // The other configs MUST be synthetic rebase + const allOthersSynthetic: boolean = otherConfigs.every( + ([_, config], _index) => isSyntheticRebaseConfig(config), + ); + return allOthersSynthetic; +} diff --git a/typescript/utils/src/index.ts b/typescript/utils/src/index.ts index e45994f8f..0c18543dd 100644 --- a/typescript/utils/src/index.ts +++ b/typescript/utils/src/index.ts @@ -119,6 +119,8 @@ export { pick, promiseObjAll, stringifyObject, + diffObjMerge, + ObjectDiff, } from './objects.js'; export { Result, failure, success } from './result.js'; export { difference, setEquality, symmetricDifference } from './sets.js'; diff --git a/typescript/utils/src/objects.test.ts b/typescript/utils/src/objects.test.ts index b6fd1d012..d5ed72a6b 100644 --- a/typescript/utils/src/objects.test.ts +++ b/typescript/utils/src/objects.test.ts @@ -1,6 +1,12 @@ import { expect } from 'chai'; -import { deepCopy, deepEquals, objMerge, objOmit } from './objects.js'; +import { + deepCopy, + deepEquals, + diffObjMerge, + objMerge, + objOmit, +} from './objects.js'; describe('Object utilities', () => { it('deepEquals', () => { @@ -67,4 +73,132 @@ describe('Object utilities', () => { const omitted1_2 = objOmit(obj1, obj2, 10, false); expect(omitted1_2).to.eql({ a: 1, b: { d: 'string' } }); }); + + describe('diffObjMerge', () => { + it('should merge objects with equal values', () => { + const actual = { a: 1, b: 2 }; + const expected = { a: 1, b: 2 }; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: false, + mergedObject: { a: 1, b: 2 }, + }); + }); + + it('should return a diff for objects with different values', () => { + const actual = { a: 1, b: 2 }; + const expected = { a: 1, b: 3 }; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: true, + mergedObject: { + a: 1, + b: { actual: 2, expected: 3 }, + }, + }); + }); + + it('should detect missing fields in the top level object', () => { + const actual = { a: 1 }; + const expected = { a: 1, b: 3 }; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: true, + mergedObject: { + a: 1, + b: { actual: '', expected: 3 }, + }, + }); + }); + + it('should detect extra fields in the top level object', () => { + const actual = { a: 1, b: 2 }; + const expected = { a: 1 }; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: true, + mergedObject: { + a: 1, + b: { actual: 2, expected: '' }, + }, + }); + }); + + it('should merge nested objects and show differences', () => { + const actual = { a: 1, b: { c: 2, d: 4 } }; + const expected = { a: 1, b: { c: 2, d: 3 } }; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: true, + mergedObject: { + a: 1, + b: { + c: 2, + d: { actual: 4, expected: 3 }, + }, + }, + }); + }); + + it('should throw an error when maxDepth is exceeded', () => { + const actual = { a: { b: { c: { d: { e: 5 } } } } }; + const expected = { a: { b: { c: { d: { e: 5 } } } } }; + + expect(() => diffObjMerge(actual, expected, 3)).to.Throw( + 'diffObjMerge tried to go too deep', + ); + }); + + it('should merge arrays of equal length and show the diffs', () => { + const actual = [1, 2, 3]; + const expected = [1, 2, 4]; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: true, + mergedObject: [1, 2, { actual: 3, expected: 4 }], + }); + }); + + it('should return a diff for arrays of different lengths', () => { + const actual = [1, 2]; + const expected = [1, 2, 3]; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: true, + mergedObject: { + actual, + expected, + }, + }); + }); + + it('should handle null and undefined values properly', () => { + const actual = { a: null, b: 2 }; + const expected = { a: undefined, b: 2 }; + + const result = diffObjMerge(actual, expected); + + expect(result).to.eql({ + isInvalid: false, + mergedObject: { + a: undefined, + b: 2, + }, + }); + }); + }); }); diff --git a/typescript/utils/src/objects.ts b/typescript/utils/src/objects.ts index 680c5d579..403caa849 100644 --- a/typescript/utils/src/objects.ts +++ b/typescript/utils/src/objects.ts @@ -2,6 +2,7 @@ import { cloneDeep, isEqual } from 'lodash-es'; import { stringify as yamlStringify } from 'yaml'; import { ethersBigNumberSerializer } from './logging.js'; +import { isNullish } from './typeof.js'; import { assert } from './validation.js'; export function isObject(item: any) { @@ -216,3 +217,103 @@ export function stringifyObject( } return yamlStringify(JSON.parse(json), null, space); } + +interface ObjectDiffOutput { + actual: any; + expected: any; +} + +export type ObjectDiff = + | { + [key: string]: ObjectDiffOutput | ObjectDiff; + } + | ObjectDiff[] + | undefined; + +/** + * Merges 2 objects showing any difference in value for common fields. + */ +export function diffObjMerge( + actual: Record, + expected: Record, + maxDepth = 10, +): { + mergedObject: ObjectDiff; + isInvalid: boolean; +} { + if (maxDepth === 0) { + throw new Error('diffObjMerge tried to go too deep'); + } + + let isDiff = false; + if (!isObject(actual) && !isObject(expected) && actual === expected) { + return { + isInvalid: isDiff, + mergedObject: actual, + }; + } + + if (isNullish(actual) && isNullish(expected)) { + return { mergedObject: undefined, isInvalid: isDiff }; + } + + if (isObject(actual) && isObject(expected)) { + const ret: Record = {}; + + const actualKeys = new Set(Object.keys(actual)); + const expectedKeys = new Set(Object.keys(expected)); + const allKeys = new Set([...actualKeys, ...expectedKeys]); + for (const key of allKeys.values()) { + if (actualKeys.has(key) && expectedKeys.has(key)) { + const { mergedObject, isInvalid } = + diffObjMerge(actual[key], expected[key], maxDepth - 1) ?? {}; + ret[key] = mergedObject; + isDiff ||= isInvalid; + } else if (actualKeys.has(key) && !isNullish(actual[key])) { + ret[key] = { + actual: actual[key], + expected: '' as any, + }; + isDiff = true; + } else if (!isNullish(expected[key])) { + ret[key] = { + actual: '' as any, + expected: expected[key], + }; + isDiff = true; + } + } + return { + isInvalid: isDiff, + mergedObject: ret, + }; + } + + // Merge the elements of the array to see if there are any differences + if ( + Array.isArray(actual) && + Array.isArray(expected) && + actual.length === expected.length + ) { + const merged = actual.reduce( + (acc: [ObjectDiff[], boolean], curr, idx) => { + const { isInvalid, mergedObject } = diffObjMerge(curr, expected[idx]); + + acc[0].push(mergedObject); + acc[1] ||= isInvalid; + + return acc; + }, + [[], isDiff], + ); + return { + isInvalid: merged[1], + mergedObject: merged[0], + }; + } + + return { + mergedObject: { expected: expected ?? '', actual: actual ?? '' }, + isInvalid: true, + }; +} diff --git a/yarn.lock b/yarn.lock index c103e9fe5..626807df0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7875,14 +7875,17 @@ __metadata: "@hyperlane-xyz/sdk": "workspace:^" "@hyperlane-xyz/utils": "workspace:^" "@inquirer/prompts": "npm:^3.0.0" + "@types/chai-as-promised": "npm:^8" "@types/mocha": "npm:^10.0.1" "@types/node": "npm:^18.14.5" "@types/yargs": "npm:^17.0.24" "@typescript-eslint/eslint-plugin": "npm:^7.4.0" "@typescript-eslint/parser": "npm:^7.4.0" + ansi-escapes: "npm:^7.0.0" asn1.js: "npm:^5.4.1" bignumber.js: "npm:^9.1.1" - chai: "npm:4.5.0" + chai: "npm:^4.5.0" + chai-as-promised: "npm:^8.0.0" chalk: "npm:^5.3.0" eslint: "npm:^8.57.0" eslint-config-prettier: "npm:^9.1.0" @@ -13286,6 +13289,15 @@ __metadata: languageName: node linkType: hard +"@types/chai-as-promised@npm:^8": + version: 8.0.0 + resolution: "@types/chai-as-promised@npm:8.0.0" + dependencies: + "@types/chai": "npm:*" + checksum: f6db5698e4f28fd6e3914740810f356269b7f4e93a0650b38a9b01a1bae030593487c80bc57a0e69dd0bfb069a61d3dd285bfcfba6d1daf66ef3939577b68169 + languageName: node + linkType: hard + "@types/chai@npm:*, @types/chai@npm:^4.2.21": version: 4.3.1 resolution: "@types/chai@npm:4.3.1" @@ -14896,6 +14908,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^7.0.0": + version: 7.0.0 + resolution: "ansi-escapes@npm:7.0.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 2d0e2345087bd7ae6bf122b9cc05ee35560d40dcc061146edcdc02bc2d7c7c50143cd12a22e69a0b5c0f62b948b7bc9a4539ee888b80f5bd33cdfd82d01a70ab + languageName: node + linkType: hard + "ansi-regex@npm:^2.0.0": version: 2.1.1 resolution: "ansi-regex@npm:2.1.1" @@ -16454,7 +16475,18 @@ __metadata: languageName: node linkType: hard -"chai@npm:4.5.0, chai@npm:^4.3.10, chai@npm:^4.3.7": +"chai-as-promised@npm:^8.0.0": + version: 8.0.0 + resolution: "chai-as-promised@npm:8.0.0" + dependencies: + check-error: "npm:^2.0.0" + peerDependencies: + chai: ">= 2.1.2 < 6" + checksum: 91d6a49caac7965440b8f8af421ebe6f060a3b5523599ae143816d08fc19d9a971ea2bc5401f82ce88d15d8bc7b64d356bf3e53542ace9e2f25cc454164d3247 + languageName: node + linkType: hard + +"chai@npm:4.5.0, chai@npm:^4.3.10, chai@npm:^4.3.7, chai@npm:^4.5.0": version: 4.5.0 resolution: "chai@npm:4.5.0" dependencies: @@ -16574,6 +16606,13 @@ __metadata: languageName: node linkType: hard +"check-error@npm:^2.0.0": + version: 2.1.1 + resolution: "check-error@npm:2.1.1" + checksum: d785ed17b1d4a4796b6e75c765a9a290098cf52ff9728ce0756e8ffd4293d2e419dd30c67200aee34202463b474306913f2fcfaf1890641026d9fc6966fea27a + languageName: node + linkType: hard + "chokidar@npm:3.3.0": version: 3.3.0 resolution: "chokidar@npm:3.3.0" @@ -18397,6 +18436,13 @@ __metadata: languageName: node linkType: hard +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: dd3c1b9825e7f71f1e72b03c2344799ac73f2e9ef81b78ea8b373e55db021786c6b9f3858ea43a436a2c4611052670ec0afe85bc029c384cc71165feee2f4ba6 + languageName: node + linkType: hard + "erc721a@npm:^4.2.3": version: 4.2.3 resolution: "erc721a@npm:4.2.3"