Merge branch 'main' into marvin/public_keys

This commit is contained in:
jonesmarvin8 2026-02-10 20:31:02 -05:00
commit 7c930415a6
32 changed files with 3725 additions and 54 deletions

1171
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ members = [
"indexer_service",
"indexer_service/protocol",
"indexer_service/rpc",
"explorer_service",
"programs/token/core",
"programs/token",
"program_methods",
@ -123,3 +124,10 @@ actix-web = { version = "=4.1.0", default-features = false, features = [
] }
clap = { version = "4.5.42", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
# Profile for leptos WASM release builds
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1

11
explorer_service/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Leptos build outputs
/target
/pkg
/site
# WASM artifacts
*.wasm
# Environment
.env
.env.local

View File

@ -0,0 +1,73 @@
[package]
name = "explorer_service"
version = "0.1.0"
edition = "2024"
license.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
indexer_service_protocol.workspace = true
# Leptos framework
leptos = "0.8.15"
leptos_meta = "0.8.5"
leptos_router = "0.8.11"
# Serialization
serde.workspace = true
# Logging
log.workspace = true
console_error_panic_hook = "0.1"
console_log = "1.0"
# Date/Time
chrono.workspace = true
# Hex encoding/decoding
hex.workspace = true
# URL encoding
urlencoding = "2.1"
# WASM-specific
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = [
"Window",
"Document",
"Location",
"HtmlInputElement",
] }
# Server-side dependencies (optional, enabled by features)
indexer_service_rpc = { workspace = true, features = [
"client",
], optional = true }
jsonrpsee = { workspace = true, features = ["http-client"], optional = true }
tokio = { workspace = true, optional = true }
axum = { version = "0.8.8", optional = true }
leptos_axum = { version = "0.8.7", optional = true }
clap = { workspace = true, features = ["derive"], optional = true }
url = { workspace = true, optional = true }
env_logger = { workspace = true, optional = true }
[features]
hydrate = ["leptos/hydrate"]
ssr = [
"leptos/ssr",
"dep:indexer_service_rpc",
"dep:jsonrpsee",
"dep:tokio",
"dep:axum",
"dep:leptos_axum",
"dep:clap",
"dep:url",
"dep:env_logger",
]
[package.metadata.leptos]
bin-features = ["ssr"]
lib-features = ["hydrate"]
assets-dir = "public"

View File

@ -0,0 +1,52 @@
FROM rust:1.91.1-trixie AS builder
# Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN cp cargo-binstall /usr/local/cargo/bin
# Install required tools
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends clang
# Install cargo-leptos
RUN cargo binstall cargo-leptos -y
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown
# Make an /explorer_service dir, which everything will eventually live in
RUN mkdir -p /explorer_service
WORKDIR /explorer_service
COPY . .
# Build the app
RUN cargo leptos build --release -vv
FROM debian:trixie-slim AS runtime
WORKDIR /explorer_service
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
# Copy the server binary to the /explorer_service directory
COPY --from=builder /explorer_service/target/release/explorer_service /explorer_service/
# /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /explorer_service/target/site /explorer_service/site
# Copy Cargo.toml as its needed at runtime
COPY --from=builder /explorer_service/Cargo.toml /explorer_service/
# Set any required env variables
ENV RUST_LOG="info"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT="site"
ENV INDEXER_RPC_URL="http://localhost:8779"
EXPOSE 8080
# Run the server
CMD ["/explorer_service/explorer_service"]

View File

@ -0,0 +1,71 @@
# LEE Blockchain Explorer
A web-based UI for exploring the blockchain state, built with Rust and Leptos framework.
## Features
- **Main Page**: Search for blocks, transactions, or accounts by hash/ID. View recent blocks.
- **Block Page**: View detailed block information and all transactions within a block.
- **Transaction Page**: View transaction details including type, accounts involved, and proofs.
- **Account Page**: View account state and transaction history.
## Architecture
- **Framework**: Leptos 0.8 with SSR (Server-Side Rendering) and hydration
- **Data Source**: Indexer Service JSON-RPC API
- **Components**: Reusable BlockPreview, TransactionPreview, and AccountPreview components
- **Styling**: Custom CSS with responsive design
## Development
### Prerequisites
- Rust (stable or nightly)
- `cargo-leptos` tool: `cargo install cargo-leptos`
- Running indexer service at `http://localhost:8080/rpc` (or configure via `INDEXER_RPC_URL`)
### Build and Run
```bash
# Development mode (with hot-reload)
cargo leptos watch
# Production build
cargo leptos build --release
# Run production build
cargo leptos serve --release
```
The explorer will be available at `http://localhost:3000` by default.
### Configuration
Set the `INDEXER_RPC_URL` environment variable to point to your indexer service:
```bash
export INDEXER_RPC_URL=http://localhost:8080/rpc
cargo leptos watch
```
## Features
### Search
The search bar supports:
- Block IDs (numeric)
- Block hashes (64-character hex)
- Transaction hashes (64-character hex)
- Account IDs (64-character hex)
### Real-time Updates
The main page loads recent blocks and can be extended to subscribe to new blocks via WebSocket.
### Responsive Design
The UI is mobile-friendly and adapts to different screen sizes.
## License
See LICENSE file in the repository root.

View File

@ -0,0 +1,11 @@
services:
explorer_service:
image: lssa/explorer_service
build:
context: ..
dockerfile: explorer_service/Dockerfile
container_name: explorer_service
environment:
INDEXER_RPC_URL: ${INDEXER_RPC_URL:-http://localhost:8779}
ports:
- "8080:8080"

View File

@ -0,0 +1,516 @@
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f7fa;
color: #2c3e50;
line-height: 1.6;
}
/* App layout */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
background-color: #2c3e50;
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-nav {
max-width: 1200px;
margin: 0 auto;
}
.nav-logo {
color: white;
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.nav-logo:hover {
color: #3498db;
}
.app-main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2rem;
}
.app-footer {
background-color: #34495e;
color: white;
text-align: center;
padding: 1.5rem;
margin-top: 2rem;
}
/* Page headers */
.page-header h1 {
font-size: 2rem;
margin-bottom: 1.5rem;
color: #2c3e50;
}
/* Search section */
.search-section {
margin-bottom: 3rem;
}
.search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #dde4ed;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #3498db;
}
.search-button {
padding: 0.75rem 2rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s;
}
.search-button:hover {
background-color: #2980b9;
}
/* Block preview */
.block-preview {
background-color: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.block-preview:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.block-preview-link {
text-decoration: none;
color: inherit;
}
.block-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.block-id .label {
color: #7f8c8d;
font-size: 0.9rem;
}
.block-id .value {
font-size: 1.5rem;
font-weight: bold;
color: #2c3e50;
}
.block-status {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.status-pending {
background-color: #fff3cd;
color: #856404;
}
.status-safe {
background-color: #d1ecf1;
color: #0c5460;
}
.status-finalized {
background-color: #d4edda;
color: #155724;
}
.block-preview-body {
display: grid;
gap: 0.5rem;
}
.block-field {
display: flex;
gap: 0.5rem;
}
.field-label {
color: #7f8c8d;
font-weight: 500;
}
.field-value {
color: #2c3e50;
}
.hash {
font-family: "Courier New", monospace;
font-size: 0.9rem;
word-break: break-all;
}
/* Transaction preview */
.transaction-preview {
background-color: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.transaction-preview:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.transaction-preview-link {
text-decoration: none;
color: inherit;
}
.transaction-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
}
.tx-type {
padding: 0.4rem 0.8rem;
border-radius: 16px;
font-size: 0.85rem;
font-weight: 600;
border: 2px solid;
}
.tx-type-public {
background-color: #e3f2fd;
color: #0d47a1;
border-color: #1976d2;
border-style: solid;
}
.tx-type-private {
background-color: #ffe0f0;
color: #880e4f;
border-color: #c2185b;
border-style: dashed;
font-style: italic;
}
.tx-type-deployment {
background-color: #fff3e0;
color: #e65100;
border-color: #ff9800;
border-style: dotted;
}
.tx-hash {
display: flex;
gap: 0.5rem;
align-items: center;
}
.transaction-preview-body {
color: #7f8c8d;
font-size: 0.9rem;
}
/* Account preview */
.account-preview {
background-color: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.account-preview:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.account-preview-link {
text-decoration: none;
color: inherit;
}
.account-preview-header {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.account-id .label {
color: #7f8c8d;
font-size: 0.9rem;
}
.account-id .value {
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.account-preview-body {
display: grid;
gap: 0.5rem;
}
.account-field {
display: flex;
gap: 0.5rem;
}
.account-not-found {
color: #e74c3c;
font-style: italic;
}
/* Detail pages */
.block-detail,
.transaction-detail,
.account-detail {
background-color: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.block-info,
.transaction-info,
.account-info,
.transaction-details {
margin-bottom: 2rem;
}
.block-info h2,
.transaction-info h2,
.account-info h2,
.transaction-details h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #2c3e50;
}
.info-grid {
display: grid;
gap: 1rem;
}
.info-row {
display: flex;
gap: 1rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.info-label {
color: #7f8c8d;
font-weight: 600;
min-width: 150px;
}
.info-value {
color: #2c3e50;
word-break: break-all;
}
.signature {
font-size: 0.75rem;
}
/* Transactions list */
.block-transactions,
.account-transactions {
margin-top: 2rem;
}
.block-transactions h2,
.account-transactions h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #2c3e50;
}
.transactions-list {
display: grid;
gap: 1rem;
}
.no-transactions {
padding: 2rem;
text-align: center;
color: #7f8c8d;
background-color: #f8f9fa;
border-radius: 8px;
}
/* Accounts list */
.accounts-list {
display: grid;
gap: 0.5rem;
margin-top: 1rem;
}
.account-item {
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.account-item a {
color: #3498db;
text-decoration: none;
}
.account-item a:hover {
text-decoration: underline;
}
.nonce {
color: #7f8c8d;
font-size: 0.9rem;
margin-left: 0.5rem;
}
/* Loading and error states */
.loading,
.loading-more {
text-align: center;
padding: 2rem;
color: #7f8c8d;
font-style: italic;
}
.error,
.error-page {
background-color: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.not-found,
.not-found-page {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
.not-found-page h1 {
font-size: 4rem;
color: #e74c3c;
margin-bottom: 1rem;
}
.not-found-page a {
color: #3498db;
text-decoration: none;
font-weight: 600;
}
.not-found-page a:hover {
text-decoration: underline;
}
/* Load more button */
.load-more-button {
display: block;
width: 100%;
padding: 1rem;
margin-top: 1rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s;
}
.load-more-button:hover {
background-color: #2980b9;
}
/* Responsive design */
@media (max-width: 768px) {
.app-main {
padding: 1rem;
}
.search-form {
flex-direction: column;
}
.search-button {
width: 100%;
}
.block-preview-header,
.transaction-preview-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.info-row {
flex-direction: column;
gap: 0.25rem;
}
.info-label {
min-width: auto;
}
}

158
explorer_service/src/api.rs Normal file
View File

@ -0,0 +1,158 @@
use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction};
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
/// Search results structure
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SearchResults {
pub blocks: Vec<Block>,
pub transactions: Vec<Transaction>,
pub accounts: Vec<(AccountId, Option<Account>)>,
}
/// RPC client type
#[cfg(feature = "ssr")]
pub type IndexerRpcClient = jsonrpsee::http_client::HttpClient;
/// Get account information by ID
#[server]
pub async fn get_account(account_id: AccountId) -> Result<Account, ServerFnError> {
use indexer_service_rpc::RpcClient as _;
let client = expect_context::<IndexerRpcClient>();
client
.get_account(account_id)
.await
.map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e)))
}
/// Parse hex string to bytes
#[cfg(feature = "ssr")]
fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x");
hex::decode(s).ok()
}
/// Search for a block, transaction, or account by query string
#[server]
pub async fn search(query: String) -> Result<SearchResults, ServerFnError> {
use indexer_service_rpc::RpcClient as _;
let client = expect_context::<IndexerRpcClient>();
let mut blocks = Vec::new();
let mut transactions = Vec::new();
let mut accounts = Vec::new();
// Try to parse as hash (32 bytes)
if let Some(bytes) = parse_hex(&query)
&& let Ok(hash_array) = <[u8; 32]>::try_from(bytes)
{
let hash = Hash(hash_array);
// Try as block hash
if let Ok(block) = client.get_block_by_hash(hash).await {
blocks.push(block);
}
// Try as transaction hash
if let Ok(tx) = client.get_transaction(hash).await {
transactions.push(tx);
}
// Try as account ID
let account_id = AccountId { value: hash_array };
match client.get_account(account_id).await {
Ok(account) => {
accounts.push((account_id, Some(account)));
}
Err(_) => {
// Account might not exist yet, still add it to results
accounts.push((account_id, None));
}
}
}
// Try as block ID
if let Ok(block_id) = query.parse::<u64>()
&& let Ok(block) = client.get_block_by_id(block_id).await
{
blocks.push(block);
}
Ok(SearchResults {
blocks,
transactions,
accounts,
})
}
/// Get block by ID
#[server]
pub async fn get_block_by_id(block_id: BlockId) -> Result<Block, ServerFnError> {
use indexer_service_rpc::RpcClient as _;
let client = expect_context::<IndexerRpcClient>();
client
.get_block_by_id(block_id)
.await
.map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e)))
}
/// Get block by hash
#[server]
pub async fn get_block_by_hash(block_hash: Hash) -> Result<Block, ServerFnError> {
use indexer_service_rpc::RpcClient as _;
let client = expect_context::<IndexerRpcClient>();
client
.get_block_by_hash(block_hash)
.await
.map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e)))
}
/// Get transaction by hash
#[server]
pub async fn get_transaction(tx_hash: Hash) -> Result<Transaction, ServerFnError> {
use indexer_service_rpc::RpcClient as _;
let client = expect_context::<IndexerRpcClient>();
client
.get_transaction(tx_hash)
.await
.map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e)))
}
/// Get blocks with pagination
#[server]
pub async fn get_blocks(offset: u32, limit: u32) -> Result<Vec<Block>, ServerFnError> {
use indexer_service_rpc::RpcClient as _;
let client = expect_context::<IndexerRpcClient>();
client
.get_blocks(offset, limit)
.await
.map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e)))
}
/// Get transactions by account
#[server]
pub async fn get_transactions_by_account(
account_id: AccountId,
limit: u32,
offset: u32,
) -> Result<Vec<Transaction>, ServerFnError> {
use indexer_service_rpc::RpcClient as _;
let client = expect_context::<IndexerRpcClient>();
client
.get_transactions_by_account(account_id, limit, offset)
.await
.map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e)))
}
/// Create the RPC client for the indexer service (server-side only)
#[cfg(feature = "ssr")]
pub fn create_indexer_rpc_client(url: &url::Url) -> Result<IndexerRpcClient, String> {
use jsonrpsee::http_client::HttpClientBuilder;
use log::info;
info!("Connecting to Indexer RPC on URL: {url}");
HttpClientBuilder::default()
.build(url.as_str())
.map_err(|e| format!("Failed to create RPC client: {e}"))
}

