mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-02-17 20:03:21 +00:00
feat: implement Explorer
This commit is contained in:
parent
cb1b6f14f6
commit
72c45be083
1171
Cargo.lock
generated
1171
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
explorer_service/.gitignore
vendored
Normal file
11
explorer_service/.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# Leptos build outputs
|
||||
/target
|
||||
/pkg
|
||||
/site
|
||||
|
||||
# WASM artifacts
|
||||
*.wasm
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
73
explorer_service/Cargo.toml
Normal file
73
explorer_service/Cargo.toml
Normal 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"
|
||||
52
explorer_service/Dockerfile
Normal file
52
explorer_service/Dockerfile
Normal 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 it’s 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"]
|
||||
71
explorer_service/README.md
Normal file
71
explorer_service/README.md
Normal 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.
|
||||
11
explorer_service/docker-compose.yml
Normal file
11
explorer_service/docker-compose.yml
Normal 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"
|
||||
516
explorer_service/public/explorer.css
Normal file
516
explorer_service/public/explorer.css
Normal 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
158
explorer_service/src/api.rs
Normal 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}"))
|
||||
}
|
||||
63
explorer_service/src/components/account_preview.rs
Normal file
63
explorer_service/src/components/account_preview.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
77
explorer_service/src/components/block_preview.rs
Normal file
77
explorer_service/src/components/block_preview.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
7
explorer_service/src/components/mod.rs
Normal file
7
explorer_service/src/components/mod.rs
Normal 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;
|
||||
72
explorer_service/src/components/transaction_preview.rs
Normal file
72
explorer_service/src/components/transaction_preview.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
33
explorer_service/src/format_utils.rs
Normal file
33
explorer_service/src/format_utils.rs
Normal 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
102
explorer_service/src/lib.rs
Normal 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);
|
||||
}
|
||||
79
explorer_service/src/main.rs
Normal file
79
explorer_service/src/main.rs
Normal 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
|
||||
}
|
||||
229
explorer_service/src/pages/account_page.rs
Normal file
229
explorer_service/src/pages/account_page.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
159
explorer_service/src/pages/block_page.rs
Normal file
159
explorer_service/src/pages/block_page.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
208
explorer_service/src/pages/main_page.rs
Normal file
208
explorer_service/src/pages/main_page.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
9
explorer_service/src/pages/mod.rs
Normal file
9
explorer_service/src/pages/mod.rs
Normal 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;
|
||||
262
explorer_service/src/pages/transaction_page.rs
Normal file
262
explorer_service/src/pages/transaction_page.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
@ -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 = []
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -1 +1,4 @@
|
||||
pub mod service;
|
||||
|
||||
#[cfg(feature = "mock-responses")]
|
||||
pub mod mock_service;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
271
indexer_service/src/mock_service.rs
Normal file
271
indexer_service/src/mock_service.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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!()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user