Merge remote-tracking branch 'origin' into dan/gas-escalator-middleware

dan/stage-agent-fixes
Daniel Savu 4 months ago
commit c89ee3d466
No known key found for this signature in database
GPG Key ID: 795E587829AF7E08
  1. 6
      .changeset/cuddly-fans-chew.md
  2. 5
      .changeset/loud-bears-flash.md
  3. 5
      .changeset/spotty-ducks-sell.md
  4. 5
      .changeset/tame-apricots-lay.md
  5. 26
      rust/Cargo.lock
  6. 2
      rust/Cargo.toml
  7. 15
      rust/agents/relayer/src/relayer.rs
  8. 30
      rust/agents/scraper/src/agent.rs
  9. 1
      rust/chains/hyperlane-ethereum/src/rpc_clients/trait_builder.rs
  10. 52
      rust/hyperlane-base/src/contract_sync/broadcast.rs
  11. 2
      rust/hyperlane-base/src/contract_sync/cursors/mod.rs
  12. 32
      rust/hyperlane-base/src/contract_sync/mod.rs
  13. 17
      rust/utils/run-locally/src/invariants.rs
  14. 3
      typescript/ccip-server/package.json
  15. 3
      typescript/ccip-server/src/services/HyperlaneService.ts
  16. 4
      typescript/ccip-server/src/services/__mocks__/HyperlaneService.ts
  17. 60
      typescript/ccip-server/src/services/explorerTypes.ts
  18. 5
      typescript/cli/src/commands/core.ts
  19. 13
      typescript/cli/src/config/warp.ts
  20. 28
      typescript/cli/src/utils/files.ts
  21. 193
      typescript/helloworld/LICENSE.md
  22. 2
      typescript/infra/package.json
  23. 4
      typescript/sdk/package.json
  24. 128
      typescript/sdk/src/providers/SmartProvider/SmartProvider.foundry-test.ts
  25. 6
      typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts
  26. 2
      typescript/utils/package.json
  27. 6
      typescript/widgets/.eslintignore
  28. 6
      typescript/widgets/.eslintrc
  29. 5
      typescript/widgets/.gitignore
  30. 19
      typescript/widgets/.storybook/main.ts
  31. 1
      typescript/widgets/.storybook/preview-head.html
  32. 15
      typescript/widgets/.storybook/preview.ts
  33. 35
      typescript/widgets/README.md
  34. 74
      typescript/widgets/package.json
  35. 5
      typescript/widgets/postcss.config.cjs
  36. 30
      typescript/widgets/src/color.ts
  37. 1
      typescript/widgets/src/consts.ts
  38. 30
      typescript/widgets/src/icons/Airplane.tsx
  39. 73
      typescript/widgets/src/icons/ChainLogo.tsx
  40. 30
      typescript/widgets/src/icons/Circle.tsx
  41. 34
      typescript/widgets/src/icons/Envelope.tsx
  42. 29
      typescript/widgets/src/icons/Lock.tsx
  43. 29
      typescript/widgets/src/icons/QuestionMark.tsx
  44. 31
      typescript/widgets/src/icons/Shield.tsx
  45. 71
      typescript/widgets/src/icons/WideChevron.tsx
  46. 15
      typescript/widgets/src/index.ts
  47. 225
      typescript/widgets/src/messages/MessageTimeline.tsx
  48. 86
      typescript/widgets/src/messages/types.ts
  49. 78
      typescript/widgets/src/messages/useMessage.ts
  50. 248
      typescript/widgets/src/messages/useMessageStage.ts
  51. 37
      typescript/widgets/src/messages/useMessageTimeline.ts
  52. 58
      typescript/widgets/src/stories/ChainLogo.stories.tsx
  53. 70
      typescript/widgets/src/stories/MessageTimeline.stories.tsx
  54. 32
      typescript/widgets/src/stories/WideChevron.stories.tsx
  55. 3
      typescript/widgets/src/styles.css
  56. 6
      typescript/widgets/src/types.d.ts
  57. 84
      typescript/widgets/src/utils/explorers.ts
  58. 14
      typescript/widgets/src/utils/timeout.ts
  59. 27
      typescript/widgets/src/utils/useInterval.ts
  60. 108
      typescript/widgets/tailwind.config.cjs
  61. 13
      typescript/widgets/tsconfig.json
  62. 10525
      yarn.lock

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/utils': major
'@hyperlane-xyz/sdk': major
---
Upgrade CosmJS libs to 0.32.4

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/widgets': minor
---
Migrate hyperlane widgets lib to monorepo

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/cli': minor
---
Update hyperlane core read to log the config terminal "preview", only if the number of lines is < 250

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/cli': patch
---
Update CLI verbiage to ask for vault and not token when initiating collateralVault warp route.

26
rust/Cargo.lock generated

@ -3951,9 +3951,9 @@ dependencies = [
[[package]]
name = "hermit-abi"
version = "0.3.3"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hex"
@ -5077,7 +5077,7 @@ version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bad00257d07be169d870ab665980b06cdb366d792ad690bf2e76876dc503455"
dependencies = [
"hermit-abi 0.3.3",
"hermit-abi 0.3.9",
"rustix",
"windows-sys 0.52.0",
]
@ -5525,13 +5525,14 @@ checksum = "9bec4598fddb13cc7b528819e697852653252b760f1228b7642679bf2ff2cd07"
[[package]]
name = "mio"
version = "0.8.10"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4"
dependencies = [
"hermit-abi 0.3.9",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
"windows-sys 0.52.0",
]
[[package]]
@ -5845,7 +5846,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi 0.3.3",
"hermit-abi 0.3.9",
"libc",
]
@ -10079,22 +10080,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.35.1"
version = "1.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
checksum = "d040ac2b29ab03b09d4129c2f5bbd012a3ac2f79d38ff506a4bf8dd34b0eac8a"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"num_cpus",
"parking_lot 0.12.1",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.5",
"tokio-macros",
"tracing",
"windows-sys 0.48.0",
"windows-sys 0.52.0",
]
[[package]]
@ -10109,9 +10109,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.2.0"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [
"proc-macro2 1.0.76",
"quote 1.0.35",

@ -173,7 +173,7 @@ tendermint-rpc = { version = "0.32.0", features = ["http-client", "tokio"] }
thiserror = "1.0"
time = "0.3"
tiny-keccak = "2.0.2"
tokio = { version = "1", features = ["parking_lot", "tracing"] }
tokio = { version = "1.37", features = ["parking_lot", "tracing"] }
tokio-metrics = { version = "0.3.1", default-features = false }
tokio-test = "0.4"
toml_edit = "0.19.14"