View File

@ -0,0 +1,63 @@
use indexer_service_protocol::{Account, AccountId};
use leptos::prelude::*;
use leptos_router::components::A;
use crate::format_utils;
/// Account preview component
#[component]
pub fn AccountPreview(account_id: AccountId, account: Option<Account>) -> impl IntoView {
let account_id_str = format_utils::format_account_id(&account_id);
view! {
<div class="account-preview">
<A href=format!("/account/{}", account_id_str) attr:class="account-preview-link">
<div class="account-preview-header">
<div class="account-id">
<span class="label">"Account "</span>
<span class="value hash">{account_id_str.clone()}</span>
</div>
</div>
{move || {
account
.as_ref()
.map(|Account { program_owner, balance, data, nonce }| {
let program_id = format_utils::format_program_id(program_owner);
view! {
<div class="account-preview-body">
<div class="account-field">
<span class="field-label">"Balance: "</span>
<span class="field-value">{balance.to_string()}</span>
</div>
<div class="account-field">
<span class="field-label">"Program: "</span>
<span class="field-value hash">{program_id}</span>
</div>
<div class="account-field">
<span class="field-label">"Nonce: "</span>
<span class="field-value">{nonce.to_string()}</span>
</div>
<div class="account-field">
<span class="field-label">"Data: "</span>
<span class="field-value">
{format!("{} bytes", data.0.len())}
</span>
</div>
</div>
}
.into_any()
})
.unwrap_or_else(|| {
view! {
<div class="account-preview-body">
<div class="account-not-found">"Account not found"</div>
</div>
}
.into_any()
})
}}
</A>
</div>
}
}

