mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-02-17 11:53:14 +00:00
Merge branch 'main' into marvin/public_keys
This commit is contained in:
commit
7c930415a6
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
|
||||
|
||||
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