@ -9,6 +9,7 @@ use derive_more::AsRef;
use eyre::Result;
use futures_util::future::try_join_all;
use hyperlane_base::{
broadcast::BroadcastMpscSender,
db::{HyperlaneRocksDB, DB},
metrics::{AgentMetrics, MetricsUpdater},
settings::ChainConf,
@ -21,8 +22,8 @@ use hyperlane_core::{
};
use tokio::{
sync::{
broadcast::{Receiver, Sender},
mpsc::{self, UnboundedSender},
broadcast::Sender as BroadcastSender,
mpsc::{self, Receiver as MpscReceiver, UnboundedSender},
RwLock,
},
task::JoinHandle,
@ -309,7 +310,7 @@ impl BaseAgent for Relayer {
}));
tasks.push(console_server.instrument(info_span!("Tokio console server")));
}
let sender = Sender::<MessageRetryRequest>::new(ENDPOINT_MESSAGES_QUEUE_SIZE);
let sender = BroadcastSender::<MessageRetryRequest>::new(ENDPOINT_MESSAGES_QUEUE_SIZE);
// send channels by destination chain
let mut send_channels = HashMap::with_capacity(self.destination_chains.len());
let mut prep_queues = HashMap::with_capacity(self.destination_chains.len());
@ -358,7 +359,7 @@ impl BaseAgent for Relayer {
tasks.push(
self.run_interchain_gas_payment_sync(
origin,
maybe_broadcaster.clone().map(|b| b.subscribe()),
BroadcastMpscSender::map_get_receiver(maybe_broadcaster.as_ref()).await,
task_monitor.clone(),
)
.await,
@ -366,7 +367,7 @@ impl BaseAgent for Relayer {
tasks.push(
self.run_merkle_tree_hook_syncs(
origin,
maybe_broadcaster.map(|b| b.subscribe()),
BroadcastMpscSender::map_get_receiver(maybe_broadcaster.as_ref()).await,
task_monitor.clone(),
)
.await,
@ -428,7 +429,7 @@ impl Relayer {
async fn run_interchain_gas_payment_sync(
&self,
origin: &HyperlaneDomain,
tx_id_receiver: Option<Receiver<H512>>,
tx_id_receiver: Option<MpscReceiver<H512>>,
task_monitor: TaskMonitor,
) -> Instrumented<JoinHandle<()>> {
let index_settings = self.as_ref().settings.chains[origin.name()].index_settings();
@ -453,7 +454,7 @@ impl Relayer {
async fn run_merkle_tree_hook_syncs(
&self,
origin: &HyperlaneDomain,
tx_id_receiver: Option<Receiver<H512>>,
tx_id_receiver: Option<MpscReceiver<H512>>,
task_monitor: TaskMonitor,
) -> Instrumented<JoinHandle<()>> {
let index_settings = self.as_ref().settings.chains[origin.name()].index.clone();

@ -4,14 +4,12 @@ use async_trait::async_trait;
use derive_more::AsRef;
use futures::future::try_join_all;
use hyperlane_base::{
metrics::AgentMetrics, settings::IndexSettings, BaseAgent, ChainMetrics, ContractSyncMetrics,
ContractSyncer, CoreMetrics, HyperlaneAgentCore, MetricsUpdater, SyncOptions,
broadcast::BroadcastMpscSender, metrics::AgentMetrics, settings::IndexSettings, BaseAgent,
ChainMetrics, ContractSyncMetrics, ContractSyncer, CoreMetrics, HyperlaneAgentCore,
MetricsUpdater, SyncOptions,
};
use hyperlane_core::{Delivery, HyperlaneDomain, HyperlaneMessage, InterchainGasPayment, H512};
use tokio::{
sync::broadcast::{Receiver, Sender},
task::JoinHandle,
};
use tokio::{sync::mpsc::Receiver as MpscReceiver, task::JoinHandle};
use tracing::{info_span, instrument::Instrumented, trace, Instrument};
use crate::{chain_scraper::HyperlaneSqlDb, db::ScraperDb, settings::ScraperSettings};
@ -155,7 +153,6 @@ impl Scraper {
self.contract_sync_metrics.clone(),
db.clone(),
index_settings.clone(),
maybe_broadcaster.clone().map(|b| b.subscribe()),
)
.await,
);
@ -166,7 +163,7 @@ impl Scraper {
self.contract_sync_metrics.clone(),
db,
index_settings.clone(),
maybe_broadcaster.map(|b| b.subscribe()),
BroadcastMpscSender::<H512>::map_get_receiver(maybe_broadcaster.as_ref()).await,
)
.await,
);
@ -187,7 +184,10 @@ impl Scraper {
contract_sync_metrics: Arc<ContractSyncMetrics>,
db: HyperlaneSqlDb,
index_settings: IndexSettings,
) -> (Instrumented<JoinHandle<()>>, Option<Sender<H512>>) {
) -> (
Instrumented<JoinHandle<()>>,
Option<BroadcastMpscSender<H512>>,
) {
let sync = self
.as_ref()
.settings
@ -215,7 +215,6 @@ impl Scraper {
contract_sync_metrics: Arc<ContractSyncMetrics>,
db: HyperlaneSqlDb,
index_settings: IndexSettings,
tx_id_receiver: Option<Receiver<H512>>,
) -> Instrumented<JoinHandle<()>> {
let sync = self
.as_ref()
@ -231,11 +230,10 @@ impl Scraper {
let label = "message_delivery";
let cursor = sync.cursor(index_settings.clone()).await;
tokio::spawn(async move {
sync.sync(label, SyncOptions::new(Some(cursor), tx_id_receiver))
.await
})
.instrument(info_span!("ChainContractSync", chain=%domain.name(), event=label))
// there is no txid receiver for delivery indexing, since delivery txs aren't batched with
// other types of indexed txs / events
tokio::spawn(async move { sync.sync(label, SyncOptions::new(Some(cursor), None)).await })
.instrument(info_span!("ChainContractSync", chain=%domain.name(), event=label))
}
async fn build_interchain_gas_payment_indexer(
@ -245,7 +243,7 @@ impl Scraper {
contract_sync_metrics: Arc<ContractSyncMetrics>,
db: HyperlaneSqlDb,
index_settings: IndexSettings,
tx_id_receiver: Option<Receiver<H512>>,
tx_id_receiver: Option<MpscReceiver<H512>>,
) -> Instrumented<JoinHandle<()>> {
let sync = self
.as_ref()

@ -230,6 +230,7 @@ pub trait BuildableWithProvider {
self.build_with_provider(nonce_manager_provider, conn, locator)
} else {
println!("Building provider without siger");
self.build_with_provider(provider, conn, locator)
}
.await)

@ -0,0 +1,52 @@
use std::sync::Arc;
use derive_new::new;
use eyre::Result;
use hyperlane_core::H512;
use tokio::sync::{
mpsc::{Receiver as MpscReceiver, Sender as MpscSender},
Mutex,
};
#[derive(Debug, Clone, new)]
/// Wrapper around a vec of mpsc senders that broadcasts messages to all of them.
/// This is a workaround to get an async interface for `send`, so senders are blocked if any of the receiving channels is full,
/// rather than overwriting old messages (as the `broadcast` channel ring buffer implementation does).
pub struct BroadcastMpscSender<T> {
capacity: usize,
/// To make this safe to `Clone`, the sending end has to be in an arc-mutex.
/// Otherwise it would be possible to call `get_receiver` and create new receiver-sender pairs, whose sender is later dropped
/// because the other `BroadcastMpscSender`s have no reference to it. The receiver would then point to a closed
/// channel. So all instances of `BroadcastMpscSender` have to point to the entire set of senders.
#[new(default)]
sender: Arc<Mutex<Vec<MpscSender<T>>>>,
}
impl BroadcastMpscSender<H512> {
/// Send a message to all the receiving channels.
// This will block if at least one of the receiving channels is full
pub async fn send(&self, txid: H512) -> Result<()> {
let senders = self.sender.lock().await;
for sender in &*senders {
sender.send(txid).await?
}
Ok(())
}
/// Get a receiver channel that will receive messages broadcasted by all the senders
pub async fn get_receiver(&self) -> MpscReceiver<H512> {
let (sender, receiver) = tokio::sync::mpsc::channel(self.capacity);
self.sender.lock().await.push(sender);
receiver
}
/// Utility function map an option of `BroadcastMpscSender` to an option of `MpscReceiver`
pub async fn map_get_receiver(maybe_self: Option<&Self>) -> Option<MpscReceiver<H512>> {
if let Some(s) = maybe_self {
Some(s.get_receiver().await)
} else {
None
}
}
}

@ -13,7 +13,7 @@ pub enum CursorType {
RateLimited,
}
// H512 * 1M = 64MB per origin chain
// H512 * 30k =~ 2MB per origin chain
const TX_ID_CHANNEL_CAPACITY: Option<usize> = Some(30_000);
pub trait Indexable {

@ -3,6 +3,7 @@ use std::{
};
use axum::async_trait;
use broadcast::BroadcastMpscSender;
use cursors::*;
use derive_new::new;
use hyperlane_core::{
@ -13,13 +14,14 @@ use hyperlane_core::{
use hyperlane_core::{Indexed, LogMeta, H512};
pub use metrics::ContractSyncMetrics;
use prometheus::core::{AtomicI64, AtomicU64, GenericCounter, GenericGauge};
use tokio::sync::broadcast::error::TryRecvError;
use tokio::sync::broadcast::{Receiver as BroadcastReceiver, Sender as BroadcastSender};
use tokio::sync::mpsc::{error::TryRecvError, Receiver as MpscReceiver};
use tokio::time::sleep;
use tracing::{debug, info, instrument, trace, warn};
use crate::settings::IndexSettings;
/// Broadcast channel utility, with async interface for `send`
pub mod broadcast;
pub(crate) mod cursors;
mod eta_calculator;
mod metrics;
@ -37,7 +39,7 @@ pub struct ContractSync<T: Indexable, D: HyperlaneLogStore<T>, I: Indexer<T>> {
db: D,
indexer: I,
metrics: ContractSyncMetrics,
broadcast_sender: Option<BroadcastSender<H512>>,
broadcast_sender: Option<BroadcastMpscSender<H512>>,
_phantom: PhantomData<T>,
}
@ -49,7 +51,7 @@ impl<T: Indexable, D: HyperlaneLogStore<T>, I: Indexer<T>> ContractSync<T, D, I>
db,
indexer,
metrics,
broadcast_sender: T::broadcast_channel_size().map(BroadcastSender::new),
broadcast_sender: T::broadcast_channel_size().map(BroadcastMpscSender::new),
_phantom: PhantomData,
}
}
@ -66,7 +68,7 @@ where
&self.domain
}
fn get_broadcaster(&self) -> Option<BroadcastSender<H512>> {
fn get_broadcaster(&self) -> Option<BroadcastMpscSender<H512>> {
self.broadcast_sender.clone()
}
@ -97,7 +99,7 @@ where
#[instrument(fields(domain=self.domain().name()), skip(self, recv, stored_logs_metric))]
async fn fetch_logs_from_receiver(
&self,
recv: &mut BroadcastReceiver<H512>,
recv: &mut MpscReceiver<H512>,
stored_logs_metric: &GenericCounter<AtomicU64>,
) {
loop {
@ -121,11 +123,11 @@ where
);
}
Err(TryRecvError::Empty) => {
trace!("No txid received");
trace!("No tx id received");
break;
}
Err(err) => {
warn!(?err, "Error receiving txid from channel");
warn!(?err, "Error receiving tx id from channel");
break;
}
}
@ -175,11 +177,11 @@ where
);
if let Some(tx) = self.broadcast_sender.as_ref() {
logs.iter().for_each(|(_, meta)| {
if let Err(err) = tx.send(meta.transaction_id) {
for (_, meta) in &logs {
if let Err(err) = tx.send(meta.transaction_id).await {
trace!(?err, "Error sending txid to receiver");
}
});
}
}
// Update cursor
@ -247,7 +249,7 @@ pub trait ContractSyncer<T>: Send + Sync {
fn domain(&self) -> &HyperlaneDomain;
/// If this syncer is also a broadcaster, return the channel to receive txids
fn get_broadcaster(&self) -> Option<BroadcastSender<H512>>;
fn get_broadcaster(&self) -> Option<BroadcastMpscSender<H512>>;
}
#[derive(new)]
@ -257,7 +259,7 @@ pub struct SyncOptions<T> {
// Might want to refactor into an enum later, where we either index with a cursor or rely on receiving
// txids from a channel to other indexing tasks
cursor: Option<Box<dyn ContractSyncCursor<T>>>,
tx_id_receiver: Option<BroadcastReceiver<H512>>,
tx_id_receiver: Option<MpscReceiver<H512>>,
}
impl<T> From<Box<dyn ContractSyncCursor<T>>> for SyncOptions<T> {
@ -302,7 +304,7 @@ where
ContractSync::domain(self)
}
fn get_broadcaster(&self) -> Option<BroadcastSender<H512>> {
fn get_broadcaster(&self) -> Option<BroadcastMpscSender<H512>> {
ContractSync::get_broadcaster(self)
}
}
@ -341,7 +343,7 @@ where
ContractSync::domain(self)
}
fn get_broadcaster(&self) -> Option<BroadcastSender<H512>> {
fn get_broadcaster(&self) -> Option<BroadcastMpscSender<H512>> {
ContractSync::get_broadcaster(self)
}
}

@ -65,12 +65,16 @@ pub fn termination_invariants_met(
const STORING_NEW_MESSAGE_LOG_MESSAGE: &str = "Storing new message in db";
const LOOKING_FOR_EVENTS_LOG_MESSAGE: &str = "Looking for events in index range";
const HYPER_INCOMING_BODY_LOG_MESSAGE: &str = "incoming body completed";
const TX_ID_INDEXING_LOG_MESSAGE: &str = "Found log(s) for tx id";
let relayer_logfile = File::open(log_file_path)?;
let invariant_logs = &[
STORING_NEW_MESSAGE_LOG_MESSAGE,
LOOKING_FOR_EVENTS_LOG_MESSAGE,
GAS_EXPENDITURE_LOG_MESSAGE,
HYPER_INCOMING_BODY_LOG_MESSAGE,
TX_ID_INDEXING_LOG_MESSAGE,
];
let log_counts = get_matching_lines(&relayer_logfile, invariant_logs);
// Zero insertion messages don't reach `submit` stage where gas is spent, so we only expect these logs for the other messages.
@ -96,6 +100,19 @@ pub fn termination_invariants_met(
log_counts.get(LOOKING_FOR_EVENTS_LOG_MESSAGE).unwrap() > &0,
"Didn't find any logs about looking for events in index range"
);
let total_tx_id_log_count = log_counts.get(TX_ID_INDEXING_LOG_MESSAGE).unwrap();
assert!(
// there are 3 txid-indexed events:
// - relayer: merkle insertion and gas payment
// - scraper: gas payment
// some logs are emitted for multiple events, so requiring there to be at least
// `config.kathy_messages` logs is a reasonable approximation, since all three of these events
// are expected to be logged for each message.
*total_tx_id_log_count as u64 >= config.kathy_messages,
"Didn't find as many tx id logs as expected. Found {} and expected {}",
total_tx_id_log_count,
config.kathy_messages
);
assert!(
log_counts.get(HYPER_INCOMING_BODY_LOG_MESSAGE).is_none(),
"Verbose logs not expected at the log level set in e2e"

@ -33,7 +33,6 @@
"dependencies": {
"@chainlink/ccip-read-server": "^0.2.1",
"dotenv-flow": "^4.1.0",
"ethers": "5.7.2",
"hyperlane-explorer": "https://github.com/hyperlane-xyz/hyperlane-explorer.git"
"ethers": "5.7.2"
}
}

@ -1,5 +1,6 @@
import { info } from 'console';
import { Message, MessageTx } from 'hyperlane-explorer/src/types';
import { Message, MessageTx } from './explorerTypes';
// These types are copied from hyperlane-explorer. TODO: export them so this file can use them directly.
interface ApiResult<R> {

@ -1,7 +1,7 @@
import { MessageTx } from 'hyperlane-explorer/src/types';
import { MessageTx } from '../explorerTypes';
class HyperlaneService {
async getOriginBlockByMessageId(id: string): Promise<MessageTx> {
async getOriginBlockByMessageId(_messageId: string): Promise<MessageTx> {
return {
timestamp: 123456789,
hash: '0x123abc456def789',

@ -0,0 +1,60 @@
// TODO de-dupe this types with the Explorer by moving them to a shared lib
// These were originally imported from the explorer package but there were two issues
// 1. The explorer is not structured to be a lib (it's an app)
// 2. The explorer's deps on monorepo packages created circular deps leading to transitive deps conflicts
type Address = string;
export enum MessageStatus {
Unknown = 'unknown',
Pending = 'pending',
Delivered = 'delivered',
Failing = 'failing',
}
export interface MessageTxStub {
timestamp: number;
hash: string;
from: Address;
}
export interface MessageTx extends MessageTxStub {
to: Address;
blockHash: string;
blockNumber: number;
mailbox: Address;
nonce: number;
gasLimit: number;
gasPrice: number;
effectiveGasPrice: number;
gasUsed: number;
cumulativeGasUsed: number;
maxFeePerGas: number;
maxPriorityPerGas: number;
}
export interface MessageStub {
status: MessageStatus;
id: string; // Database id
msgId: string; // Message hash
nonce: number; // formerly leafIndex
sender: Address;
recipient: Address;
originChainId: number;
originDomainId: number;
destinationChainId: number;
destinationDomainId: number;
origin: MessageTxStub;
destination?: MessageTxStub;
isPiMsg?: boolean;
}
export interface Message extends MessageStub {
body: string;
decodedBody?: string;
origin: MessageTx;
destination?: MessageTx;
totalGasAmount?: string;
totalPayment?: string;
numPayments?: number;
}

@ -1,4 +1,3 @@
import { stringify as yamlStringify } from 'yaml';
import { CommandModule } from 'yargs';
import { EvmCoreReader } from '@hyperlane-xyz/sdk';
@ -12,7 +11,7 @@ import { runCoreDeploy } from '../deploy/core.js';
import { evaluateIfDryRunFailure } from '../deploy/dry-run.js';
import { errorRed, log, logGray, logGreen } from '../logger.js';
import {
indentYamlOrJson,
logYamlIfUnderMaxLines,
readYamlOrJson,
writeYamlOrJson,
} from '../utils/files.js';
@ -146,7 +145,7 @@ export const read: CommandModuleWithContext<{
const coreConfig = await evmCoreReader.deriveCoreConfig(mailbox);
writeYamlOrJson(configFilePath, coreConfig, 'yaml');
logGreen(`✅ Core config written successfully to ${configFilePath}:\n`);
log(indentYamlOrJson(yamlStringify(coreConfig, null, 2), 4));
logYamlIfUnderMaxLines(coreConfig);
} catch (e: any) {
errorRed(
`❌ Failed to read core config for mailbox ${mailbox} on ${chain}:`,

@ -154,7 +154,6 @@ export async function createWarpRouteDeployConfig({
case TokenType.collateralFiat:
case TokenType.collateralUri:
case TokenType.fastCollateral:
case TokenType.collateralVault:
result[chain] = {
mailbox,
type,
@ -166,6 +165,18 @@ export async function createWarpRouteDeployConfig({
}),
};
break;
case TokenType.collateralVault:
result[chain] = {
mailbox,
type,
owner,
isNft,
interchainSecurityModule,
token: await input({
message: `Enter the ERC-4626 vault address on chain ${chain}`,
}),
};
break;
default:
result[chain] = {
mailbox,

@ -3,12 +3,19 @@ import select from '@inquirer/select';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
import {
LineCounter,
parse,
parse as yamlParse,
stringify as yamlStringify,
} from 'yaml';
import { objMerge } from '@hyperlane-xyz/utils';
import { log } from '../logger.js';
export const MAX_READ_LINE_OUTPUT = 250;
export type FileFormat = 'yaml' | 'json';
export type ArtifactsFile = {
@ -221,3 +228,22 @@ export function indentYamlOrJson(str: string, indentLevel: number): string {
.map((line) => indent + line)
.join('\n');
}
/**
* Logs the YAML representation of an object if the number of lines is less than the specified maximum.
*
* @param obj - The object to be converted to YAML.
* @param maxLines - The maximum number of lines allowed for the YAML representation.
* @param margin - The number of spaces to use for indentation (default is 2).
*/
export function logYamlIfUnderMaxLines(
obj: any,
maxLines: number = MAX_READ_LINE_OUTPUT,
margin: number = 2,
): void {
const asYamlString = yamlStringify(obj, null, margin);
const lineCounter = new LineCounter();
parse(asYamlString, { lineCounter });
log(lineCounter.lineStarts.length < maxLines ? asYamlString : '');
}

@ -1,193 +0,0 @@
# Apache License
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### Terms and Conditions for use, reproduction, and distribution
#### 1. Definitions
“License” shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
“Licensor” shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
“Legal Entity” shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, “control” means **(i)** the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
outstanding shares, or **(iii)** beneficial ownership of such entity.
“You” (or “Your”) shall mean an individual or Legal Entity exercising
permissions granted by this License.
“Source” form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
“Object” form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
“Work” shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
“Derivative Works” shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
“Contribution” shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as “Not a Contribution.”
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
#### 2. Grant of Copyright License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.
#### 3. Grant of Patent License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
such license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by combination
of their Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this License
for that Work shall terminate as of the date such litigation is filed.
#### 4. Redistribution
You may reproduce and distribute copies of the Work or Derivative Works thereof
in any medium, with or without modifications, and in Source or Object form,
provided that You meet the following conditions:
- **(a)** You must give any other recipients of the Work or Derivative Works a copy of
this License; and
- **(b)** You must cause any modified files to carry prominent notices stating that You
changed the files; and
- **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source form
of the Work, excluding those notices that do not pertain to any part of the
Derivative Works; and
- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
#### 5. Submission of Contributions
Unless You explicitly state otherwise, any Contribution intentionally submitted
for inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the terms of
any separate license agreement you may have executed with Licensor regarding
such Contributions.
#### 6. Trademarks
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
#### 7. Disclaimer of Warranty
Unless required by applicable law or agreed to in writing, Licensor provides the
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
including, without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise of
permissions under this License.
#### 8. Limitation of Liability
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License or
out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
any and all other commercial damages or losses), even if such Contributor has
been advised of the possibility of such damages.
#### 9. Accepting Warranty or Additional Liability
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
other liability obligations and/or rights consistent with this License. However,
in accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if You
agree to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
_END OF TERMS AND CONDITIONS_
### APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets `[]` replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same “printed page” as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

@ -7,7 +7,7 @@
"@aws-sdk/client-iam": "^3.74.0",
"@aws-sdk/client-kms": "3.48.0",
"@aws-sdk/client-s3": "^3.74.0",
"@cosmjs/amino": "^0.31.3",
"@cosmjs/amino": "^0.32.4",
"@eth-optimism/sdk": "^3.1.6",
"@ethersproject/experimental": "^5.7.0",
"@ethersproject/hardware-wallets": "^5.7.0",

@ -4,8 +4,8 @@
"version": "4.1.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.74.0",
"@cosmjs/cosmwasm-stargate": "^0.31.3",
"@cosmjs/stargate": "^0.31.3",
"@cosmjs/cosmwasm-stargate": "^0.32.4",
"@cosmjs/stargate": "^0.32.4",
"@hyperlane-xyz/core": "4.1.0",
"@hyperlane-xyz/utils": "4.1.0",
"@safe-global/api-kit": "1.3.0",

@ -1,9 +1,12 @@
import { expect } from 'chai';
import { Wallet, constants } from 'ethers';
import { errors as EthersError, Wallet, constants } from 'ethers';
import { ERC20__factory } from '@hyperlane-xyz/core';
import { HyperlaneSmartProvider } from './SmartProvider.js';
import {
HyperlaneSmartProvider,
getSmartProviderErrorMessage,
} from './SmartProvider.js';
const PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const NETWORK = 31337;
@ -82,68 +85,61 @@ describe('SmartProvider', async () => {
expect(Array.isArray(logs)).to.be.true;
});
// it('throws with invalid RPC', async () => {
// const INVALID_URL = 'http://1337.1337.1337.1:8545';
// const NETWORK = 11337;
// const smartProvider = HyperlaneSmartProvider.fromRpcUrl(
// NETWORK,
// INVALID_URL,
// {
// maxRetries: 3,
// },
// );
// const signer = new Wallet(PK, smartProvider);
// try {
// const factory = new ERC20__factory(signer);
// await factory.deploy('fake', 'FAKE');
// } catch (e: any) {
// expect(e.message).to.equal(
// getSmartProviderErrorMessage(EthersError.SERVER_ERROR),
// );
// }
// });
// it('throws with multiple invalid RPCs', async () => {
// const INVALID_URL_1 = 'http://1337.1337.1337.1:8545';
// const INVALID_URL_2 = 'http://1338.1338.1338.1:8545';
// const NETWORK = 11337;
// const smartProvider = new HyperlaneSmartProvider(
// NETWORK,
// [{ http: INVALID_URL_1 }, { http: INVALID_URL_2 }],
// [],
// {
// maxRetries: 3,
// },
// );
// const signer = new Wallet(PK, smartProvider);
// try {
// const factory = new ERC20__factory(signer);
// await factory.deploy('fake', 'FAKE');
// } catch (e: any) {
// expect(e.message).to.equal(
// getSmartProviderErrorMessage(EthersError.SERVER_ERROR),
// );
// }
// });
// it('handles invalid and valid RPCs', async () => {
// const INVALID_URL = 'http://1337.1337.1337.1:8545';
// const NETWORK = 11337;
// const smartProvider = new HyperlaneSmartProvider(
// NETWORK,
// [{ http: INVALID_URL }, { http: URL }],
// [],
// {
// maxRetries: 3,
// },
// );
// const signer = new Wallet(PK, smartProvider);
// const factory = new ERC20__factory(signer);
// const erc20 = await factory.deploy('fake', 'FAKE');
// expect(erc20.address).to.not.be.empty;
// });
it('throws with invalid RPC', async () => {
const INVALID_URL = 'http://127.0.0.1:33331337';
const INVALID_NETWORK = 55555;
const smartProvider = HyperlaneSmartProvider.fromRpcUrl(
INVALID_NETWORK,
INVALID_URL,
);
const signer = new Wallet(PK, smartProvider);
try {
const factory = new ERC20__factory(signer);
await factory.deploy('fake', 'FAKE');
} catch (e: any) {
expect(e.message).to.equal(
getSmartProviderErrorMessage(EthersError.SERVER_ERROR),
);
}
});
it('throws with multiple invalid RPCs', async () => {
const INVALID_URL_1 = 'http://127.0.0.1:33331337';
const INVALID_URL_2 = 'http://127.0.0.1:23331337';
const INVALID_NETWORK = 55555;
const smartProvider = new HyperlaneSmartProvider(
INVALID_NETWORK,
[{ http: INVALID_URL_1 }, { http: INVALID_URL_2 }],
[],
);
const signer = new Wallet(PK, smartProvider);
try {
const factory = new ERC20__factory(signer);
await factory.deploy('fake', 'FAKE');
} catch (e: any) {
expect(e.message).to.equal(
getSmartProviderErrorMessage(EthersError.SERVER_ERROR),
);
}
});
it('handles invalid and valid RPCs', async () => {
const INVALID_URL = 'http://127.0.0.1:33331337';
const smartProvider = new HyperlaneSmartProvider(
NETWORK,
[{ http: INVALID_URL }, { http: URL }],
[],
{
maxRetries: 3,
},
);
const signer = new Wallet(PK, smartProvider);
const factory = new ERC20__factory(signer);
const erc20 = await factory.deploy('fake', 'FAKE');
expect(erc20.address).to.not.be.empty;
});
});

@ -1,5 +1,4 @@
import { MsgTransferEncodeObject } from '@cosmjs/stargate';
import Long from 'long';
import { Address, Domain, assert } from '@hyperlane-xyz/utils';
@ -128,9 +127,8 @@ export class CosmIbcTokenAdapter
sender: transferParams.fromAccountOwner,
receiver: transferParams.recipient,
// Represented as nano-seconds
timeoutTimestamp: Long.fromNumber(
new Date().getTime() + COSMOS_IBC_TRANSFER_TIMEOUT,
).multiply(1_000_000),
timeoutTimestamp:
BigInt(new Date().getTime() + COSMOS_IBC_TRANSFER_TIMEOUT) * 1000000n,
memo,
};
return {

@ -3,7 +3,7 @@
"description": "General utilities and types for the Hyperlane network",
"version": "4.1.0",
"dependencies": {
"@cosmjs/encoding": "^0.31.3",
"@cosmjs/encoding": "^0.32.4",
"@solana/web3.js": "^1.78.0",
"bignumber.js": "^9.1.1",
"ethers": "^5.7.2",

@ -0,0 +1,6 @@
node_modules
dist
coverage
tailwind.config.js
postcss.config.js
src/stories/**/*.stories.tsx

@ -0,0 +1,6 @@
{
"rules": {
// TODO use utils rootLogger in widgets lib
"no-console": ["off"]
}
}

@ -0,0 +1,5 @@
# testing
/storybook-static
# production
/dist

@ -0,0 +1,19 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: true,
},
};
export default config;

@ -0,0 +1 @@
<link href="/styles.css" rel="stylesheet" />

@ -0,0 +1,15 @@
import '../src/styles.css';
const preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

@ -0,0 +1,35 @@
# Hyperlane Widgets
Common react components for projects using Hyperlane.
## Installation
```sh
# Install with npm
npm install @hyperlane-xyz/widgets
# Or install with yarn
yarn add @hyperlane-xyz/widgets
```
### Peer dependencies
This package requires `@hyperlane-xyz/sdk`, `react`, and `react-dom`.
## Contents
### Components
- `ChainLogo`: A logo icon for a given chain ID
- `MessageTimeline`: A timeline showing stages of message delivery
- `WideChevron`: A customizable version of Hyperlane's chevron logo
### Hooks
- `useMessage`: Fetch data about a message from the Hyperlane Explorer
- `useMessageStage`: Fetch and compute message delivery stage and timings
- `useMessageTimeline`: Fetch message data for use with `MessageTimeline`
## Learn more
For more information, see the [Hyperlane documentation](https://docs.hyperlane.xyz/docs/intro).

@ -0,0 +1,74 @@
{
"name": "@hyperlane-xyz/widgets",
"description": "Common react components for Hyperlane projects",
"version": "4.1.0",
"peerDependencies": {
"react": "^18",
"react-dom": "^18"
},
"dependencies": {
"@hyperlane-xyz/registry": "2.3.0",
"@hyperlane-xyz/sdk": "4.1.0"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.6.14",
"@storybook/addon-interactions": "^7.6.14",
"@storybook/addon-links": "^7.6.14",
"@storybook/addon-onboarding": "^1.0.11",
"@storybook/blocks": "^7.6.14",
"@storybook/react": "^7.6.14",
"@storybook/react-vite": "^7.6.14",
"@storybook/test": "^7.6.14",
"@types/node": "^18.11.18",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@types/ws": "^8.5.5",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"babel-loader": "^8.3.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-storybook": "^0.6.15",
"postcss": "^8.4.21",
"prettier": "^2.8.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.6.14",
"tailwindcss": "^3.2.4",
"ts-node": "^10.8.0",
"typescript": "5.3.3",
"vite": "^5.1.1"
},
"files": [
"/dist"
],
"type": "module",
"exports": {
".": "./dist/index.js",
"./styles.css": "./dist/styles.css"
},
"types": "./dist/index.d.ts",
"homepage": "https://www.hyperlane.xyz",
"keywords": [
"Hyperlane",
"Widgets",
"React",
"Components",
"Typescript"
],
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/hyperlane-xyz/hyperlane-widgets"
},
"scripts": {
"build": "yarn build:ts && yarn build:css",
"build:ts": "tsc",
"build:css": "tailwindcss -c ./tailwind.config.cjs -i ./src/styles.css -o ./dist/styles.css --minify",
"clean": "rm -rf ./dist ./cache ./storybook-static",
"lint": "eslint ./src --ext .ts",
"prettier": "prettier --write ./src",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}
}

@ -0,0 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
},
}

@ -0,0 +1,30 @@
export enum ColorPalette {
Black = '#010101',
White = '#FFFFFF',
Blue = '#2362C0',
DarkBlue = '#162A4A',
LightBlue = '#82A8E4',
Pink = '#CF2FB3',
Gray = '#6B7280',
Beige = '#F1EDE9',
Red = '#BF1B15',
}
export function seedToBgColor(seed?: number) {
if (!seed) return 'htw-bg-gray-100';
const mod = seed % 5;
switch (mod) {
case 0:
return 'htw-bg-blue-100';
case 1:
return 'htw-bg-pink-200';
case 2:
return 'htw-bg-green-100';
case 3:
return 'htw-bg-orange-200';
case 4:
return 'htw-bg-violet-200';
default:
return 'htw-bg-gray-100';
}
}

@ -0,0 +1 @@
export const HYPERLANE_EXPLORER_API_URL = 'https://explorer.hyperlane.xyz/api';

@ -0,0 +1,30 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
// Paper airplane shape
function _AirplaneIcon({ width, height, color, classes }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={width}
height={height}
className={classes}
>
<path
d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083l6-15Zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471-.47 1.178Z"
fill={color || ColorPalette.Blue}
/>
</svg>
);
}
export const AirplaneIcon = memo(_AirplaneIcon);

@ -0,0 +1,73 @@
import React, { ReactElement, useEffect, useState } from 'react';
import type { IRegistry } from '@hyperlane-xyz/registry';
import { Circle } from './Circle.js';
import { QuestionMarkIcon } from './QuestionMark.js';
type SvgIcon = (props: {
width: number;
height: number;
title?: string;
}) => ReactElement;
export interface ChainLogoProps {
chainName: string;
registry: IRegistry;
size?: number;
background?: boolean;
Icon?: SvgIcon; // Optional override for the logo in the registry
}
export function ChainLogo({
chainName,
registry,
size = 32,
background = false,
Icon,
}: ChainLogoProps) {
const title = chainName || 'Unknown';
const bgColorSeed = title.charCodeAt(0);
const iconSize = Math.floor(size / 1.9);
const [svgLogos, setSvgLogos] = useState({});
const logoUri = svgLogos[chainName];
useEffect(() => {
if (!chainName || svgLogos[chainName] || Icon) return;
registry
.getChainLogoUri(chainName)
.then((uri) => uri && setSvgLogos({ ...svgLogos, [chainName]: uri }))
.catch((err) => console.error(err));
}, [chainName, registry, svgLogos, Icon]);
if (!logoUri && !Icon) {
return (
<Circle size={size} title={title} bgColorSeed={bgColorSeed}>
{chainName ? (
<div style={{ fontSize: iconSize }}>{chainName[0].toUpperCase()}</div>
) : (
<QuestionMarkIcon width={iconSize} height={iconSize} />
)}
</Circle>
);
}
if (background) {
return (
<Circle size={size} title={title} classes="htw-bg-gray-100">
{Icon ? (
<Icon width={iconSize} height={iconSize} title={title} />
) : (
<img src={logoUri} alt={title} width={iconSize} height={iconSize} />
)}
</Circle>
);
} else {
return Icon ? (
<Icon width={size} height={size} title={title} />
) : (
<img src={logoUri} alt={title} width={size} height={size} />
);
}
}

@ -0,0 +1,30 @@
import React, { PropsWithChildren } from 'react';
import { seedToBgColor } from '../color.js';
export function Circle({
size,
title,
bgColorSeed,
classes,
children,
}: PropsWithChildren<{
size: string | number;
title?: string;
bgColorSeed?: number;
classes?: string;
}>) {
const bgColor =
bgColorSeed === null || bgColorSeed == undefined
? ''
: seedToBgColor(bgColorSeed);
return (
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={`htw-flex htw-items-center htw-justify-center htw-rounded-full htw-transition-all overflow-hidden ${bgColor} ${classes}`}
title={title}
>
{children}
</div>
);
}

@ -0,0 +1,34 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
// Envelope with checkmark
function _EnvelopeIcon({ width, height, color, classes }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={width}
height={height}
className={classes}
>
<path
d="M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.026A2 2 0 0 0 2 14h6.256A4.493 4.493 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586l-1.239-.757ZM16 4.697v4.974A4.491 4.491 0 0 0 12.5 8a4.49 4.49 0 0 0-1.965.45l-.338-.207L16 4.697Z"
fill={color || ColorPalette.Blue}
/>
<path
d="M16 12.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Zm-1.993-1.679a.5.5 0 0 0-.686.172l-1.17 1.95-.547-.547a.5.5 0 0 0-.708.708l.774.773a.75.75 0 0 0 1.174-.144l1.335-2.226a.5.5 0 0 0-.172-.686Z"
fill={color || ColorPalette.Blue}
/>
</svg>
);
}
export const EnvelopeIcon = memo(_EnvelopeIcon);

@ -0,0 +1,29 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
function _LockIcon({ width, height, color, classes }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 15 18"
width={width}
height={height}
className={classes}
>
<path
d="M7.14 1.13c.76 0 1.49.23 2.02.65.54.43.84 1 .84 1.6v4.5H4.29v-4.5c0-.6.3-1.17.83-1.6a3.29 3.29 0 0 1 2.02-.66Zm4.29 6.75v-4.5c0-.9-.45-1.76-1.26-2.4C9.37.37 8.28 0 7.14 0 6.01 0 4.92.36 4.11.99c-.8.63-1.25 1.49-1.25 2.38v4.5c-.76 0-1.49.24-2.02.66-.54.43-.84 1-.84 1.6v5.62c0 .6.3 1.17.84 1.6.53.41 1.26.65 2.02.65h8.57c.76 0 1.48-.24 2.02-.66.53-.42.84-1 .84-1.59v-5.63c0-.6-.3-1.16-.84-1.59a3.29 3.29 0 0 0-2.02-.65Z"
fill={color || ColorPalette.Blue}
/>
</svg>
);
}
export const LockIcon = memo(_LockIcon);

@ -0,0 +1,29 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
function _QuestionMarkIcon({ width, height, color, classes }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="13.7 6 20.65 38"
className={classes}
>
<path
d="M21.55 31.5q.05-3.6.82-5.25.78-1.65 2.93-3.6 2.1-1.9 3.23-3.52t1.12-3.48q0-2.25-1.5-3.75t-4.2-1.5q-2.6 0-4 1.48t-2.05 3.07l-4.2-1.85q1.1-2.95 3.73-5.03T23.95 6q5 0 7.7 2.77t2.7 6.68q0 2.4-1.02 4.35-1.03 1.95-3.28 4.1-2.45 2.35-2.95 3.6t-.55 4Zm2.4 12.5q-1.45 0-2.48-1.02-1.02-1.03-1.02-2.48t1.02-2.48Q22.5 37 23.95 37t2.48 1.02q1.02 1.03 1.02 2.48t-1.02 2.48Q25.4 44 23.95 44Z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const QuestionMarkIcon = memo(_QuestionMarkIcon);

@ -0,0 +1,31 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
// Shield with checkmark
function _ShieldIcon({ width, height, color, classes }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={width}
height={height}
className={classes}
>
<path
fillRule="evenodd"
d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.777 11.777 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7.159 7.159 0 0 0 1.048-.625 11.775 11.775 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.541 1.541 0 0 0-1.044-1.263 62.467 62.467 0 0 0-2.887-.87C9.843.266 8.69 0 8 0zm2.146 5.146a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647z"
fill={color || ColorPalette.Blue}
/>
</svg>
);
}
export const ShieldIcon = memo(_ShieldIcon);

@ -0,0 +1,71 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
export interface WideChevronProps {
width?: string | number;
height?: string | number;
direction: 'n' | 'e' | 's' | 'w';
color?: string;
rounded?: boolean;
classes?: string;
}
function _WideChevron({
width,
height,
direction,
color,
rounded,
classes,
}: WideChevronProps) {
let directionClass;
switch (direction) {
case 'n':
directionClass = 'htw--rotate-90';
break;
case 'e':
directionClass = '';
break;
case 's':
directionClass = 'htw-rotate-90';
break;
case 'w':
directionClass = 'htw-rotate-180';
break;
default:
throw new Error(`Invalid chevron direction ${direction}`);
}
if (rounded) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 120.3 190"
width={width}
height={height}
fill={color || ColorPalette.Blue}
className={`${directionClass} ${classes}`}
>
<path d="M4.4 0h53c7.2 0 13.7 3 16.2 7.7l46.5 85.1a2 2 0 0 1 0 2l-.2.5-46.3 87c-2.5 4.6-9 7.7-16.3 7.7h-53c-3 0-5-2-4-4L48 92.9.4 4c-1-2 1-4 4-4Z" />
</svg>
);
} else {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 28 27"
width={width}
height={height}
className={`${directionClass} ${classes}`}
>
<path
d="M13.44 13.5 0 27h14.56L28 13.5 14.56 0H0l13.44 13.5Z"
fill={color || ColorPalette.Blue}
/>
</svg>
);
}
}
export const WideChevron = memo(_WideChevron);

@ -0,0 +1,15 @@
export { ColorPalette, seedToBgColor } from './color.js';
export * from './consts.js';
export { ChainLogo } from './icons/ChainLogo.js';
export { Circle } from './icons/Circle.js';
export { WideChevron } from './icons/WideChevron.js';
export { MessageTimeline } from './messages/MessageTimeline.js';
export {
MessageStage,
MessageStatus,
type ApiMessage,
type StageTimings,
} from './messages/types.js';
export { useMessage } from './messages/useMessage.js';
export { useMessageStage } from './messages/useMessageStage.js';
export { useMessageTimeline } from './messages/useMessageTimeline.js';

@ -0,0 +1,225 @@
import React from 'react';
import { ColorPalette } from '../color.js';
import { AirplaneIcon } from '../icons/Airplane.js';
import { EnvelopeIcon } from '../icons/Envelope.js';
import { LockIcon } from '../icons/Lock.js';
import { ShieldIcon } from '../icons/Shield.js';
import { WideChevron } from '../icons/WideChevron.js';
import { MessageStatus, MessageStage as Stage, StageTimings } from './types.js';
interface Props {
status: MessageStatus;
stage: Stage;
timings: StageTimings;
timestampSent?: number;
hideDescriptions?: boolean;
}
export function MessageTimeline({
status,
stage: _stage,
timings,
timestampSent,
hideDescriptions,
}: Props) {
// Ignore stage value if status shows as delivered
const stage = status === MessageStatus.Delivered ? Stage.Relayed : _stage;
const timeSent = timestampSent ? new Date(timestampSent) : null;
const timeSentStr = timeSent
? `${timeSent.toLocaleDateString()} ${timeSent.toLocaleTimeString()}`
: null;
return (
<div className="htw-pt-14 htw-pb-1 htw-flex htw-w-full">
<div className={styles.stageContainer}>
<div
className={`${styles.stageBar} htw-rounded-l ${getStageOpacityClass(
Stage.Sent,
stage,
status,
)}`}
>
<div className={styles.stageHole}></div>
<div className={styles.stageIconContainer}>
<StageIcon Icon={AirplaneIcon} />
<div className={styles.stageIconCircle}></div>
</div>
<ChevronBlue />
</div>
<h4 className={styles.stageHeader}>
{getStageHeader(Stage.Sent, stage, timings, status)}
</h4>
{!hideDescriptions && (
<p className={styles.stageDesc}>
{timeSentStr
? `Origin transaction sent at ${timeSentStr}`
: 'Waiting for origin transaction'}
</p>
)}
</div>
<div className={styles.stageSpacer}></div>
<div className={styles.stageContainer}>
<div
className={`${styles.stageBar} ${getStageOpacityClass(
Stage.Finalized,
stage,
status,
)}`}
>
<div className={styles.stageHole}></div>
<div className={styles.stageIconContainer}>
<StageIcon Icon={LockIcon} size={14} />
<div className={styles.stageIconCircle}></div>
</div>
<ChevronWhite />
<ChevronBlue />
</div>
<h4 className={styles.stageHeader}>
{getStageHeader(Stage.Finalized, stage, timings, status)}
</h4>
{!hideDescriptions && (
<p className={styles.stageDesc}>
Origin transaction has sufficient confirmations
</p>
)}
</div>
<div className={styles.stageSpacer}></div>
<div className={styles.stageContainer}>
<div
className={`${styles.stageBar} ${getStageOpacityClass(
Stage.Validated,
stage,
status,
)}`}
>
<div className={styles.stageHole}></div>
<div className={styles.stageIconContainer}>
<StageIcon Icon={ShieldIcon} />
<div className={styles.stageIconCircle}></div>
</div>
<ChevronWhite />
<ChevronBlue />
</div>
<h4 className={styles.stageHeader}>
{getStageHeader(Stage.Validated, stage, timings, status)}
</h4>
{!hideDescriptions && (
<p className={styles.stageDesc}>
Validators have signed the message bundle
</p>
)}
</div>
<div className={styles.stageSpacer}></div>
<div className={styles.stageContainer}>
<div
className={`${styles.stageBar} htw-rounded-r ${getStageOpacityClass(
Stage.Relayed,
stage,
status,
)}`}
>
<div className={styles.stageHole}></div>
<div className={styles.stageIconContainer}>
<StageIcon Icon={EnvelopeIcon} />
<div className={styles.stageIconCircle}></div>
</div>
<ChevronWhite />
</div>
<h4 className={styles.stageHeader}>
{getStageHeader(Stage.Relayed, stage, timings, status)}
</h4>
{!hideDescriptions && (
<p className={styles.stageDesc}>
Destination transaction has been confirmed
</p>
)}
</div>
</div>
);
}
function StageIcon({ Icon, size }: { Icon: any; size?: number }) {
return (
<div className="htw-h-9 htw-w-9 htw-flex htw-items-center htw-justify-center htw-rounded-full htw-bg-blue-500">
<Icon
width={size ?? 14}
height={size ?? 14}
alt=""
color={ColorPalette.White}
/>
</div>
);
}
function ChevronWhite() {
return (
<div className="htw-absolute htw--left-3 htw-top-0 htw-h-6">
<WideChevron direction="e" height="100%" width="auto" color="#ffffff" />
</div>
);
}
function ChevronBlue() {
return (
<div className="htw-absolute htw--right-3 htw-top-0 htw-h-6">
<WideChevron direction="e" height="100%" width="auto" />
</div>
);
}
function getStageHeader(
targetStage: Stage,
currentStage: Stage,
timings: StageTimings,
status: MessageStatus,
) {
let label = '';
if (targetStage === Stage.Finalized) {
label = currentStage >= targetStage ? 'Finalized' : 'Finalizing';
} else if (targetStage === Stage.Validated) {
label = currentStage >= targetStage ? 'Validated' : 'Validating';
} else if (targetStage === Stage.Relayed) {
label = currentStage >= targetStage ? 'Relayed' : 'Relaying';
} else if (targetStage === Stage.Sent) {
label = currentStage >= targetStage ? 'Sent' : 'Sending';
}
const timing = timings[targetStage];
if (status === MessageStatus.Failing) {
if (targetStage === currentStage + 1) return `${label}: failed`;
if (targetStage > currentStage + 1) return label;
}
if (timing) return `${label}: ${timing} sec`;
else return label;
}
function getStageOpacityClass(
targetStage: Stage,
currentStage: Stage,
messageStatus: MessageStatus,
) {
if (currentStage >= targetStage) return '';
if (
currentStage === targetStage - 1 &&
messageStatus !== MessageStatus.Failing
)
return 'htw-animate-pulse-slow';
return 'htw-opacity-50';
}
const styles = {
stageContainer: 'htw-flex-1 htw-flex htw-flex-col htw-items-center',
stageSpacer: 'htw-flex-0 htw-w-1 xs:htw-w-2 sm:htw-w-3',
stageBar:
'htw-w-full htw-h-6 htw-flex htw-items-center htw-justify-center htw-bg-blue-500 htw-relative',
stageHole: 'htw-w-3 htw-h-3 htw-rounded-full htw-bg-white',
stageIconContainer:
'htw-absolute htw--top-12 htw-flex htw-flex-col htw-items-center',
stageIconCircle: 'htw-w-0.5 htw-h-4 htw-bg-blue-500',
stageHeader:
'htw-mt-2.5 htw-text-gray-700 htw-text-xs xs:htw-text-sm sm:htw-text-base',
stageDesc:
'htw-mt-1 sm:htw-px-4 htw-text-xs htw-text-gray-500 htw-text-center',
};

@ -0,0 +1,86 @@
// TODO DE-DUPE WITH EXPLORER
// Mostly copied from explorer src/types.ts
export enum MessageStatus {
Unknown = 'unknown',
Pending = 'pending',
Delivered = 'delivered',
Failing = 'failing',
}
export interface MessageTxStub {
timestamp: number;
hash: string;
from: Address;
}
export interface MessageTx extends MessageTxStub {
to: Address;
blockHash: string;
blockNumber: number;
mailbox: Address;
nonce: number;
gasLimit: number;
gasPrice: number;
effectiveGasPrice;
gasUsed: number;
cumulativeGasUsed: number;
maxFeePerGas: number;
maxPriorityPerGas: number;
}
export interface MessageStub {
status: MessageStatus;
id: string; // Database id
msgId: string; // Message hash
nonce: number; // formerly leafIndex
sender: Address;
recipient: Address;
originChainId: number;
originDomainId: number;
destinationChainId: number;
destinationDomainId: number;
origin: MessageTxStub;
destination?: MessageTxStub;
isPiMsg?: boolean;
}
export interface Message extends MessageStub {
body: string;
decodedBody?: string;
origin: MessageTx;
destination?: MessageTx;
totalGasAmount?: string;
totalPayment?: string;
numPayments?: number;
}
export type ApiMessage = Omit<
Message,
| 'msgId' // use id field for msgId
| 'decodedBody'
>;
export interface PartialMessage {
status: MessageStatus;
nonce: number;
originChainId: number;
originDomainId: number;
destinationChainId: number;
destinationDomainId: number;
origin: { blockNumber: number; timestamp: number };
destination?: { blockNumber: number; timestamp: number };
}
export enum MessageStage {
Preparing = 0,
Sent = 1,
Finalized = 2,
Validated = 3,
Relayed = 4,
}
export type StageTimings = {
[MessageStage.Finalized]: number | null;
[MessageStage.Validated]: number | null;
[MessageStage.Relayed]: number | null;
};

@ -0,0 +1,78 @@
import { useCallback, useState } from 'react';
import { HYPERLANE_EXPLORER_API_URL } from '../consts.js';
import { executeExplorerQuery } from '../utils/explorers.js';
import { useInterval } from '../utils/useInterval.js';
import { ApiMessage, MessageStatus } from './types.js';
interface Params {
messageId?: string;
originTxHash?: string;
explorerApiUrl?: string;
retryInterval?: number;
}
// Queries Explorer API to get data for message
// Requires either messageId or originTxHash
export function useMessage({
messageId,
originTxHash,
explorerApiUrl = HYPERLANE_EXPLORER_API_URL,
retryInterval = 2000,
}: Params) {
// Tempting to use react-query here as we did in Explorer but
// avoiding for now to keep dependencies for this lib minimal
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<ApiMessage | null>(null);
const fetcher = useCallback(() => {
// Skip if message is already fetched and delivered
if (data?.status === MessageStatus.Delivered) return;
setIsLoading(true);
fetchMessage(explorerApiUrl, messageId, originTxHash)
.then((result) => {
setData(result);
setError(null);
})
.catch((e) => setError(e.toString()))
.finally(() => setIsLoading(false));
}, [messageId, originTxHash, data]);
useInterval(fetcher, retryInterval);
return {
data,
isLoading,
error,
};
}
async function fetchMessage(
explorerApiUrl: string,
messageId?: string,
originTxHash?: string,
): Promise<ApiMessage | null> {
if (!explorerApiUrl) throw new Error('Explorer API URL required');
if (!messageId && !originTxHash)
throw new Error('Either messageId or originTxHash required');
let url = `${explorerApiUrl}?module=message&action=get-messages`;
if (messageId) url += `&id=${messageId}`;
else if (originTxHash) url += `&origin-tx-hash=${originTxHash}`;
const result = await executeExplorerQuery<ApiMessage[]>(url, 5000);
if (result.length > 1) {
console.warn('More than one message received, should not occur');
return result[0];
} else if (result.length === 1) {
console.debug('Message data found, id:', result[0].id);
return result[0];
} else {
console.debug('Message data not found');
return null;
}
}

@ -0,0 +1,248 @@
import { useCallback, useState } from 'react';
import type { MultiProvider } from '@hyperlane-xyz/sdk';
import { HYPERLANE_EXPLORER_API_URL } from '../consts.js';
import { queryExplorerForBlock } from '../utils/explorers.js';
import { fetchWithTimeout } from '../utils/timeout.js';
import { useInterval } from '../utils/useInterval.js';
import {
MessageStatus,
PartialMessage,
MessageStage as Stage,
StageTimings,
} from './types.js';
const VALIDATION_TIME_EST = 5;
const DEFAULT_BLOCK_TIME_EST = 3;
const DEFAULT_FINALITY_BLOCKS = 3;
interface Params {
message: PartialMessage | null | undefined;
multiProvider: MultiProvider;
explorerApiUrl?: string;
retryInterval?: number;
}
const defaultTiming: StageTimings = {
[Stage.Finalized]: null,
[Stage.Validated]: null,
[Stage.Relayed]: null,
};
export function useMessageStage({
message,
multiProvider,
explorerApiUrl = HYPERLANE_EXPLORER_API_URL,
retryInterval = 2000,
}: Params) {
// Tempting to use react-query here as we did in Explorer but
// avoiding for now to keep dependencies for this lib minimal
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<{
stage: Stage;
timings: StageTimings;
} | null>(null);
const fetcher = useCallback(() => {
// Skip invalid or placeholder messages
if (!isValidMessage(message)) return;
// Don't re-run for failing messages
if (message.status === MessageStatus.Failing && data) return;
// Don't re-run for pending, validated messages
if (
message.status === MessageStatus.Pending &&
data?.stage === Stage.Validated
)
return;
setIsLoading(true);
fetchMessageState(message, multiProvider, explorerApiUrl)
.then((result) => {
setData(result);
setError(null);
})
.catch((e) => setError(e.toString()))
.finally(() => setIsLoading(false));
}, [message, data]);
useInterval(fetcher, retryInterval);
return {
stage: data?.stage
? data.stage
: isValidMessage(message)
? Stage.Sent
: Stage.Preparing,
timings: data?.timings ? data.timings : defaultTiming,
isLoading,
error,
};
}
async function fetchMessageState(
message: PartialMessage,
multiProvider: MultiProvider,
explorerApiUrl: string,
) {
const {
status,
nonce,
originDomainId,
destinationDomainId,
origin,
destination,
} = message;
const { blockNumber: originBlockNumber, timestamp: originTimestamp } = origin;
const destTimestamp = destination?.timestamp;
const relayEstimate = Math.floor(
(await getBlockTimeEst(destinationDomainId, multiProvider)) * 1.5,
);
const finalityBlocks = await getFinalityBlocks(originDomainId, multiProvider);
const finalityEstimate =
finalityBlocks * (await getBlockTimeEst(originDomainId, multiProvider));
if (status === MessageStatus.Delivered && destTimestamp) {
// For delivered messages, just to rough estimates for stages
// This saves us from making extra explorer calls. May want to revisit in future
const totalDuration = Math.round((destTimestamp - originTimestamp) / 1000);
const finalityDuration = Math.max(
Math.min(finalityEstimate, totalDuration - VALIDATION_TIME_EST),
1,
);
const remaining = totalDuration - finalityDuration;
const validateDuration = Math.max(
Math.min(Math.round(remaining * 0.25), VALIDATION_TIME_EST),
1,
);
const relayDuration = Math.max(remaining - validateDuration, 1);
return {
stage: Stage.Relayed,
timings: {
[Stage.Finalized]: finalityDuration,
[Stage.Validated]: validateDuration,
[Stage.Relayed]: relayDuration,
},
};
}
const latestNonce = await tryFetchLatestNonce(
originDomainId,
multiProvider,
explorerApiUrl,
);
if (latestNonce && latestNonce >= nonce) {
return {
stage: Stage.Validated,
timings: {
[Stage.Finalized]: finalityEstimate,
[Stage.Validated]: VALIDATION_TIME_EST,
[Stage.Relayed]: relayEstimate,
},
};
}
const latestBlock = await tryFetchChainLatestBlock(
originDomainId,
multiProvider,
);
const finalizedBlock = originBlockNumber + finalityBlocks;
if (latestBlock && parseInt(latestBlock.number.toString()) > finalizedBlock) {
return {
stage: Stage.Finalized,
timings: {
[Stage.Finalized]: finalityEstimate,
[Stage.Validated]: VALIDATION_TIME_EST,
[Stage.Relayed]: relayEstimate,
},
};
}
return {
stage: Stage.Sent,
timings: {
[Stage.Finalized]: finalityEstimate,
[Stage.Validated]: VALIDATION_TIME_EST,
[Stage.Relayed]: relayEstimate,
},
};
}
async function getFinalityBlocks(
domainId: number,
multiProvider: MultiProvider,
) {
const metadata = await multiProvider.getChainMetadata(domainId);
if (metadata?.blocks?.confirmations) return metadata.blocks.confirmations;
else return DEFAULT_FINALITY_BLOCKS;
}
async function getBlockTimeEst(domainId: number, multiProvider: MultiProvider) {
const metadata = await multiProvider.getChainMetadata(domainId);
return metadata?.blocks?.estimateBlockTime || DEFAULT_BLOCK_TIME_EST;
}
async function tryFetchChainLatestBlock(
domainId: number,
multiProvider: MultiProvider,
) {
const metadata = multiProvider.tryGetChainMetadata(domainId);
if (!metadata) return null;
console.debug(`Attempting to fetch latest block for:`, metadata.name);
try {
const block = await queryExplorerForBlock(
metadata.name,
multiProvider,
'latest',
);
return block;
} catch (error) {
console.error('Error fetching latest block', error);
return null;
}
}
async function tryFetchLatestNonce(
domainId: number,
multiProvider: MultiProvider,
explorerApiUrl: string,
) {
const metadata = multiProvider.tryGetChainMetadata(domainId);
if (!metadata) return null;
console.debug(`Attempting to fetch nonce for:`, metadata.name);
try {
const response = await fetchWithTimeout(
`${explorerApiUrl}/latest-nonce`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ chainId: metadata.chainId }),
},
3000,
);
const result = await response.json();
console.debug(`Found nonce:`, result.nonce);
return result.nonce;
} catch (error) {
console.error('Error fetching nonce', error);
return null;
}
}
function isValidMessage(
message: PartialMessage | undefined | null,
): message is PartialMessage {
return !!(
message &&
message.originChainId &&
message.destinationChainId &&
message.originDomainId &&
message.destinationDomainId
);
}

@ -0,0 +1,37 @@
import type { MultiProvider } from '@hyperlane-xyz/sdk';
import { useMessage } from './useMessage.js';
import { useMessageStage } from './useMessageStage.js';
interface Params {
messageId?: string;
multiProvider: MultiProvider;
originTxHash?: string;
explorerApiUrl?: string;
retryInterval?: number;
}
export function useMessageTimeline(params: Params) {
const {
data: message,
error: msgError,
isLoading: isMsgLoading,
} = useMessage(params);
const {
stage,
timings,
error: stageError,
isLoading: isStageLoading,
} = useMessageStage({
message,
multiProvider: params.multiProvider,
retryInterval: params.retryInterval,
});
return {
message,
stage,
timings,
error: msgError || stageError,
isLoading: isMsgLoading || isStageLoading,
};
}

@ -0,0 +1,58 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { GithubRegistry } from '@hyperlane-xyz/registry';
import { ChainLogo } from '../icons/ChainLogo.js';
export default {
title: 'ChainLogo',
component: ChainLogo,
} as ComponentMeta<typeof ChainLogo>;
const Template: ComponentStory<typeof ChainLogo> = (args) => (
<ChainLogo {...args} />
);
const registry = new GithubRegistry();
export const ChainNoBackground = Template.bind({});
ChainNoBackground.args = {
chainName: 'ethereum',
background: false,
registry,
};
export const ChainWithBackground = Template.bind({});
ChainWithBackground.args = {
chainName: 'ethereum',
background: true,
registry,
};
export const ChainWithBigSize = Template.bind({});
ChainWithBigSize.args = {
chainName: 'ethereum',
size: 100,
registry,
};
export const ChainWithBackgrounAndBig = Template.bind({});
ChainWithBackgrounAndBig.args = {
chainName: 'ethereum',
size: 100,
background: true,
registry,
};
export const JustChainName = Template.bind({});
JustChainName.args = {
chainName: 'ethereum',
registry,
};
export const FakeChainName = Template.bind({});
FakeChainName.args = {
chainName: 'myfakechain',
registry,
};

@ -0,0 +1,70 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { MessageTimeline } from '../messages/MessageTimeline.js';
import { MessageStage, MessageStatus } from '../messages/types.js';
export default {
title: 'MessageTimeline',
component: MessageTimeline,
} as ComponentMeta<typeof MessageTimeline>;
const Template: ComponentStory<typeof MessageTimeline> = (args) => (
<MessageTimeline {...args} />
);
const defaultTimings = {
[MessageStage.Finalized]: 10,
[MessageStage.Validated]: 5,
[MessageStage.Relayed]: 8,
};
const defaultTimeSent = Date.now() - 10_000;
export const TimelinePreparing = Template.bind({});
TimelinePreparing.args = {
status: MessageStatus.Pending,
stage: MessageStage.Preparing,
timings: {},
timestampSent: undefined,
};
export const TimelineOriginSent = Template.bind({});
TimelineOriginSent.args = {
status: MessageStatus.Pending,
stage: MessageStage.Sent,
timings: defaultTimings,
timestampSent: defaultTimeSent,
};
export const TimelineOriginFinalized = Template.bind({});
TimelineOriginFinalized.args = {
status: MessageStatus.Pending,
stage: MessageStage.Finalized,
timings: defaultTimings,
timestampSent: defaultTimeSent,
};
export const TimelineOriginValidated = Template.bind({});
TimelineOriginValidated.args = {
status: MessageStatus.Pending,
stage: MessageStage.Validated,
timings: defaultTimings,
timestampSent: defaultTimeSent,
};
export const TimelineOriginDelivered = Template.bind({});
TimelineOriginDelivered.args = {
status: MessageStatus.Delivered,
stage: MessageStage.Preparing,
timings: defaultTimings,
timestampSent: defaultTimeSent,
};
export const TimelineHideDesc = Template.bind({});
TimelineHideDesc.args = {
status: MessageStatus.Pending,
stage: MessageStage.Sent,
timings: defaultTimings,
timestampSent: defaultTimeSent,
hideDescriptions: true,
};

@ -0,0 +1,32 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { ColorPalette } from '../color.js';
import { WideChevron } from '../icons/WideChevron.js';
export default {
title: 'WideChevron',
component: WideChevron,
} as ComponentMeta<typeof WideChevron>;
const Template: ComponentStory<typeof WideChevron> = (args) => (
<WideChevron {...args} />
);
export const BlueEastRounded = Template.bind({});
BlueEastRounded.args = {
color: ColorPalette.Blue,
direction: 'e',
rounded: true,
width: 50,
height: 150,
};
export const BlackSouthUnrounded = Template.bind({});
BlackSouthUnrounded.args = {
color: ColorPalette.Black,
direction: 's',
rounded: false,
width: 50,
height: 150,
};

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@ -0,0 +1,6 @@
declare module '*.svg' {
const content: string;
export default content;
}
declare type Address = string;

@ -0,0 +1,84 @@
import type { MultiProvider } from '@hyperlane-xyz/sdk';
import { fetchWithTimeout } from './timeout.js';
export interface ExplorerQueryResponse<R> {
status: string;
message: string;
result: R;
}
export async function getExplorerApiUrl(
chainName: string,
multiProvider: MultiProvider,
) {
const metadata = await multiProvider.getChainMetadata(chainName);
const blockExplorers = metadata?.blockExplorers;
if (!blockExplorers?.length) return null;
return blockExplorers[0].apiUrl || blockExplorers[0].url;
}
export async function queryExplorer<P>(
chainName: string,
multiProvider: MultiProvider,
path: string,
apiKey?: string,
timeout?: number,
) {
const baseUrl = getExplorerApiUrl(chainName, multiProvider);
if (!baseUrl)
throw new Error(`No URL found for explorer for chain ${chainName}`);
let url = `${baseUrl}/${path}`;
console.debug('Querying explorer url:', url);
if (apiKey) {
url += `&apikey=${apiKey}`;
}
const result = await executeExplorerQuery<P>(url, timeout);
return result;
}
export async function executeExplorerQuery<P>(url: string, timeout?: number) {
const response = await fetchWithTimeout(url, undefined, timeout);
if (!response.ok) {
throw new Error(`Fetch response not okay: ${response.status}`);
}
const json = (await response.json()) as ExplorerQueryResponse<P>;
if (!json.result) {
const responseText = await response.text();
throw new Error(`Invalid result format: ${responseText}`);
}
return json.result;
}
interface PartialBlock {
hash: string;
number: number;
timestamp: number;
nonce: string;
}
export async function queryExplorerForBlock(
chainName: string,
multiProvider: MultiProvider,
blockNumber?: number | string,
) {
const path = `?module=proxy&action=eth_getBlockByNumber&tag=${
blockNumber || 'latest'
}&boolean=false`;
const block = await queryExplorer<PartialBlock>(
chainName,
multiProvider,
path,
);
if (!block?.number || parseInt(block.number.toString()) < 0) {
const msg = 'Invalid block result';
console.error(msg, JSON.stringify(block), path);
throw new Error(msg);
}
return block;
}

@ -0,0 +1,14 @@
export async function fetchWithTimeout(
resource: RequestInfo,
options?: RequestInit,
timeout = 10000,
) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const response = await fetch(resource, {
...options,
signal: controller.signal,
});
clearTimeout(id);
return response;
}

@ -0,0 +1,27 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
// https://usehooks-typescript.com/react-hook/use-interval
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// Remember the latest callback if it changes.
useIsomorphicLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
// Don't schedule if no delay is specified.
// Note: 0 is a valid value for delay.
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}

@ -0,0 +1,108 @@
/** @type {import('tailwindcss').Config} */
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
prefix: 'htw-',
theme: {
fontFamily: {
sans: ['Helvetica', 'Arial', 'sans-serif'],
serif: ['Garamond', 'serif'],
mono: ['Courier New', 'monospace'],
},
screens: {
xs: '480px',
...defaultTheme.screens,
},
extend: {
colors: {
black: '#010101',
white: '#ffffff',
blue: {
50: '#E6EDF9',
100: '#CDDCF4',
200: '#A7C2EC',
300: '#82A8E4',
400: '#5385D2',
500: '#2362C0',
600: '#1D4685',
700: '#162A4A',
800: '#11213B',
900: '#0D192C',
},
beige: {
100: '#F6F4F1',
200: '#F5F2EF',
300: '#F3F0ED',
400: '#F2EEEB',
500: '#F1EDE9',
600: '#D8D5D1',
700: '#C0BDBA',
800: '#A8A5A3',
900: '#908E8B',
},
red: {
100: '#EBBAB8',
200: '#DF8D8A',
300: '#D25F5B',
400: '#C5312C',
500: '#BF1B15',
600: '#AB1812',
700: '#85120E',
800: '#5F0D0A',
900: '#390806',
},
green: {
50: '#D3E3DB',
100: '#BED5C9',
200: '#93BAA6',
300: '#679F82',
400: '#3C835E',
500: '#27764d',
600: '#236A45',
700: '#1F5E3D',
800: '#17462E',
900: '#0F2F1E',
},
pink: {
50: '#FAEAF7',
100: '#F0C0E8',
200: '#EBABE0',
300: '#E282D1',
400: '#D858C2',
500: '#CF2FB3',
600: '#BA2AA1',
700: '#A5258F',
800: '#90207D',
900: '#7C1C6B',
}
},
fontSize: {
md: '0.95rem',
},
spacing: {
88: '22rem',
100: '26rem',
112: '28rem',
128: '32rem',
144: '36rem',
},
borderRadius: {
none: '0',
sm: '0.2rem',
DEFAULT: '0.3rem',
md: '0.4rem',
lg: '0.5rem',
full: '9999px',
},
blur: {
xs: '3px',
},
animation: {
'pulse-slow': 'pulse 3s infinite cubic-bezier(.4,0,.6,1)',
}
},
},
plugins: [],
};

@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"jsx": "react",
"noImplicitAny": false,
"rootDir": "./src",
"outDir": "./dist",
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "dist", "**/*.stories.tsx"],
}

10525
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save