View File

@ -0,0 +1,77 @@
use indexer_service_protocol::{BedrockStatus, Block, BlockBody, BlockHeader};
use leptos::prelude::*;
use leptos_router::components::A;
use crate::format_utils;
/// Get CSS class for bedrock status
fn status_class(status: &BedrockStatus) -> &'static str {
match status {
BedrockStatus::Pending => "status-pending",
BedrockStatus::Safe => "status-safe",
BedrockStatus::Finalized => "status-finalized",
}
}
/// Block preview component
#[component]
pub fn BlockPreview(block: Block) -> impl IntoView {
let Block {
header:
BlockHeader {
block_id,
prev_block_hash,
hash,
timestamp,
signature: _,
},
body: BlockBody { transactions },
bedrock_status,
bedrock_parent_id: _,
} = block;
let tx_count = transactions.len();
let hash_str = hex::encode(hash.0);
let prev_hash_str = hex::encode(prev_block_hash.0);
let time_str = format_utils::format_timestamp(timestamp);
let status_str = match &bedrock_status {
BedrockStatus::Pending => "Pending",
BedrockStatus::Safe => "Safe",
BedrockStatus::Finalized => "Finalized",
};
view! {
<div class="block-preview">
<A href=format!("/block/{}", block_id) attr:class="block-preview-link">
<div class="block-preview-header">
<div class="block-id">
<span class="label">"Block "</span>
<span class="value">{block_id}</span>
</div>
<div class=format!("block-status {}", status_class(&bedrock_status))>
{status_str}
</div>
</div>
<div class="block-preview-body">
<div class="block-field">
<span class="field-label">"Hash: "</span>
<span class="field-value hash">{hash_str}</span>
</div>
<div class="block-field">
<span class="field-label">"Previous: "</span>
<span class="field-value hash">{prev_hash_str}</span>
</div>
<div class="block-field">
<span class="field-label">"Timestamp: "</span>
<span class="field-value">{time_str}</span>
</div>
<div class="block-field">
<span class="field-label">"Transactions: "</span>
<span class="field-value">{tx_count}</span>
</div>
</div>
</A>
</div>
}
}

View File

@ -0,0 +1,7 @@
pub mod account_preview;
pub mod block_preview;
pub mod transaction_preview;
pub use account_preview::AccountPreview;
pub use block_preview::BlockPreview;
pub use transaction_preview::TransactionPreview;

View File

@ -0,0 +1,72 @@
use indexer_service_protocol::Transaction;
use leptos::prelude::*;
use leptos_router::components::A;
/// Get transaction type name and CSS class
fn transaction_type_info(tx: &Transaction) -> (&'static str, &'static str) {
match tx {
Transaction::Public(_) => ("Public", "tx-type-public"),
Transaction::PrivacyPreserving(_) => ("Privacy-Preserving", "tx-type-private"),
Transaction::ProgramDeployment(_) => ("Program Deployment", "tx-type-deployment"),
}
}
/// Transaction preview component
#[component]
pub fn TransactionPreview(transaction: Transaction) -> impl IntoView {
let hash = transaction.hash();
let hash_str = hex::encode(hash.0);
let (type_name, type_class) = transaction_type_info(&transaction);
// Get additional metadata based on transaction type
let metadata = match &transaction {
Transaction::Public(tx) => {
let indexer_service_protocol::PublicTransaction {
hash: _,
message,
witness_set: _,
} = tx;
format!("{} accounts involved", message.account_ids.len())
}
Transaction::PrivacyPreserving(tx) => {
let indexer_service_protocol::PrivacyPreservingTransaction {
hash: _,
message,
witness_set: _,
} = tx;
format!(
"{} public accounts, {} commitments",
message.public_account_ids.len(),
message.new_commitments.len()
)
}
Transaction::ProgramDeployment(tx) => {
let indexer_service_protocol::ProgramDeploymentTransaction { hash: _, message } = tx;
format!("{} bytes", message.bytecode.len())
}
};
view! {
<div class="transaction-preview">
<A href=format!("/transaction/{}", hash_str) attr:class="transaction-preview-link">
<div class="transaction-preview-header">
<div class="tx-id">
<span class="label">"Transaction"</span>
</div>
<div class=format!("tx-type {}", type_class)>
{type_name}
</div>
</div>
<div class="transaction-preview-body">
<div class="tx-hash">
<span class="field-label">"Hash: "</span>
<span class="field-value hash">{hash_str}</span>
</div>
<div class="tx-metadata">
{metadata}
</div>
</div>
</A>
</div>
}
}

View File

@ -0,0 +1,33 @@
//! Formatting utilities for the explorer
use indexer_service_protocol::{AccountId, ProgramId};
/// Format timestamp to human-readable string
pub fn format_timestamp(timestamp: u64) -> String {
let seconds = timestamp / 1000;
let datetime = chrono::DateTime::from_timestamp(seconds as i64, 0)
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
/// Format hash (32 bytes) to hex string
pub fn format_hash(hash: &[u8; 32]) -> String {
hex::encode(hash)
}
/// Format account ID to hex string
pub fn format_account_id(account_id: &AccountId) -> String {
hex::encode(account_id.value)
}
/// Format program ID to hex string
pub fn format_program_id(program_id: &ProgramId) -> String {
let bytes: Vec<u8> = program_id.iter().flat_map(|n| n.to_be_bytes()).collect();
hex::encode(bytes)
}
/// Parse hex string to bytes
pub fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x");
hex::decode(s).ok()
}

102
explorer_service/src/lib.rs Normal file
View File

@ -0,0 +1,102 @@
use leptos::prelude::*;
use leptos_meta::{Meta, Stylesheet, Title, provide_meta_context};
use leptos_router::{
ParamSegment, StaticSegment,
components::{Route, Router, Routes},
};
use pages::{AccountPage, BlockPage, MainPage, TransactionPage};
pub mod api;
mod components;
mod format_utils;
mod pages;
/// Main application component with routing setup.
///
/// # Routes
///
/// - `/` - Main page with search and recent blocks
/// - `/block/:id` - Block detail page (`:id` is the numeric block ID)
/// - `/transaction/:hash` - Transaction detail page (`:hash` is the hex-encoded transaction hash)
/// - `/account/:id` - Account detail page (`:id` is the hex-encoded account ID)
///
/// All other routes will show a 404 Not Found page.
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/explorer.css" />
<Title text="LEE Blockchain Explorer" />
<Meta name="description" content="Explore the blockchain - view blocks, transactions, and accounts" />
<Router>
<div class="app">
<header class="app-header">
<nav class="app-nav">
<a href="/" class="nav-logo">
"LEE Blockchain Explorer"
</a>
</nav>
</header>
<main class="app-main">
// Route definitions:
// - MainPage: Home with search and recent blocks
// - BlockPage: Detailed block view with all transactions
// - TransactionPage: Detailed transaction view
// - AccountPage: Account state and transaction history
<Routes fallback=|| view! { <NotFound /> }>
// Main page - search and recent blocks
<Route path=StaticSegment("") view=MainPage />
// Block detail page - /block/123
<Route path=(StaticSegment("block"), ParamSegment("id")) view=BlockPage />
// Transaction detail page - /transaction/0abc123...
<Route
path=(StaticSegment("transaction"), ParamSegment("hash"))
view=TransactionPage
/>
// Account detail page - /account/0def456...
<Route
path=(StaticSegment("account"), ParamSegment("id"))
view=AccountPage
/>
</Routes>
</main>
<footer class="app-footer">
<p>"LEE Blockchain Explorer © 2026"</p>
</footer>
</div>
</Router>
}
}
/// 404 Not Found page component.
///
/// Displayed when a user navigates to a route that doesn't exist.
#[component]
fn NotFound() -> impl IntoView {
view! {
<div class="not-found-page">
<h1>"404"</h1>
<p>"Page not found"</p>
<a href="/">"Go back to home"</a>
</div>
}
}
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use leptos::mount::hydrate_body;
console_error_panic_hook::set_once();
console_log::init_with_level(log::Level::Debug).expect("error initializing logger");
hydrate_body(App);
}

View File

@ -0,0 +1,79 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use clap::Parser;
use explorer_service::App;
use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list};
use leptos_meta::MetaTags;
env_logger::init();
/// LEE Blockchain Explorer Server CLI arguments.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Indexer RPC URL
#[arg(long, env = "INDEXER_RPC_URL", default_value = "http://localhost:8779")]
indexer_rpc_url: url::Url,
}
let args = Args::parse();
let conf = get_configuration(None).unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
// Create RPC client once
let rpc_client = explorer_service::api::create_indexer_rpc_client(&args.indexer_rpc_url)
.expect("Failed to create RPC client");
// Build our application with routes
let app = Router::new()
.leptos_routes_with_context(
&leptos_options,
routes,
{
let rpc_client = rpc_client.clone();
move || provide_context(rpc_client.clone())
},
{
let leptos_options = leptos_options.clone();
move || {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=leptos_options.clone() />
<HydrationScripts options=leptos_options.clone() />
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}
},
)
.fallback(leptos_axum::file_and_error_handler(|_| {
view! { "Page not found" }
}))
.with_state(leptos_options);
// Run the server
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
println!("Listening on http://{}", &addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
fn main() {
// Client-only main - no-op since hydration is done via wasm_bindgen
}

View File

@ -0,0 +1,229 @@
use indexer_service_protocol::{Account, AccountId};
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::{api, components::TransactionPreview, format_utils};
/// Account page component
#[component]
pub fn AccountPage() -> impl IntoView {
let params = use_params_map();
let (tx_offset, set_tx_offset) = signal(0u32);
let (all_transactions, set_all_transactions) = signal(Vec::new());
let (is_loading, set_is_loading) = signal(false);
let (has_more, set_has_more) = signal(true);
let tx_limit = 10u32;
// Parse account ID from URL params
let account_id = move || {
let account_id_str = params.read().get("id").unwrap_or_default();
format_utils::parse_hex(&account_id_str).and_then(|bytes| {
if bytes.len() == 32 {
let account_id_array: [u8; 32] = bytes.try_into().ok()?;
Some(AccountId {
value: account_id_array,
})
} else {
None
}
})
};
// Load account data
let account_resource = Resource::new(account_id, |acc_id_opt| async move {
match acc_id_opt {
Some(acc_id) => api::get_account(acc_id).await,
None => Err(leptos::prelude::ServerFnError::ServerError(
"Invalid account ID".to_string(),
)),
}
});
// Load initial transactions
let transactions_resource = Resource::new(account_id, move |acc_id_opt| async move {
match acc_id_opt {
Some(acc_id) => api::get_transactions_by_account(acc_id, tx_limit, 0).await,
None => Err(leptos::prelude::ServerFnError::ServerError(
"Invalid account ID".to_string(),
)),
}
});
// Update all_transactions when initial load completes
Effect::new(move || {
if let Some(Ok(txs)) = transactions_resource.get() {
set_all_transactions.set(txs.clone());
set_has_more.set(txs.len() as u32 == tx_limit);
}
});
// Load more transactions handler
let load_more = move |_| {
let Some(acc_id) = account_id() else {
return;
};
set_is_loading.set(true);
let current_offset = tx_offset.get() + tx_limit;
set_tx_offset.set(current_offset);
leptos::task::spawn_local(async move {
match api::get_transactions_by_account(acc_id, tx_limit, current_offset).await {
Ok(new_txs) => {
let txs_count = new_txs.len() as u32;
set_all_transactions.update(|txs| txs.extend(new_txs));
set_has_more.set(txs_count == tx_limit);
}
Err(e) => {
log::error!("Failed to load more transactions: {}", e);
}
}
set_is_loading.set(false);
});
};
view! {
<div class="account-page">
<Suspense fallback=move || view! { <div class="loading">"Loading account..."</div> }>
{move || {
account_resource
.get()
.map(|result| match result {
Ok(acc) => {
let Account {
program_owner,
balance,
data,
nonce,
} = acc;
let acc_id = account_id().expect("Account ID should be set");
let account_id_str = format_utils::format_account_id(&acc_id);
let program_id = format_utils::format_program_id(&program_owner);
let balance_str = balance.to_string();
let nonce_str = nonce.to_string();
let data_len = data.0.len();
view! {
<div class="account-detail">
<div class="page-header">
<h1>"Account"</h1>
</div>
<div class="account-info">
<h2>"Account Information"</h2>
<div class="info-grid">
<div class="info-row">
<span class="info-label">"Account ID:"</span>
<span class="info-value hash">{account_id_str}</span>
</div>
<div class="info-row">
<span class="info-label">"Balance:"</span>
<span class="info-value">{balance_str}</span>
</div>
<div class="info-row">
<span class="info-label">"Program Owner:"</span>
<span class="info-value hash">{program_id}</span>
</div>
<div class="info-row">
<span class="info-label">"Nonce:"</span>
<span class="info-value">{nonce_str}</span>
</div>
<div class="info-row">
<span class="info-label">"Data:"</span>
<span class="info-value">{format!("{} bytes", data_len)}</span>
</div>
</div>
</div>
<div class="account-transactions">
<h2>"Transactions"</h2>
<Suspense fallback=move || {
view! { <div class="loading">"Loading transactions..."</div> }
}>
{move || {
transactions_resource
.get()
.map(|result| match result {
Ok(_) => {
let txs = all_transactions.get();
if txs.is_empty() {
view! {
<div class="no-transactions">
"No transactions found"
</div>
}
.into_any()
} else {
view! {
<div>
<div class="transactions-list">
{txs
.into_iter()
.map(|tx| {
view! { <TransactionPreview transaction=tx /> }
})
.collect::<Vec<_>>()}
</div>
{move || {
if has_more.get() {
view! {
<button
class="load-more-button"
on:click=load_more
disabled=move || is_loading.get()
>
{move || {
if is_loading.get() {
"Loading..."
} else {
"Load More"
}
}}
</button>
}
.into_any()
} else {
().into_any()
}
}}
</div>
}
.into_any()
}
}
Err(e) => {
view! {
<div class="error">
{format!("Failed to load transactions: {}", e)}
</div>
}
.into_any()
}
})
}}
</Suspense>
</div>
</div>
}
.into_any()
}
Err(e) => {
view! {
<div class="error-page">
<h1>"Error"</h1>
<p>{format!("Failed to load account: {}", e)}</p>
</div>
}
.into_any()
}
})
}}
</Suspense>
</div>
}
}

View File

@ -0,0 +1,159 @@
use indexer_service_protocol::{BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Hash};
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::{api, components::TransactionPreview, format_utils};
#[derive(Clone, PartialEq, Eq)]
enum BlockIdOrHash {
BlockId(BlockId),
Hash(Hash),
}
/// Block page component
#[component]
pub fn BlockPage() -> impl IntoView {
let params = use_params_map();
let block_resource = Resource::new(
move || {
let id_str = params.read().get("id").unwrap_or_default();
// Try to parse as block ID (number)
if let Ok(block_id) = id_str.parse::<BlockId>() {
return Some(BlockIdOrHash::BlockId(block_id));
}
// Try to parse as block hash (hex string)
let id_str = id_str.trim().trim_start_matches("0x");
if let Some(bytes) = format_utils::parse_hex(id_str)
&& let Ok(hash_array) = <[u8; 32]>::try_from(bytes)
{
return Some(BlockIdOrHash::Hash(Hash(hash_array)));
}
None
},
|block_id_or_hash| async move {
match block_id_or_hash {
Some(BlockIdOrHash::BlockId(id)) => api::get_block_by_id(id).await,
Some(BlockIdOrHash::Hash(hash)) => api::get_block_by_hash(hash).await,
None => Err(leptos::prelude::ServerFnError::ServerError(
"Invalid block ID or hash".to_string(),
)),
}
},
);
view! {
<div class="block-page">
<Suspense fallback=move || view! { <div class="loading">"Loading block..."</div> }>
{move || {
block_resource
.get()
.map(|result| match result {
Ok(blk) => {
let Block {
header: BlockHeader {
block_id,
prev_block_hash,
hash,
timestamp,
signature,
},
body: BlockBody {
transactions,
},
bedrock_status,
bedrock_parent_id: _,
} = blk;
let hash_str = format_utils::format_hash(&hash.0);
let prev_hash = format_utils::format_hash(&prev_block_hash.0);
let timestamp_str = format_utils::format_timestamp(timestamp);
let signature_str = hex::encode(signature.0);
let status = match &bedrock_status {
BedrockStatus::Pending => "Pending",
BedrockStatus::Safe => "Safe",
BedrockStatus::Finalized => "Finalized",
};
view! {
<div class="block-detail">
<div class="page-header">
<h1>"Block " {block_id.to_string()}</h1>
</div>
<div class="block-info">
<h2>"Block Information"</h2>
<div class="info-grid">
<div class="info-row">
<span class="info-label">"Block ID: "</span>
<span class="info-value">{block_id.to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Hash: "</span>
<span class="info-value hash">{hash_str}</span>
</div>
<div class="info-row">
<span class="info-label">"Previous Block Hash: "</span>
<A href=format!("/block/{}", prev_hash) attr:class="info-value hash">
{prev_hash}
</A>
</div>
<div class="info-row">
<span class="info-label">"Timestamp: "</span>
<span class="info-value">{timestamp_str}</span>
</div>
<div class="info-row">
<span class="info-label">"Status: "</span>
<span class="info-value">{status}</span>
</div>
<div class="info-row">
<span class="info-label">"Signature: "</span>
<span class="info-value hash signature">{signature_str}</span>
</div>
<div class="info-row">
<span class="info-label">"Transaction Count: "</span>
<span class="info-value">{transactions.len().to_string()}</span>
</div>
</div>
</div>
<div class="block-transactions">
<h2>"Transactions"</h2>
{if transactions.is_empty() {
view! { <div class="no-transactions">"No transactions"</div> }
.into_any()
} else {
view! {
<div class="transactions-list">
{transactions
.into_iter()
.map(|tx| view! { <TransactionPreview transaction=tx /> })
.collect::<Vec<_>>()}
</div>
}
.into_any()
}}
</div>
</div>
}
.into_any()
}
Err(e) => {
view! {
<div class="error-page">
<h1>"Error"</h1>
<p>{format!("Failed to load block: {}", e)}</p>
</div>
}
.into_any()
}
})
}}
</Suspense>
</div>
}
}

View File

@ -0,0 +1,208 @@
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_query_map};
use web_sys::SubmitEvent;
use crate::{
api::{self, SearchResults},
components::{AccountPreview, BlockPreview, TransactionPreview},
};
/// Main page component
#[component]
pub fn MainPage() -> impl IntoView {
let query_map = use_query_map();
let navigate = use_navigate();
// Read search query from URL parameter
let url_query = move || query_map.read().get("q").unwrap_or_default();
let (search_query, set_search_query) = signal(url_query());
// Sync search input with URL parameter
Effect::new(move || {
set_search_query.set(url_query());
});
// Search results resource based on URL query parameter
let search_resource = Resource::new(url_query, |query| async move {
if query.is_empty() {
return None;
}
match api::search(query).await {
Ok(result) => Some(result),
Err(e) => {
log::error!("Search error: {}", e);
None
}
}
});
// Load recent blocks on mount
let recent_blocks_resource = Resource::new(|| (), |_| async { api::get_blocks(0, 10).await });
// Handle search - update URL parameter
let on_search = move |ev: SubmitEvent| {
ev.prevent_default();
let query = search_query.get();
if query.is_empty() {
navigate("?", Default::default());
return;
}
navigate(
&format!("?q={}", urlencoding::encode(&query)),
Default::default(),
);
};
view! {
<div class="main-page">
<div class="page-header">
<h1>"LEE Blockchain Explorer"</h1>
</div>
<div class="search-section">
<form on:submit=on_search class="search-form">
<input
type="text"
class="search-input"
placeholder="Search by block ID, block hash, transaction hash, or account ID..."
prop:value=move || search_query.get()
on:input=move |ev| set_search_query.set(event_target_value(&ev))
/>
<button type="submit" class="search-button">
"Search"
</button>
</form>
<Suspense fallback=move || view! { <div class="loading">"Searching..."</div> }>
{move || {
search_resource
.get()
.and_then(|opt_results| opt_results)
.map(|results| {
let SearchResults {
blocks,
transactions,
accounts,
} = results;
let has_results = !blocks.is_empty()
|| !transactions.is_empty()
|| !accounts.is_empty();
view! {
<div class="search-results">
<h2>"Search Results"</h2>
{if !has_results {
view! { <div class="not-found">"No results found"</div> }
.into_any()
} else {
view! {
<div class="results-container">
{if !blocks.is_empty() {
view! {
<div class="results-section">
<h3>"Blocks"</h3>
<div class="results-list">
{blocks
.into_iter()
.map(|block| {
view! { <BlockPreview block=block /> }
})
.collect::<Vec<_>>()}
</div>
</div>
}
.into_any()
} else {
().into_any()
}}
{if !transactions.is_empty() {
view! {
<div class="results-section">
<h3>"Transactions"</h3>
<div class="results-list">
{transactions
.into_iter()
.map(|tx| {
view! { <TransactionPreview transaction=tx /> }
})
.collect::<Vec<_>>()}
</div>
</div>
}
.into_any()
} else {
().into_any()
}}
{if !accounts.is_empty() {
view! {
<div class="results-section">
<h3>"Accounts"</h3>
<div class="results-list">
{accounts
.into_iter()
.map(|(id, account)| {
view! {
<AccountPreview
account_id=id
account=account
/>
}
})
.collect::<Vec<_>>()}
</div>
</div>
}
.into_any()
} else {
().into_any()
}}
</div>
}
.into_any()
}}
</div>
}
.into_any()
})
}}
</Suspense>
</div>
<div class="blocks-section">
<h2>"Recent Blocks"</h2>
<Suspense fallback=move || view! { <div class="loading">"Loading blocks..."</div> }>
{move || {
recent_blocks_resource
.get()
.map(|result| match result {
Ok(blocks) if !blocks.is_empty() => {
view! {
<div class="blocks-list">
{blocks
.into_iter()
.map(|block| view! { <BlockPreview block=block /> })
.collect::<Vec<_>>()}
</div>
}
.into_any()
}
Ok(_) => {
view! { <div class="no-blocks">"No blocks found"</div> }.into_any()
}
Err(e) => {
view! { <div class="error">{format!("Error: {}", e)}</div> }
.into_any()
}
})
}}
</Suspense>
</div>
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod account_page;
pub mod block_page;
pub mod main_page;
pub mod transaction_page;
pub use account_page::AccountPage;
pub use block_page::BlockPage;
pub use main_page::MainPage;
pub use transaction_page::TransactionPage;

View File

@ -0,0 +1,262 @@
use indexer_service_protocol::{
Hash, PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, PublicMessage, PublicTransaction, Transaction, WitnessSet,
};
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::{api, format_utils};
/// Transaction page component
#[component]
pub fn TransactionPage() -> impl IntoView {
let params = use_params_map();
let transaction_resource = Resource::new(
move || {
let tx_hash_str = params.read().get("hash").unwrap_or_default();
format_utils::parse_hex(&tx_hash_str).and_then(|bytes| {
if bytes.len() == 32 {
let hash_array: [u8; 32] = bytes.try_into().ok()?;
Some(Hash(hash_array))
} else {
None
}
})
},
|hash_opt| async move {
match hash_opt {
Some(hash) => api::get_transaction(hash).await,
None => Err(leptos::prelude::ServerFnError::ServerError(
"Invalid transaction hash".to_string(),
)),
}
},
);
view! {
<div class="transaction-page">
<Suspense fallback=move || view! { <div class="loading">"Loading transaction..."</div> }>
{move || {
transaction_resource
.get()
.map(|result| match result {
Ok(tx) => {
let tx_hash = format_utils::format_hash(&tx.hash().0);
let tx_type = match &tx {
Transaction::Public(_) => "Public Transaction",
Transaction::PrivacyPreserving(_) => "Privacy-Preserving Transaction",
Transaction::ProgramDeployment(_) => "Program Deployment Transaction",
};
view! {
<div class="transaction-detail">
<div class="page-header">
<h1>"Transaction"</h1>
</div>
<div class="transaction-info">
<h2>"Transaction Information"</h2>
<div class="info-grid">
<div class="info-row">
<span class="info-label">"Hash:"</span>
<span class="info-value hash">{tx_hash}</span>
</div>
<div class="info-row">
<span class="info-label">"Type:"</span>
<span class="info-value">{tx_type}</span>
</div>
</div>
</div>
{match tx {
Transaction::Public(ptx) => {
let PublicTransaction {
hash: _,
message,
witness_set,
} = ptx;
let PublicMessage {
program_id,
account_ids,
nonces,
instruction_data,
} = message;
let WitnessSet {
signatures_and_public_keys,
proof,
} = witness_set;
let program_id_str = program_id
.iter()
.map(|n| format!("{:08x}", n))
.collect::<String>();
let proof_len = proof.0.len();
let signatures_count = signatures_and_public_keys.len();
view! {
<div class="transaction-details">
<h2>"Public Transaction Details"</h2>
<div class="info-grid">
<div class="info-row">
<span class="info-label">"Program ID:"</span>
<span class="info-value hash">{program_id_str}</span>
</div>
<div class="info-row">
<span class="info-label">"Instruction Data:"</span>
<span class="info-value">
{format!("{} u32 values", instruction_data.len())}
</span>
</div>
<div class="info-row">
<span class="info-label">"Proof Size:"</span>
<span class="info-value">{format!("{} bytes", proof_len)}</span>
</div>
<div class="info-row">
<span class="info-label">"Signatures:"</span>
<span class="info-value">{signatures_count.to_string()}</span>
</div>
</div>
<h3>"Accounts"</h3>
<div class="accounts-list">
{account_ids
.into_iter()
.zip(nonces.into_iter())
.map(|(account_id, nonce)| {
let account_id_str = format_utils::format_account_id(&account_id);
view! {
<div class="account-item">
<A href=format!("/account/{}", account_id_str)>
<span class="hash">{account_id_str}</span>
</A>
<span class="nonce">
" (nonce: " {nonce.to_string()} ")"
</span>
</div>
}
})
.collect::<Vec<_>>()}
</div>
</div>
}
.into_any()
}
Transaction::PrivacyPreserving(pptx) => {
let PrivacyPreservingTransaction {
hash: _,
message,
witness_set,
} = pptx;
let PrivacyPreservingMessage {
public_account_ids,
nonces,
public_post_states: _,
encrypted_private_post_states,
new_commitments,
new_nullifiers,
} = message;
let WitnessSet {
signatures_and_public_keys: _,
proof,
} = witness_set;
let proof_len = proof.0.len();
view! {
<div class="transaction-details">
<h2>"Privacy-Preserving Transaction Details"</h2>
<div class="info-grid">
<div class="info-row">
<span class="info-label">"Public Accounts:"</span>
<span class="info-value">
{public_account_ids.len().to_string()}
</span>
</div>
<div class="info-row">
<span class="info-label">"New Commitments:"</span>
<span class="info-value">{new_commitments.len().to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Nullifiers:"</span>
<span class="info-value">{new_nullifiers.len().to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Encrypted States:"</span>
<span class="info-value">
{encrypted_private_post_states.len().to_string()}
</span>
</div>
<div class="info-row">
<span class="info-label">"Proof Size:"</span>
<span class="info-value">{format!("{} bytes", proof_len)}</span>
</div>
</div>
<h3>"Public Accounts"</h3>
<div class="accounts-list">
{public_account_ids
.into_iter()
.zip(nonces.into_iter())
.map(|(account_id, nonce)| {
let account_id_str = format_utils::format_account_id(&account_id);
view! {
<div class="account-item">
<A href=format!("/account/{}", account_id_str)>
<span class="hash">{account_id_str}</span>
</A>
<span class="nonce">
" (nonce: " {nonce.to_string()} ")"
</span>
</div>
}
})
.collect::<Vec<_>>()}
</div>
</div>
}
.into_any()
}
Transaction::ProgramDeployment(pdtx) => {
let ProgramDeploymentTransaction {
hash: _,
message,
} = pdtx;
let ProgramDeploymentMessage { bytecode } = message;
let bytecode_len = bytecode.len();
view! {
<div class="transaction-details">
<h2>"Program Deployment Transaction Details"</h2>
<div class="info-grid">
<div class="info-row">
<span class="info-label">"Bytecode Size:"</span>
<span class="info-value">
{format!("{} bytes", bytecode_len)}
</span>
</div>
</div>
</div>
}
.into_any()
}
}}
</div>
}
.into_any()
}
Err(e) => {
view! {
<div class="error-page">
<h1>"Error"</h1>
<p>{format!("Failed to load transaction: {}", e)}</p>
</div>
}
.into_any()
}
})
}}
</Suspense>
</div>
}
}

View File

@ -10,9 +10,13 @@ indexer_service_rpc = { workspace = true, features = ["server"] }
clap = { workspace = true, features = ["derive"] }
anyhow.workspace = true
tokio.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
tokio-util.workspace = true
env_logger.workspace = true
log.workspace = true
jsonrpsee.workspace = true
async-trait = "0.1.89"
[features]
# Return mock responses with generated data for testing purposes
mock-responses = []

View File

@ -381,11 +381,17 @@ impl TryFrom<WitnessSet> for nssa::privacy_preserving_transaction::witness_set::
impl From<nssa::PublicTransaction> for PublicTransaction {
fn from(value: nssa::PublicTransaction) -> Self {
let hash = Hash(value.hash());
let nssa::PublicTransaction {
message,
witness_set,
} = value;
Self {
message: value.message().clone().into(),
hash,
message: message.into(),
witness_set: WitnessSet {
signatures_and_public_keys: value
.witness_set()
signatures_and_public_keys: witness_set
.signatures_and_public_keys()
.iter()
.map(|(sig, pk)| (sig.clone().into(), pk.clone().into()))
@ -401,6 +407,7 @@ impl TryFrom<PublicTransaction> for nssa::PublicTransaction {
fn try_from(value: PublicTransaction) -> Result<Self, Self::Error> {
let PublicTransaction {
hash: _,
message,
witness_set,
} = value;
@ -408,6 +415,7 @@ impl TryFrom<PublicTransaction> for nssa::PublicTransaction {
signatures_and_public_keys,
proof: _,
} = witness_set;
Ok(Self::new(
message.into(),
nssa::public_transaction::WitnessSet::from_raw_parts(
@ -422,9 +430,16 @@ impl TryFrom<PublicTransaction> for nssa::PublicTransaction {
impl From<nssa::PrivacyPreservingTransaction> for PrivacyPreservingTransaction {
fn from(value: nssa::PrivacyPreservingTransaction) -> Self {
let hash = Hash(value.hash());
let nssa::PrivacyPreservingTransaction {
message,
witness_set,
} = value;
Self {
message: value.message().clone().into(),
witness_set: value.witness_set().clone().into(),
hash,
message: message.into(),
witness_set: witness_set.into(),
}
}
}
@ -434,13 +449,17 @@ impl TryFrom<PrivacyPreservingTransaction> for nssa::PrivacyPreservingTransactio
fn try_from(value: PrivacyPreservingTransaction) -> Result<Self, Self::Error> {
let PrivacyPreservingTransaction {
hash: _,
message,
witness_set,
} = value;
Ok(Self::new(
message.try_into().map_err(|_| {
nssa::error::NssaError::InvalidInput("Data too big error".to_string())
})?,
message
.try_into()
.map_err(|err: nssa_core::account::data::DataTooBigError| {
nssa::error::NssaError::InvalidInput(err.to_string())
})?,
witness_set.try_into()?,
))
}
@ -448,15 +467,19 @@ impl TryFrom<PrivacyPreservingTransaction> for nssa::PrivacyPreservingTransactio
impl From<nssa::ProgramDeploymentTransaction> for ProgramDeploymentTransaction {
fn from(value: nssa::ProgramDeploymentTransaction) -> Self {
let hash = Hash(value.hash());
let nssa::ProgramDeploymentTransaction { message } = value;
Self {
message: value.into_message().into(),
hash,
message: message.into(),
}
}
}
impl From<ProgramDeploymentTransaction> for nssa::ProgramDeploymentTransaction {
fn from(value: ProgramDeploymentTransaction) -> Self {
let ProgramDeploymentTransaction { message } = value;
let ProgramDeploymentTransaction { hash: _, message } = value;
Self::new(message.into())
}
}

View File

@ -67,14 +67,27 @@ pub enum Transaction {
ProgramDeployment(ProgramDeploymentTransaction),
}
impl Transaction {
/// Get the hash of the transaction
pub fn hash(&self) -> &self::Hash {
match self {
Transaction::Public(tx) => &tx.hash,
Transaction::PrivacyPreserving(tx) => &tx.hash,
Transaction::ProgramDeployment(tx) => &tx.hash,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct PublicTransaction {
pub hash: Hash,
pub message: PublicMessage,
pub witness_set: WitnessSet,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct PrivacyPreservingTransaction {
pub hash: Hash,
pub message: PrivacyPreservingMessage,
pub witness_set: WitnessSet,
}
@ -121,6 +134,7 @@ pub struct EncryptedAccountData {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct ProgramDeploymentTransaction {
pub hash: Hash,
pub message: ProgramDeploymentMessage,
}
@ -133,7 +147,7 @@ pub struct Ciphertext(
pub Vec<u8>,
);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct PublicKey(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded public key")]
@ -147,21 +161,21 @@ pub struct EphemeralPublicKey(
pub Vec<u8>,
);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct Commitment(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded commitment")]
pub [u8; 32],
);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct Nullifier(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded nullifier")]
pub [u8; 32],
);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct CommitmentSetDigest(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded commitment set digest")]
@ -182,7 +196,7 @@ pub struct Data(
pub Vec<u8>,
);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct Hash(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded hash")]

View File

@ -1,11 +1,14 @@
use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction};
use jsonrpsee::{core::SubscriptionResult, proc_macros::rpc, types::ErrorObjectOwned};
use jsonrpsee::proc_macros::rpc;
#[cfg(feature = "server")]
use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned};
#[cfg(all(not(feature = "server"), not(feature = "client")))]
compile_error!("At least one of `server` or `client` features must be enabled.");
#[cfg_attr(feature = "server", rpc(server))]
#[cfg_attr(feature = "client", rpc(client))]
#[cfg_attr(all(feature = "server", not(feature = "client")), rpc(server))]
#[cfg_attr(all(feature = "client", not(feature = "server")), rpc(client))]
#[cfg_attr(all(feature = "server", feature = "client"), rpc(server, client))]
pub trait Rpc {
#[method(name = "get_schema")]
fn get_schema(&self) -> Result<serde_json::Value, ErrorObjectOwned> {
@ -37,4 +40,15 @@ pub trait Rpc {
#[method(name = "getTransaction")]
async fn get_transaction(&self, tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned>;
#[method(name = "getBlocks")]
async fn get_blocks(&self, offset: u32, limit: u32) -> Result<Vec<Block>, ErrorObjectOwned>;
#[method(name = "getTransactionsByAccount")]
async fn get_transactions_by_account(
&self,
account_id: AccountId,
limit: u32,
offset: u32,
) -> Result<Vec<Transaction>, ErrorObjectOwned>;
}

View File

@ -1 +1,4 @@
pub mod service;
#[cfg(feature = "mock-responses")]
pub mod mock_service;

View File

@ -51,7 +51,13 @@ async fn run_server(port: u16) -> Result<jsonrpsee::server::ServerHandle> {
info!("Starting Indexer Service RPC server on {addr}");
#[cfg(not(feature = "mock-responses"))]
let handle = server.start(indexer_service::service::IndexerService.into_rpc());
#[cfg(feature = "mock-responses")]
let handle = server.start(
indexer_service::mock_service::MockIndexerService::new_with_mock_blocks().into_rpc(),
);
Ok(handle)
}

View File

@ -0,0 +1,271 @@
use std::collections::HashMap;
use indexer_service_protocol::{
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment,
CommitmentSetDigest, Data, EncryptedAccountData, Hash, MantleMsgId, PrivacyPreservingMessage,
PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction,
PublicMessage, PublicTransaction, Signature, Transaction, WitnessSet,
};
use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned};
/// A mock implementation of the IndexerService RPC for testing purposes.
pub struct MockIndexerService {
blocks: Vec<Block>,
accounts: HashMap<AccountId, Account>,
transactions: HashMap<Hash, (Transaction, BlockId)>,
}
impl MockIndexerService {
pub fn new_with_mock_blocks() -> Self {
let mut blocks = Vec::new();
let mut accounts = HashMap::new();
let mut transactions = HashMap::new();
// Create some mock accounts
let account_ids: Vec<AccountId> = (0..5)
.map(|i| {
let mut value = [0u8; 32];
value[0] = i;
AccountId { value }
})
.collect();
for (i, account_id) in account_ids.iter().enumerate() {
accounts.insert(
*account_id,
Account {
program_owner: [i as u32; 8],
balance: 1000 * (i as u128 + 1),
data: Data(vec![0xaa, 0xbb, 0xcc]),
nonce: i as u128,
},
);
}
// Create 10 blocks with transactions
let mut prev_hash = Hash([0u8; 32]);
for block_id in 0..10 {
let block_hash = {
let mut hash = [0u8; 32];
hash[0] = block_id as u8;
hash[1] = 0xff;
Hash(hash)
};
// Create 2-4 transactions per block (mix of Public, PrivacyPreserving, and
// ProgramDeployment)
let num_txs = 2 + (block_id % 3);
let mut block_transactions = Vec::new();
for tx_idx in 0..num_txs {
let tx_hash = {
let mut hash = [0u8; 32];
hash[0] = block_id as u8;
hash[1] = tx_idx as u8;
Hash(hash)
};
// Vary transaction types: Public, PrivacyPreserving, or ProgramDeployment
let tx = match (block_id + tx_idx) % 5 {
// Public transactions (most common)
0 | 1 => Transaction::Public(PublicTransaction {
hash: tx_hash,
message: PublicMessage {
program_id: [1u32; 8],
account_ids: vec![
account_ids[tx_idx as usize % account_ids.len()],
account_ids[(tx_idx as usize + 1) % account_ids.len()],
],
nonces: vec![block_id as u128, (block_id + 1) as u128],
instruction_data: vec![1, 2, 3, 4],
},
witness_set: WitnessSet {
signatures_and_public_keys: vec![],
proof: indexer_service_protocol::Proof(vec![0; 32]),
},
}),
// PrivacyPreserving transactions
2 | 3 => Transaction::PrivacyPreserving(PrivacyPreservingTransaction {
hash: tx_hash,
message: PrivacyPreservingMessage {
public_account_ids: vec![
account_ids[tx_idx as usize % account_ids.len()],
],
nonces: vec![block_id as u128],
public_post_states: vec![Account {
program_owner: [1u32; 8],
balance: 500,
data: Data(vec![0xdd, 0xee]),
nonce: block_id as u128,
}],
encrypted_private_post_states: vec![EncryptedAccountData {
ciphertext: indexer_service_protocol::Ciphertext(vec![
0x01, 0x02, 0x03, 0x04,
]),
epk: indexer_service_protocol::EphemeralPublicKey(vec![0xaa; 32]),
view_tag: 42,
}],
new_commitments: vec![Commitment([block_id as u8; 32])],
new_nullifiers: vec![(
indexer_service_protocol::Nullifier([tx_idx as u8; 32]),
CommitmentSetDigest([0xff; 32]),
)],
},
witness_set: WitnessSet {
signatures_and_public_keys: vec![],
proof: indexer_service_protocol::Proof(vec![0; 32]),
},
}),
// ProgramDeployment transactions (rare)
_ => Transaction::ProgramDeployment(ProgramDeploymentTransaction {
hash: tx_hash,
message: ProgramDeploymentMessage {
bytecode: vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00], /* WASM magic number */
},
}),
};
transactions.insert(tx_hash, (tx.clone(), block_id));
block_transactions.push(tx);
}
let block = Block {
header: BlockHeader {
block_id,
prev_block_hash: prev_hash,
hash: block_hash,
timestamp: 1704067200000 + (block_id * 12000), // ~12 seconds per block
signature: Signature([0u8; 64]),
},
body: BlockBody {
transactions: block_transactions,
},
bedrock_status: match block_id {
0..=5 => BedrockStatus::Finalized,
6..=8 => BedrockStatus::Safe,
_ => BedrockStatus::Pending,
},
bedrock_parent_id: MantleMsgId([0; 32]),
};
prev_hash = block_hash;
blocks.push(block);
}
Self {
blocks,
accounts,
transactions,
}
}
}
// `async_trait` is required by `jsonrpsee`
#[async_trait::async_trait]
impl indexer_service_rpc::RpcServer for MockIndexerService {
async fn subscribe_to_blocks(
&self,
_subscription_sink: jsonrpsee::PendingSubscriptionSink,
_from: BlockId,
) -> SubscriptionResult {
// Subscription not implemented for mock service
Err("Subscriptions not supported in mock service".into())
}
async fn get_block_by_id(&self, block_id: BlockId) -> Result<Block, ErrorObjectOwned> {
self.blocks
.iter()
.find(|b| b.header.block_id == block_id)
.cloned()
.ok_or_else(|| {
ErrorObjectOwned::owned(
-32001,
format!("Block with ID {} not found", block_id),
None::<()>,
)
})
}
async fn get_block_by_hash(&self, block_hash: Hash) -> Result<Block, ErrorObjectOwned> {
self.blocks
.iter()
.find(|b| b.header.hash == block_hash)
.cloned()
.ok_or_else(|| ErrorObjectOwned::owned(-32001, "Block with hash not found", None::<()>))
}
async fn get_last_block_id(&self) -> Result<BlockId, ErrorObjectOwned> {
self.blocks
.last()
.map(|b| b.header.block_id)
.ok_or_else(|| ErrorObjectOwned::owned(-32001, "No blocks available", None::<()>))
}
async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
self.accounts
.get(&account_id)
.cloned()
.ok_or_else(|| ErrorObjectOwned::owned(-32001, "Account not found", None::<()>))
}
async fn get_transaction(&self, tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned> {
self.transactions
.get(&tx_hash)
.map(|(tx, _)| tx.clone())
.ok_or_else(|| ErrorObjectOwned::owned(-32001, "Transaction not found", None::<()>))
}
async fn get_blocks(&self, offset: u32, limit: u32) -> Result<Vec<Block>, ErrorObjectOwned> {
let offset = offset as usize;
let limit = limit as usize;
let total = self.blocks.len();
// Return blocks in reverse order (newest first), with pagination
let start = offset.min(total);
let end = (offset + limit).min(total);
Ok(self
.blocks
.iter()
.rev()
.skip(start)
.take(end - start)
.cloned()
.collect())
}
async fn get_transactions_by_account(
&self,
account_id: AccountId,
limit: u32,
offset: u32,
) -> Result<Vec<Transaction>, ErrorObjectOwned> {
let mut account_txs: Vec<_> = self
.transactions
.values()
.filter(|(tx, _)| match tx {
Transaction::Public(pub_tx) => pub_tx.message.account_ids.contains(&account_id),
Transaction::PrivacyPreserving(priv_tx) => {
priv_tx.message.public_account_ids.contains(&account_id)
}
Transaction::ProgramDeployment(_) => false,
})
.collect();
// Sort by block ID descending (most recent first)
account_txs.sort_by(|a, b| b.1.cmp(&a.1));
let start = offset as usize;
if start >= account_txs.len() {
return Ok(Vec::new());
}
let end = (start + limit as usize).min(account_txs.len());
Ok(account_txs[start..end]
.iter()
.map(|(tx, _)| tx.clone())
.collect())
}
}

View File

@ -33,4 +33,17 @@ impl indexer_service_rpc::RpcServer for IndexerService {
async fn get_transaction(&self, _tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned> {
todo!()
}
async fn get_blocks(&self, _offset: u32, _limit: u32) -> Result<Vec<Block>, ErrorObjectOwned> {
todo!()
}
async fn get_transactions_by_account(
&self,
_account_id: AccountId,
_limit: u32,
_offset: u32,
) -> Result<Vec<Transaction>, ErrorObjectOwned> {
todo!()
}
}

View File

@ -5,6 +5,7 @@ use nssa_core::{
Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput,
account::{Account, AccountWithMetadata},
};
use sha2::{Digest as _, digest::FixedOutput as _};
use super::{message::Message, witness_set::WitnessSet};
use crate::{
@ -131,6 +132,13 @@ impl PrivacyPreservingTransaction {
&self.witness_set
}
pub fn hash(&self) -> [u8; 32] {
let bytes = self.to_bytes();
let mut hasher = sha2::Sha256::new();
hasher.update(&bytes);
hasher.finalize_fixed().into()
}
pub(crate) fn signer_account_ids(&self) -> Vec<AccountId> {
self.witness_set
.signatures_and_public_keys()

View File

@ -1,4 +1,5 @@
use borsh::{BorshDeserialize, BorshSerialize};
use sha2::{Digest as _, digest::FixedOutput as _};
use crate::{
V02State, error::NssaError, program::Program, program_deployment_transaction::message::Message,
@ -6,7 +7,7 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct ProgramDeploymentTransaction {
pub(crate) message: Message,
pub message: Message,
}
impl ProgramDeploymentTransaction {
@ -30,4 +31,11 @@ impl ProgramDeploymentTransaction {
Ok(program)
}
}
pub fn hash(&self) -> [u8; 32] {
let bytes = self.to_bytes();
let mut hasher = sha2::Sha256::new();
hasher.update(&bytes);
hasher.finalize_fixed().into()
}
}

View File

@ -17,8 +17,8 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct PublicTransaction {
message: Message,
witness_set: WitnessSet,
pub message: Message,
pub witness_set: WitnessSet,
}
impl PublicTransaction {