mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-02-19 04:43:36 +00:00
Merge branch 'arjentix/full-bedrock-integration' into Pravdyvy/indexer-state-management
This commit is contained in:
commit
e1df915357
1306
Cargo.lock
generated
1306
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
27
Cargo.toml
@ -6,15 +6,18 @@ members = [
|
||||
"key_protocol",
|
||||
"mempool",
|
||||
"wallet",
|
||||
"wallet-ffi",
|
||||
"common",
|
||||
"nssa",
|
||||
"nssa/core",
|
||||
"sequencer_core",
|
||||
"sequencer_rpc",
|
||||
"sequencer_runner",
|
||||
"indexer_service",
|
||||
"indexer_service/protocol",
|
||||
"indexer_service/rpc",
|
||||
"indexer/core",
|
||||
"indexer/service",
|
||||
"indexer/service/protocol",
|
||||
"indexer/service/rpc",
|
||||
"explorer_service",
|
||||
"program_methods",
|
||||
"program_methods/guest",
|
||||
"test_program_methods",
|
||||
@ -23,7 +26,6 @@ members = [
|
||||
"examples/program_deployment/methods",
|
||||
"examples/program_deployment/methods/guest",
|
||||
"bedrock_client",
|
||||
"indexer_core",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@ -36,13 +38,14 @@ key_protocol = { path = "key_protocol" }
|
||||
sequencer_core = { path = "sequencer_core" }
|
||||
sequencer_rpc = { path = "sequencer_rpc" }
|
||||
sequencer_runner = { path = "sequencer_runner" }
|
||||
indexer_service = { path = "indexer_service" }
|
||||
indexer_service_protocol = { path = "indexer_service/protocol" }
|
||||
indexer_service_rpc = { path = "indexer_service/rpc" }
|
||||
indexer_core = { path = "indexer/core" }
|
||||
indexer_service = { path = "indexer/service" }
|
||||
indexer_service_protocol = { path = "indexer/service/protocol" }
|
||||
indexer_service_rpc = { path = "indexer/service/rpc" }
|
||||
wallet = { path = "wallet" }
|
||||
wallet-ffi = { path = "wallet-ffi" }
|
||||
test_program_methods = { path = "test_program_methods" }
|
||||
bedrock_client = { path = "bedrock_client" }
|
||||
indexer_core = { path = "indexer_core" }
|
||||
|
||||
tokio = { version = "1.28.2", features = [
|
||||
"net",
|
||||
@ -90,6 +93,7 @@ itertools = "0.14.0"
|
||||
url = { version = "2.5.4", features = ["serde"] }
|
||||
tokio-retry = "0.3.0"
|
||||
schemars = "1.2.0"
|
||||
async-stream = "0.3.6"
|
||||
|
||||
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git" }
|
||||
logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git" }
|
||||
@ -114,3 +118,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.
@ -1,6 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Message {
|
||||
L2BlockFinalized { l2_block_height: u64 },
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
pub mod indexer;
|
||||
55
common/src/config.rs
Normal file
55
common/src/config.rs
Normal file
@ -0,0 +1,55 @@
|
||||
//! Common configuration structures and utilities.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use logos_blockchain_common_http_client::BasicAuthCredentials;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BasicAuth {
|
||||
pub username: String,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BasicAuth {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.username)?;
|
||||
if let Some(password) = &self.password {
|
||||
write!(f, ":{password}")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for BasicAuth {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let parse = || {
|
||||
let mut parts = s.splitn(2, ':');
|
||||
let username = parts.next()?;
|
||||
let password = parts.next().filter(|p| !p.is_empty());
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((username, password))
|
||||
};
|
||||
|
||||
let (username, password) = parse().ok_or_else(|| {
|
||||
anyhow::anyhow!("Invalid auth format. Expected 'user' or 'user:password'")
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
username: username.to_string(),
|
||||
password: password.map(|p| p.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BasicAuth> for BasicAuthCredentials {
|
||||
fn from(value: BasicAuth) -> Self {
|
||||
BasicAuthCredentials::new(value.username, value.password)
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
pub mod block;
|
||||
pub mod communication;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod rpc_primitives;
|
||||
pub mod sequencer_client;
|
||||
@ -8,6 +8,7 @@ pub mod transaction;
|
||||
// Module for tests utility functions
|
||||
// TODO: Compile only for tests
|
||||
pub mod test_utils;
|
||||
pub type HashType = [u8; 32];
|
||||
|
||||
pub const PINATA_BASE58: &str = "EfQhKQAkX2FJiwNii2WFQsGndjvF1Mzd7RuVe7QdPLw7";
|
||||
|
||||
pub type HashType = [u8; 32];
|
||||
|
||||
@ -76,11 +76,6 @@ pub struct GetProofForCommitmentRequest {
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GetProgramIdsRequest {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PostIndexerMessageRequest {
|
||||
pub message: crate::communication::indexer::Message,
|
||||
}
|
||||
|
||||
parse_request!(HelloRequest);
|
||||
parse_request!(RegisterAccountRequest);
|
||||
parse_request!(SendTxRequest);
|
||||
@ -231,8 +226,3 @@ pub struct GetInitialTestnetAccountsResponse {
|
||||
pub account_id: String,
|
||||
pub balance: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PostIndexerMessageResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
use std::{collections::HashMap, ops::RangeInclusive, str::FromStr};
|
||||
use std::{collections::HashMap, ops::RangeInclusive};
|
||||
|
||||
use anyhow::Result;
|
||||
use logos_blockchain_common_http_client::BasicAuthCredentials;
|
||||
use nssa_core::program::ProgramId;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
@ -14,71 +13,22 @@ use super::rpc_primitives::requests::{
|
||||
};
|
||||
use crate::{
|
||||
block::Block,
|
||||
config::BasicAuth,
|
||||
error::{SequencerClientError, SequencerRpcError},
|
||||
rpc_primitives::{
|
||||
self,
|
||||
requests::{
|
||||
GetAccountRequest, GetAccountResponse, GetAccountsNoncesRequest,
|
||||
GetAccountsNoncesResponse, GetBlockRangeDataRequest, GetBlockRangeDataResponse,
|
||||
GetGenesisBlockRequest, GetGenesisBlockResponse, GetInitialTestnetAccountsResponse,
|
||||
GetLastBlockRequest, GetLastBlockResponse, GetProgramIdsRequest, GetProgramIdsResponse,
|
||||
GetProofForCommitmentRequest, GetProofForCommitmentResponse,
|
||||
GetTransactionByHashRequest, GetTransactionByHashResponse, PostIndexerMessageRequest,
|
||||
PostIndexerMessageResponse, SendTxRequest, SendTxResponse,
|
||||
GetInitialTestnetAccountsResponse, GetLastBlockRequest, GetLastBlockResponse,
|
||||
GetProgramIdsRequest, GetProgramIdsResponse, GetProofForCommitmentRequest,
|
||||
GetProofForCommitmentResponse, GetTransactionByHashRequest,
|
||||
GetTransactionByHashResponse, SendTxRequest, SendTxResponse,
|
||||
},
|
||||
},
|
||||
transaction::{EncodedTransaction, NSSATransaction},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BasicAuth {
|
||||
pub username: String,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BasicAuth {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.username)?;
|
||||
if let Some(password) = &self.password {
|
||||
write!(f, ":{password}")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for BasicAuth {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let parse = || {
|
||||
let mut parts = s.splitn(2, ':');
|
||||
let username = parts.next()?;
|
||||
let password = parts.next().filter(|p| !p.is_empty());
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((username, password))
|
||||
};
|
||||
|
||||
let (username, password) = parse().ok_or_else(|| {
|
||||
anyhow::anyhow!("Invalid auth format. Expected 'user' or 'user:password'")
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
username: username.to_string(),
|
||||
password: password.map(|p| p.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BasicAuth> for BasicAuthCredentials {
|
||||
fn from(value: BasicAuth) -> Self {
|
||||
BasicAuthCredentials::new(value.username, value.password)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SequencerClient {
|
||||
pub client: reqwest::Client,
|
||||
@ -415,23 +365,4 @@ impl SequencerClient {
|
||||
|
||||
Ok(resp_deser)
|
||||
}
|
||||
|
||||
/// Post indexer into sequencer
|
||||
pub async fn post_indexer_message(
|
||||
&self,
|
||||
message: crate::communication::indexer::Message,
|
||||
) -> Result<PostIndexerMessageResponse, SequencerClientError> {
|
||||
let last_req = PostIndexerMessageRequest { message };
|
||||
|
||||
let req = serde_json::to_value(last_req).unwrap();
|
||||
|
||||
let resp = self
|
||||
.call_method_with_payload("post_indexer_message", req)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp_deser = serde_json::from_value(resp).unwrap();
|
||||
|
||||
Ok(resp_deser)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
72
explorer_service/Cargo.toml
Normal file
72
explorer_service/Cargo.toml
Normal file
@ -0,0 +1,72 @@
|
||||
[package]
|
||||
name = "explorer_service"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[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>
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,4 @@ futures.workspace = true
|
||||
url.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
testnet = []
|
||||
async-stream.workspace = true
|
||||
@ -15,14 +15,12 @@ use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// ToDo: Expand if necessary
|
||||
pub struct ClientConfig {
|
||||
pub struct BedrockClientConfig {
|
||||
pub addr: Url,
|
||||
pub auth: Option<BasicAuth>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// Note: For individual RPC requests we use Fibonacci backoff retry strategy
|
||||
pub struct IndexerConfig {
|
||||
/// Home dir of sequencer storage
|
||||
pub home: PathBuf,
|
||||
@ -31,19 +29,19 @@ pub struct IndexerConfig {
|
||||
/// List of initial commitments
|
||||
pub initial_commitments: Vec<CommitmentsInitialData>,
|
||||
pub resubscribe_interval_millis: u64,
|
||||
/// For individual RPC requests we use Fibonacci backoff retry strategy.
|
||||
pub backoff: BackoffConfig,
|
||||
pub bedrock_client_config: ClientConfig,
|
||||
pub sequencer_client_config: ClientConfig,
|
||||
pub bedrock_client_config: BedrockClientConfig,
|
||||
pub channel_id: ChannelId,
|
||||
}
|
||||
|
||||
impl IndexerConfig {
|
||||
pub fn from_path(config_home: &Path) -> Result<IndexerConfig> {
|
||||
let file = File::open(config_home)
|
||||
.with_context(|| format!("Failed to open indexer config at {config_home:?}"))?;
|
||||
pub fn from_path(config_path: &Path) -> Result<IndexerConfig> {
|
||||
let file = File::open(config_path)
|
||||
.with_context(|| format!("Failed to open indexer config at {config_path:?}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
serde_json::from_reader(reader)
|
||||
.with_context(|| format!("Failed to parse indexer config at {config_home:?}"))
|
||||
.with_context(|| format!("Failed to parse indexer config at {config_path:?}"))
|
||||
}
|
||||
}
|
||||
107
indexer/core/src/lib.rs
Normal file
107
indexer/core/src/lib.rs
Normal file
@ -0,0 +1,107 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use bedrock_client::BedrockClient;
|
||||
use common::block::Block;
|
||||
use futures::StreamExt;
|
||||
use log::info;
|
||||
use logos_blockchain_core::mantle::{
|
||||
Op, SignedMantleTx,
|
||||
ops::channel::{ChannelId, inscribe::InscriptionOp},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{config::IndexerConfig, state::IndexerState};
|
||||
|
||||
pub mod config;
|
||||
pub mod state;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IndexerCore {
|
||||
bedrock_client: BedrockClient,
|
||||
config: IndexerConfig,
|
||||
state: IndexerState,
|
||||
}
|
||||
|
||||
impl IndexerCore {
|
||||
pub fn new(config: IndexerConfig) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bedrock_client: BedrockClient::new(
|
||||
config.bedrock_client_config.auth.clone().map(Into::into),
|
||||
config.bedrock_client_config.addr.clone(),
|
||||
)?,
|
||||
config,
|
||||
// No state setup for now, future task.
|
||||
state: IndexerState {
|
||||
latest_seen_block: Arc::new(RwLock::new(0)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn subscribe_parse_block_stream(&self) -> impl futures::Stream<Item = Result<Block>> {
|
||||
async_stream::stream! {
|
||||
loop {
|
||||
let mut stream_pinned = Box::pin(self.bedrock_client.get_lib_stream().await?);
|
||||
|
||||
info!("Block stream joined");
|
||||
|
||||
while let Some(block_info) = stream_pinned.next().await {
|
||||
let header_id = block_info.header_id;
|
||||
|
||||
info!("Observed L1 block at height {}", block_info.height);
|
||||
|
||||
if let Some(l1_block) = self
|
||||
.bedrock_client
|
||||
.get_block_by_id(header_id, &self.config.backoff)
|
||||
.await?
|
||||
{
|
||||
info!("Extracted L1 block at height {}", block_info.height);
|
||||
|
||||
let l2_blocks_parsed = parse_blocks(
|
||||
l1_block.into_transactions().into_iter(),
|
||||
&self.config.channel_id,
|
||||
).collect::<Vec<_>>();
|
||||
|
||||
info!("Parsed {} L2 blocks", l2_blocks_parsed.len());
|
||||
|
||||
for l2_block in l2_blocks_parsed {
|
||||
// State modification, will be updated in future
|
||||
{
|
||||
let mut guard = self.state.latest_seen_block.write().await;
|
||||
if l2_block.header.block_id > *guard {
|
||||
*guard = l2_block.header.block_id;
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok(l2_block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch stream after delay
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
self.config.resubscribe_interval_millis,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_blocks(
|
||||
block_txs: impl Iterator<Item = SignedMantleTx>,
|
||||
decoded_channel_id: &ChannelId,
|
||||
) -> impl Iterator<Item = Block> {
|
||||
block_txs.flat_map(|tx| {
|
||||
tx.mantle_tx.ops.into_iter().filter_map(|op| match op {
|
||||
Op::ChannelInscribe(InscriptionOp {
|
||||
channel_id,
|
||||
inscription,
|
||||
..
|
||||
}) if channel_id == *decoded_channel_id => {
|
||||
borsh::from_slice::<Block>(&inscription).ok()
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
25
indexer/service/Cargo.toml
Normal file
25
indexer/service/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "indexer_service"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
indexer_service_protocol = { workspace = true, features = ["convert"] }
|
||||
indexer_service_rpc = { workspace = true, features = ["server"] }
|
||||
indexer_core.workspace = true
|
||||
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
anyhow.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
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
futures.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> {
|
||||
@ -20,8 +23,8 @@ pub trait Rpc {
|
||||
Ok(serde_json::to_value(block_schema).expect("Schema serialization should not fail"))
|
||||
}
|
||||
|
||||
#[subscription(name = "subscribeToBlocks", item = Vec<Block>)]
|
||||
async fn subscribe_to_blocks(&self, from: BlockId) -> SubscriptionResult;
|
||||
#[subscription(name = "subscribeToFinalizedBlocks", item = BlockId)]
|
||||
async fn subscribe_to_finalized_blocks(&self) -> SubscriptionResult;
|
||||
|
||||
#[method(name = "getBlockById")]
|
||||
async fn get_block_by_id(&self, block_id: BlockId) -> Result<Block, ErrorObjectOwned>;
|
||||
@ -29,12 +32,20 @@ pub trait Rpc {
|
||||
#[method(name = "getBlockByHash")]
|
||||
async fn get_block_by_hash(&self, block_hash: Hash) -> Result<Block, ErrorObjectOwned>;
|
||||
|
||||
#[method(name = "getLastBlockId")]
|
||||
async fn get_last_block_id(&self) -> Result<BlockId, ErrorObjectOwned>;
|
||||
|
||||
#[method(name = "getAccount")]
|
||||
async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned>;
|
||||
|
||||
#[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>;
|
||||
}
|
||||
82
indexer/service/src/lib.rs
Normal file
82
indexer/service/src/lib.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
pub use indexer_core::config::*;
|
||||
use indexer_service_rpc::RpcServer as _;
|
||||
use jsonrpsee::server::Server;
|
||||
use log::{error, info};
|
||||
|
||||
pub mod service;
|
||||
|
||||
#[cfg(feature = "mock-responses")]
|
||||
pub mod mock_service;
|
||||
|
||||
pub struct IndexerHandle {
|
||||
addr: SocketAddr,
|
||||
server_handle: Option<jsonrpsee::server::ServerHandle>,
|
||||
}
|
||||
impl IndexerHandle {
|
||||
fn new(addr: SocketAddr, server_handle: jsonrpsee::server::ServerHandle) -> Self {
|
||||
Self {
|
||||
addr,
|
||||
server_handle: Some(server_handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> SocketAddr {
|
||||
self.addr
|
||||
}
|
||||
|
||||
pub async fn stopped(mut self) {
|
||||
let handle = self
|
||||
.server_handle
|
||||
.take()
|
||||
.expect("Indexer server handle is set");
|
||||
|
||||
handle.stopped().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IndexerHandle {
|
||||
fn drop(&mut self) {
|
||||
let Self {
|
||||
addr: _,
|
||||
server_handle,
|
||||
} = self;
|
||||
|
||||
let Some(handle) = server_handle else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = handle.stop() {
|
||||
error!("An error occurred while stopping Indexer RPC server: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_server(config: IndexerConfig, port: u16) -> Result<IndexerHandle> {
|
||||
#[cfg(feature = "mock-responses")]
|
||||
let _ = config;
|
||||
|
||||
let server = Server::builder()
|
||||
.build(SocketAddr::from(([0, 0, 0, 0], port)))
|
||||
.await
|
||||
.context("Failed to build RPC server")?;
|
||||
|
||||
let addr = server
|
||||
.local_addr()
|
||||
.context("Failed to get local address of RPC server")?;
|
||||
|
||||
info!("Starting Indexer Service RPC server on {addr}");
|
||||
|
||||
#[cfg(not(feature = "mock-responses"))]
|
||||
let handle = {
|
||||
let service =
|
||||
service::IndexerService::new(config).context("Failed to initialize indexer service")?;
|
||||
server.start(service.into_rpc())
|
||||
};
|
||||
#[cfg(feature = "mock-responses")]
|
||||
let handle = server.start(mock_service::MockIndexerService::new_with_mock_blocks().into_rpc());
|
||||
|
||||
Ok(IndexerHandle::new(addr, handle))
|
||||
}
|
||||
@ -1,15 +1,15 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use indexer_service_rpc::RpcServer as _;
|
||||
use jsonrpsee::server::Server;
|
||||
use log::{error, info};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(version)]
|
||||
struct Args {
|
||||
#[clap(name = "config")]
|
||||
config_path: PathBuf,
|
||||
#[clap(short, long, default_value = "8779")]
|
||||
port: u16,
|
||||
}
|
||||
@ -18,18 +18,18 @@ struct Args {
|
||||
async fn main() -> Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
let args = Args::parse();
|
||||
let Args { config_path, port } = Args::parse();
|
||||
|
||||
let cancellation_token = listen_for_shutdown_signal();
|
||||
|
||||
let handle = run_server(args.port).await?;
|
||||
let handle_clone = handle.clone();
|
||||
let config = indexer_service::IndexerConfig::from_path(&config_path)?;
|
||||
let indexer_handle = indexer_service::run_server(config, port).await?;
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
info!("Shutting down server...");
|
||||
}
|
||||
_ = handle_clone.stopped() => {
|
||||
_ = indexer_handle.stopped() => {
|
||||
error!("Server stopped unexpectedly");
|
||||
}
|
||||
}
|
||||
@ -39,22 +39,6 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_server(port: u16) -> Result<jsonrpsee::server::ServerHandle> {
|
||||
let server = Server::builder()
|
||||
.build(SocketAddr::from(([0, 0, 0, 0], port)))
|
||||
.await
|
||||
.context("Failed to build RPC server")?;
|
||||
|
||||
let addr = server
|
||||
.local_addr()
|
||||
.context("Failed to get local address of RPC server")?;
|
||||
|
||||
info!("Starting Indexer Service RPC server on {addr}");
|
||||
|
||||
let handle = server.start(indexer_service::service::IndexerService.into_rpc());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn listen_for_shutdown_signal() -> CancellationToken {
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let cancellation_token_clone = cancellation_token.clone();
|
||||
270
indexer/service/src/mock_service.rs
Normal file
270
indexer/service/src/mock_service.rs
Normal file
@ -0,0 +1,270 @@
|
||||
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::async_trait]
|
||||
impl indexer_service_rpc::RpcServer for MockIndexerService {
|
||||
async fn subscribe_to_finalized_blocks(
|
||||
&self,
|
||||
subscription_sink: jsonrpsee::PendingSubscriptionSink,
|
||||
) -> SubscriptionResult {
|
||||
let sink = subscription_sink.accept().await?;
|
||||
for block in self
|
||||
.blocks
|
||||
.iter()
|
||||
.filter(|b| b.bedrock_status == BedrockStatus::Finalized)
|
||||
{
|
||||
let json = serde_json::value::to_raw_value(block).unwrap();
|
||||
sink.send(json).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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_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())
|
||||
}
|
||||
}
|
||||
162
indexer/service/src/service.rs
Normal file
162
indexer/service/src/service.rs
Normal file
@ -0,0 +1,162 @@
|
||||
use std::{pin::pin, sync::Arc};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use futures::StreamExt as _;
|
||||
use indexer_core::{IndexerCore, config::IndexerConfig};
|
||||
use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction};
|
||||
use jsonrpsee::{
|
||||
SubscriptionSink,
|
||||
core::{Serialize, SubscriptionResult},
|
||||
types::ErrorObjectOwned,
|
||||
};
|
||||
use tokio::sync::{Mutex, mpsc::UnboundedSender};
|
||||
|
||||
pub struct IndexerService {
|
||||
subscription_service: SubscriptionService,
|
||||
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "Will be used in future implementations of RPC methods"
|
||||
)]
|
||||
indexer: IndexerCore,
|
||||
}
|
||||
|
||||
impl IndexerService {
|
||||
pub fn new(config: IndexerConfig) -> Result<Self> {
|
||||
let indexer = IndexerCore::new(config)?;
|
||||
let subscription_service = SubscriptionService::spawn_new(indexer.clone());
|
||||
|
||||
Ok(Self {
|
||||
subscription_service,
|
||||
indexer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl indexer_service_rpc::RpcServer for IndexerService {
|
||||
async fn subscribe_to_finalized_blocks(
|
||||
&self,
|
||||
subscription_sink: jsonrpsee::PendingSubscriptionSink,
|
||||
) -> SubscriptionResult {
|
||||
let sink = subscription_sink.accept().await?;
|
||||
self.subscription_service
|
||||
.add_subscription(Subscription::new(sink))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_block_by_id(&self, _block_id: BlockId) -> Result<Block, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn get_block_by_hash(&self, _block_hash: Hash) -> Result<Block, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn get_account(&self, _account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
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!()
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionService {
|
||||
respond_subscribers_loop_handle: tokio::task::JoinHandle<Result<()>>,
|
||||
new_subscription_sender: UnboundedSender<Subscription<BlockId>>,
|
||||
}
|
||||
|
||||
impl SubscriptionService {
|
||||
pub fn spawn_new(indexer: IndexerCore) -> Self {
|
||||
let (new_subscription_sender, mut sub_receiver) =
|
||||
tokio::sync::mpsc::unbounded_channel::<Subscription<BlockId>>();
|
||||
|
||||
let subscriptions = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let respond_subscribers_loop_handle = tokio::spawn(async move {
|
||||
let mut block_stream = pin!(indexer.subscribe_parse_block_stream().await);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
sub = sub_receiver.recv() => {
|
||||
let Some(subscription) = sub else {
|
||||
bail!("Subscription receiver closed unexpectedly");
|
||||
};
|
||||
subscriptions.lock().await.push(subscription);
|
||||
}
|
||||
block_opt = block_stream.next() => {
|
||||
let Some(block) = block_opt else {
|
||||
bail!("Block stream ended unexpectedly");
|
||||
};
|
||||
let block = block.context("Failed to get L2 block data")?;
|
||||
let block: indexer_service_protocol::Block = block
|
||||
.try_into()
|
||||
.context("Failed to convert L2 Block into protocol Block")?;
|
||||
|
||||
// Cloning subscriptions to avoid holding the lock while sending
|
||||
let subscriptions = subscriptions.lock().await.clone();
|
||||
for sink in subscriptions {
|
||||
sink.send(&block.header.block_id).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
respond_subscribers_loop_handle,
|
||||
new_subscription_sender,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_subscription(&self, subscription: Subscription<BlockId>) -> Result<()> {
|
||||
self.new_subscription_sender.send(subscription)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SubscriptionService {
|
||||
fn drop(&mut self) {
|
||||
self.respond_subscribers_loop_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Subscription<T> {
|
||||
sink: SubscriptionSink,
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T> Subscription<T> {
|
||||
fn new(sink: SubscriptionSink) -> Self {
|
||||
Self {
|
||||
sink,
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&self, item: &T) -> Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let json = serde_json::value::to_raw_value(item)
|
||||
.context("Failed to serialize item for subscription")?;
|
||||
self.sink.send(json).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use bedrock_client::BedrockClient;
|
||||
// ToDo: Remove after testnet
|
||||
use common::PINATA_BASE58;
|
||||
use common::{
|
||||
block::Block, communication::indexer::Message,
|
||||
rpc_primitives::requests::PostIndexerMessageResponse, sequencer_client::SequencerClient,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use log::info;
|
||||
use logos_blockchain_core::mantle::{
|
||||
Op, SignedMantleTx,
|
||||
ops::channel::{ChannelId, inscribe::InscriptionOp},
|
||||
};
|
||||
|
||||
use crate::{block_store::IndexerStore, config::IndexerConfig};
|
||||
|
||||
pub mod block_store;
|
||||
pub mod config;
|
||||
pub mod state;
|
||||
|
||||
pub struct IndexerCore {
|
||||
pub bedrock_client: BedrockClient,
|
||||
pub sequencer_client: SequencerClient,
|
||||
pub config: IndexerConfig,
|
||||
pub store: IndexerStore,
|
||||
}
|
||||
|
||||
impl IndexerCore {
|
||||
pub async fn new(config: IndexerConfig) -> Result<Self> {
|
||||
let sequencer_client = SequencerClient::new_with_auth(
|
||||
config.sequencer_client_config.addr.clone(),
|
||||
config.sequencer_client_config.auth.clone(),
|
||||
)?;
|
||||
|
||||
let start_block = sequencer_client.get_genesis_block().await?;
|
||||
|
||||
let initial_commitments: Vec<nssa_core::Commitment> = config
|
||||
.initial_commitments
|
||||
.iter()
|
||||
.map(|init_comm_data| {
|
||||
let npk = &init_comm_data.npk;
|
||||
|
||||
let mut acc = init_comm_data.account.clone();
|
||||
|
||||
acc.program_owner = nssa::program::Program::authenticated_transfer_program().id();
|
||||
|
||||
nssa_core::Commitment::new(npk, &acc)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let init_accs: Vec<(nssa::AccountId, u128)> = config
|
||||
.initial_accounts
|
||||
.iter()
|
||||
.map(|acc_data| (acc_data.account_id.parse().unwrap(), acc_data.balance))
|
||||
.collect();
|
||||
|
||||
let mut state = nssa::V02State::new_with_genesis_accounts(&init_accs, &initial_commitments);
|
||||
|
||||
// ToDo: Remove after testnet
|
||||
state.add_pinata_program(PINATA_BASE58.parse().unwrap());
|
||||
|
||||
let home = config.home.clone();
|
||||
|
||||
Ok(Self {
|
||||
bedrock_client: BedrockClient::new(
|
||||
config.bedrock_client_config.auth.clone().map(Into::into),
|
||||
config.bedrock_client_config.addr.clone(),
|
||||
)?,
|
||||
sequencer_client,
|
||||
config,
|
||||
// ToDo: Implement restarts
|
||||
store: IndexerStore::open_db_with_genesis(&home, Some((start_block, state)))?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn subscribe_parse_block_stream(&self) -> Result<()> {
|
||||
loop {
|
||||
let mut stream_pinned = Box::pin(self.bedrock_client.get_lib_stream().await?);
|
||||
|
||||
info!("Block stream joined");
|
||||
|
||||
while let Some(block_info) = stream_pinned.next().await {
|
||||
let header_id = block_info.header_id;
|
||||
|
||||
info!("Observed L1 block at height {}", block_info.height);
|
||||
|
||||
if let Some(l1_block) = self
|
||||
.bedrock_client
|
||||
.get_block_by_id(header_id, &self.config.backoff)
|
||||
.await?
|
||||
{
|
||||
info!("Extracted L1 block at height {}", block_info.height);
|
||||
|
||||
let l2_blocks_parsed = parse_blocks(
|
||||
l1_block.into_transactions().into_iter(),
|
||||
&self.config.channel_id,
|
||||
);
|
||||
|
||||
for l2_block in l2_blocks_parsed {
|
||||
let l2_block_height = l2_block.header.block_id;
|
||||
|
||||
// State modification, will be updated in future
|
||||
self.store.put_block(l2_block)?;
|
||||
|
||||
// Sending data into sequencer, may need to be expanded.
|
||||
let message = Message::L2BlockFinalized { l2_block_height };
|
||||
|
||||
let status = self.send_message_to_sequencer(message.clone()).await?;
|
||||
|
||||
info!("Sent message {message:#?} to sequencer; status {status:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch stream after delay
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
self.config.resubscribe_interval_millis,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_message_to_sequencer(
|
||||
&self,
|
||||
message: Message,
|
||||
) -> Result<PostIndexerMessageResponse> {
|
||||
Ok(self.sequencer_client.post_indexer_message(message).await?)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_blocks(
|
||||
block_txs: impl Iterator<Item = SignedMantleTx>,
|
||||
decoded_channel_id: &ChannelId,
|
||||
) -> impl Iterator<Item = Block> {
|
||||
block_txs.flat_map(|tx| {
|
||||
tx.mantle_tx.ops.into_iter().filter_map(|op| match op {
|
||||
Op::ChannelInscribe(InscriptionOp {
|
||||
channel_id,
|
||||
inscription,
|
||||
..
|
||||
}) if channel_id == *decoded_channel_id => {
|
||||
borsh::from_slice::<Block>(&inscription).ok()
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "indexer_service"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
indexer_service_protocol.workspace = true
|
||||
indexer_service_rpc = { workspace = true, features = ["server"] }
|
||||
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
env_logger.workspace = true
|
||||
log.workspace = true
|
||||
jsonrpsee.workspace = true
|
||||
async-trait = "0.1.89"
|
||||
@ -1 +0,0 @@
|
||||
pub mod service;
|
||||
@ -1,36 +0,0 @@
|
||||
use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction};
|
||||
use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned};
|
||||
|
||||
pub struct IndexerService;
|
||||
|
||||
// `async_trait` is required by `jsonrpsee`
|
||||
#[async_trait::async_trait]
|
||||
impl indexer_service_rpc::RpcServer for IndexerService {
|
||||
async fn subscribe_to_blocks(
|
||||
&self,
|
||||
_subscription_sink: jsonrpsee::PendingSubscriptionSink,
|
||||
_from: BlockId,
|
||||
) -> SubscriptionResult {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn get_block_by_id(&self, _block_id: BlockId) -> Result<Block, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn get_block_by_hash(&self, _block_hash: Hash) -> Result<Block, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn get_last_block_id(&self) -> Result<BlockId, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn get_account(&self, _account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn get_transaction(&self, _tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@ sequencer_runner.workspace = true
|
||||
wallet.workspace = true
|
||||
common.workspace = true
|
||||
key_protocol.workspace = true
|
||||
indexer_core.workspace = true
|
||||
indexer_service.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
|
||||
39
integration_tests/src/config.rs
Normal file
39
integration_tests/src/config.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Result;
|
||||
use indexer_service::{BackoffConfig, BedrockClientConfig, ChannelId, IndexerConfig};
|
||||
use url::Url;
|
||||
|
||||
pub fn indexer_config(bedrock_addr: SocketAddr) -> IndexerConfig {
|
||||
let channel_id: [u8; 32] = [0u8, 1]
|
||||
.repeat(16)
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
let channel_id = ChannelId::try_from(channel_id).expect("Failed to create channel ID");
|
||||
|
||||
IndexerConfig {
|
||||
resubscribe_interval_millis: 1000,
|
||||
backoff: BackoffConfig {
|
||||
start_delay_millis: 100,
|
||||
max_retries: 10,
|
||||
},
|
||||
bedrock_client_config: BedrockClientConfig {
|
||||
addr: addr_to_http_url(bedrock_addr).expect("Failed to convert bedrock addr to URL"),
|
||||
auth: None,
|
||||
},
|
||||
channel_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn addr_to_http_url(addr: SocketAddr) -> Result<Url> {
|
||||
// Convert 0.0.0.0 to 127.0.0.1 for client connections
|
||||
// When binding to port 0, the server binds to 0.0.0.0:<random_port>
|
||||
// but clients need to connect to 127.0.0.1:<port> to work reliably
|
||||
let url_string = if addr.ip().is_unspecified() {
|
||||
format!("http://127.0.0.1:{}", addr.port())
|
||||
} else {
|
||||
format!("http://{addr}")
|
||||
};
|
||||
|
||||
url_string.parse().map_err(Into::into)
|
||||
}
|
||||
@ -2,24 +2,24 @@
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::LazyLock};
|
||||
|
||||
use actix_web::dev::ServerHandle;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use common::{
|
||||
sequencer_client::SequencerClient,
|
||||
transaction::{EncodedTransaction, NSSATransaction},
|
||||
};
|
||||
use futures::FutureExt as _;
|
||||
use indexer_core::{IndexerCore, config::IndexerConfig};
|
||||
use indexer_service::IndexerHandle;
|
||||
use log::debug;
|
||||
use nssa::PrivacyPreservingTransaction;
|
||||
use nssa_core::Commitment;
|
||||
use sequencer_core::config::SequencerConfig;
|
||||
use sequencer_runner::SequencerHandle;
|
||||
use tempfile::TempDir;
|
||||
use tokio::task::JoinHandle;
|
||||
use url::Url;
|
||||
use wallet::{WalletCore, config::WalletConfigOverrides};
|
||||
|
||||
mod config;
|
||||
|
||||
// TODO: Remove this and control time from tests
|
||||
pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12;
|
||||
|
||||
@ -38,19 +38,17 @@ static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init);
|
||||
/// It's memory and logically safe to create multiple instances of this struct in parallel tests,
|
||||
/// as each instance uses its own temporary directories for sequencer and wallet data.
|
||||
pub struct TestContext {
|
||||
sequencer_server_handle: ServerHandle,
|
||||
sequencer_loop_handle: JoinHandle<Result<()>>,
|
||||
sequencer_retry_pending_blocks_handle: JoinHandle<Result<()>>,
|
||||
indexer_loop_handle: Option<JoinHandle<Result<()>>>,
|
||||
sequencer_client: SequencerClient,
|
||||
wallet: WalletCore,
|
||||
_sequencer_handle: SequencerHandle,
|
||||
_indexer_handle: IndexerHandle,
|
||||
_temp_sequencer_dir: TempDir,
|
||||
_temp_wallet_dir: TempDir,
|
||||
_temp_indexer_dir: Option<TempDir>,
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
/// Create new test context in detached mode. Default.
|
||||
/// Create new test context.
|
||||
pub async fn new() -> Result<Self> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
|
||||
@ -60,51 +58,26 @@ impl TestContext {
|
||||
let sequencer_config = SequencerConfig::from_path(&sequencer_config_path)
|
||||
.context("Failed to create sequencer config from file")?;
|
||||
|
||||
Self::new_with_sequencer_and_maybe_indexer_configs(sequencer_config, None).await
|
||||
Self::new_with_sequencer_config(sequencer_config).await
|
||||
}
|
||||
|
||||
/// Create new test context in local bedrock node attached mode.
|
||||
pub async fn new_bedrock_local_attached() -> Result<Self> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
|
||||
let sequencer_config_path = PathBuf::from(manifest_dir)
|
||||
.join("configs/sequencer/bedrock_local_attached/sequencer_config.json");
|
||||
|
||||
let sequencer_config = SequencerConfig::from_path(&sequencer_config_path)
|
||||
.context("Failed to create sequencer config from file")?;
|
||||
|
||||
let indexer_config_path =
|
||||
PathBuf::from(manifest_dir).join("configs/indexer/indexer_config.json");
|
||||
|
||||
let indexer_config = IndexerConfig::from_path(&indexer_config_path)
|
||||
.context("Failed to create indexer config from file")?;
|
||||
|
||||
Self::new_with_sequencer_and_maybe_indexer_configs(sequencer_config, Some(indexer_config))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create new test context with custom sequencer config and maybe indexer config.
|
||||
/// Create new test context with custom sequencer config.
|
||||
///
|
||||
/// `home` and `port` fields of the provided config will be overridden to meet tests parallelism
|
||||
/// requirements.
|
||||
pub async fn new_with_sequencer_and_maybe_indexer_configs(
|
||||
sequencer_config: SequencerConfig,
|
||||
indexer_config: Option<IndexerConfig>,
|
||||
) -> Result<Self> {
|
||||
pub async fn new_with_sequencer_config(sequencer_config: SequencerConfig) -> Result<Self> {
|
||||
// Ensure logger is initialized only once
|
||||
*LOGGER;
|
||||
|
||||
debug!("Test context setup");
|
||||
|
||||
let (
|
||||
sequencer_server_handle,
|
||||
sequencer_addr,
|
||||
sequencer_loop_handle,
|
||||
sequencer_retry_pending_blocks_handle,
|
||||
temp_sequencer_dir,
|
||||
) = Self::setup_sequencer(sequencer_config)
|
||||
.await
|
||||
.context("Failed to setup sequencer")?;
|
||||
let bedrock_addr = todo!();
|
||||
let indexer_config = config::indexer_config(bedrock_addr);
|
||||
|
||||
let (_sequencer_handle, sequencer_addr, temp_sequencer_dir) =
|
||||
Self::setup_sequencer(sequencer_config)
|
||||
.await
|
||||
.context("Failed to setup sequencer")?;
|
||||
|
||||
// Convert 0.0.0.0 to 127.0.0.1 for client connections
|
||||
// When binding to port 0, the server binds to 0.0.0.0:<random_port>
|
||||
@ -124,57 +97,23 @@ impl TestContext {
|
||||
)
|
||||
.context("Failed to create sequencer client")?;
|
||||
|
||||
if let Some(mut indexer_config) = indexer_config {
|
||||
indexer_config.sequencer_client_config.addr =
|
||||
Url::parse(&sequencer_addr).context("Failed to parse sequencer addr")?;
|
||||
let _indexer_handle = indexer_service::run_server(indexer_config, 0)
|
||||
.await
|
||||
.context("Failed to run Indexer Service")?;
|
||||
|
||||
let temp_indexer_dir =
|
||||
tempfile::tempdir().context("Failed to create temp dir for indexer home")?;
|
||||
|
||||
debug!("Using temp indexer home at {:?}", temp_indexer_dir.path());
|
||||
indexer_config.home = temp_indexer_dir.path().to_owned();
|
||||
|
||||
let indexer_core = IndexerCore::new(indexer_config).await?;
|
||||
|
||||
let indexer_loop_handle = Some(tokio::spawn(async move {
|
||||
indexer_core.subscribe_parse_block_stream().await
|
||||
}));
|
||||
|
||||
Ok(Self {
|
||||
sequencer_server_handle,
|
||||
sequencer_loop_handle,
|
||||
sequencer_retry_pending_blocks_handle,
|
||||
indexer_loop_handle,
|
||||
sequencer_client,
|
||||
wallet,
|
||||
_temp_sequencer_dir: temp_sequencer_dir,
|
||||
_temp_wallet_dir: temp_wallet_dir,
|
||||
_temp_indexer_dir: Some(temp_indexer_dir),
|
||||
})
|
||||
} else {
|
||||
Ok(Self {
|
||||
sequencer_server_handle,
|
||||
sequencer_loop_handle,
|
||||
sequencer_retry_pending_blocks_handle,
|
||||
indexer_loop_handle: None,
|
||||
sequencer_client,
|
||||
wallet,
|
||||
_temp_sequencer_dir: temp_sequencer_dir,
|
||||
_temp_wallet_dir: temp_wallet_dir,
|
||||
_temp_indexer_dir: None,
|
||||
})
|
||||
}
|
||||
Ok(Self {
|
||||
sequencer_client,
|
||||
wallet,
|
||||
_sequencer_handle,
|
||||
_indexer_handle,
|
||||
_temp_sequencer_dir: temp_sequencer_dir,
|
||||
_temp_wallet_dir: temp_wallet_dir,
|
||||
})
|
||||
}
|
||||
|
||||
async fn setup_sequencer(
|
||||
mut config: SequencerConfig,
|
||||
) -> Result<(
|
||||
ServerHandle,
|
||||
SocketAddr,
|
||||
JoinHandle<Result<()>>,
|
||||
JoinHandle<Result<()>>,
|
||||
TempDir,
|
||||
)> {
|
||||
) -> Result<(SequencerHandle, SocketAddr, TempDir)> {
|
||||
let temp_sequencer_dir =
|
||||
tempfile::tempdir().context("Failed to create temp dir for sequencer home")?;
|
||||
|
||||
@ -186,20 +125,10 @@ impl TestContext {
|
||||
// Setting port to 0 lets the OS choose a free port for us
|
||||
config.port = 0;
|
||||
|
||||
let (
|
||||
sequencer_server_handle,
|
||||
sequencer_addr,
|
||||
sequencer_loop_handle,
|
||||
sequencer_retry_pending_blocks_handle,
|
||||
) = sequencer_runner::startup_sequencer(config).await?;
|
||||
let (sequencer_handle, sequencer_addr) =
|
||||
sequencer_runner::startup_sequencer(config).await?;
|
||||
|
||||
Ok((
|
||||
sequencer_server_handle,
|
||||
sequencer_addr,
|
||||
sequencer_loop_handle,
|
||||
sequencer_retry_pending_blocks_handle,
|
||||
temp_sequencer_dir,
|
||||
))
|
||||
Ok((sequencer_handle, sequencer_addr, temp_sequencer_dir))
|
||||
}
|
||||
|
||||
async fn setup_wallet(sequencer_addr: String) -> Result<(WalletCore, TempDir)> {
|
||||
@ -251,33 +180,6 @@ impl TestContext {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestContext {
|
||||
fn drop(&mut self) {
|
||||
debug!("Test context cleanup");
|
||||
|
||||
let Self {
|
||||
sequencer_server_handle,
|
||||
sequencer_loop_handle,
|
||||
sequencer_retry_pending_blocks_handle,
|
||||
indexer_loop_handle,
|
||||
sequencer_client: _,
|
||||
wallet: _,
|
||||
_temp_sequencer_dir,
|
||||
_temp_wallet_dir,
|
||||
_temp_indexer_dir,
|
||||
} = self;
|
||||
|
||||
sequencer_loop_handle.abort();
|
||||
sequencer_retry_pending_blocks_handle.abort();
|
||||
if let Some(indexer_loop_handle) = indexer_loop_handle {
|
||||
indexer_loop_handle.abort();
|
||||
}
|
||||
|
||||
// Can't wait here as Drop can't be async, but anyway stop signal should be sent
|
||||
sequencer_server_handle.stop(true).now_or_never();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_public_account_id(account_id: &str) -> String {
|
||||
format!("Public/{account_id}")
|
||||
}
|
||||
|
||||
@ -192,6 +192,7 @@ impl TpsTestManager {
|
||||
signing_key: [37; 32],
|
||||
bedrock_config: None,
|
||||
retry_pending_blocks_timeout_millis: 1000 * 60 * 4,
|
||||
indexer_rpc_url: "http://localhost:8779".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -9,6 +9,7 @@ nssa_core.workspace = true
|
||||
common.workspace = true
|
||||
storage.workspace = true
|
||||
mempool.workspace = true
|
||||
bedrock_client.workspace = true
|
||||
|
||||
base58.workspace = true
|
||||
anyhow.workspace = true
|
||||
@ -18,12 +19,13 @@ tempfile.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
bedrock_client.workspace = true
|
||||
logos-blockchain-key-management-system-service.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
rand.workspace = true
|
||||
reqwest.workspace = true
|
||||
borsh.workspace = true
|
||||
url.workspace = true
|
||||
jsonrpsee = { workspace = true, features = ["http-client"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@ -20,7 +20,7 @@ impl SequencerStore {
|
||||
/// ATTENTION: Will overwrite genesis block.
|
||||
pub fn open_db_with_genesis(
|
||||
location: &Path,
|
||||
genesis_block: Option<Block>,
|
||||
genesis_block: Option<&Block>,
|
||||
signing_key: nssa::PrivateKey,
|
||||
) -> Result<Self> {
|
||||
let tx_hash_to_block_map = if let Some(block) = &genesis_block {
|
||||
@ -84,8 +84,8 @@ impl SequencerStore {
|
||||
self.dbio.get_all_blocks().map(|res| Ok(res?))
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, block: Block, state: &V02State) -> Result<()> {
|
||||
let new_transactions_map = block_to_transactions_map(&block);
|
||||
pub(crate) fn update(&mut self, block: &Block, state: &V02State) -> Result<()> {
|
||||
let new_transactions_map = block_to_transactions_map(block);
|
||||
self.dbio.atomic_update(block, state)?;
|
||||
self.tx_hash_to_block_map.extend(new_transactions_map);
|
||||
Ok(())
|
||||
@ -129,7 +129,7 @@ mod tests {
|
||||
let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]);
|
||||
// Start an empty node store
|
||||
let mut node_store =
|
||||
SequencerStore::open_db_with_genesis(path, Some(genesis_block), signing_key).unwrap();
|
||||
SequencerStore::open_db_with_genesis(path, Some(&genesis_block), signing_key).unwrap();
|
||||
|
||||
let tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]);
|
||||
@ -139,7 +139,7 @@ mod tests {
|
||||
assert_eq!(None, retrieved_tx);
|
||||
// Add the block with the transaction
|
||||
let dummy_state = V02State::new_with_genesis_accounts(&[], &[]);
|
||||
node_store.update(block, &dummy_state).unwrap();
|
||||
node_store.update(&block, &dummy_state).unwrap();
|
||||
// Try again
|
||||
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
|
||||
assert_eq!(Some(tx), retrieved_tx);
|
||||
|
||||
@ -11,6 +11,7 @@ use common::{
|
||||
};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
// TODO: Provide default values
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@ -41,6 +42,8 @@ pub struct SequencerConfig {
|
||||
pub signing_key: [u8; 32],
|
||||
/// Bedrock configuration options
|
||||
pub bedrock_config: Option<BedrockConfig>,
|
||||
/// Indexer RPC URL
|
||||
pub indexer_rpc_url: Url,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@ -48,7 +51,7 @@ pub struct BedrockConfig {
|
||||
/// Bedrock channel ID
|
||||
pub channel_id: ChannelId,
|
||||
/// Bedrock Url
|
||||
pub node_url: String,
|
||||
pub node_url: Url,
|
||||
/// Bedrock auth
|
||||
pub auth: Option<BasicAuth>,
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use std::time::Instant;
|
||||
use std::{fmt::Display, sync::Arc, time::Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(feature = "testnet")]
|
||||
@ -17,6 +17,8 @@ mod block_settlement_client;
|
||||
pub mod block_store;
|
||||
pub mod config;
|
||||
|
||||
type IndexerClient = Arc<jsonrpsee::ws_client::WsClient>;
|
||||
|
||||
pub struct SequencerCore {
|
||||
state: nssa::V02State,
|
||||
store: SequencerStore,
|
||||
@ -24,6 +26,7 @@ pub struct SequencerCore {
|
||||
sequencer_config: SequencerConfig,
|
||||
chain_height: u64,
|
||||
block_settlement_client: Option<BlockSettlementClient>,
|
||||
indexer_client: IndexerClient,
|
||||
last_bedrock_msg_id: MantleMsgId,
|
||||
}
|
||||
|
||||
@ -33,7 +36,9 @@ impl SequencerCore {
|
||||
/// assumed to represent the correct latest state consistent with Bedrock-finalized data.
|
||||
/// If no database is found, the sequencer performs a fresh start from genesis,
|
||||
/// initializing its state with the accounts defined in the configuration file.
|
||||
pub fn start_from_config(config: SequencerConfig) -> (Self, MemPoolHandle<EncodedTransaction>) {
|
||||
pub async fn start_from_config(
|
||||
config: SequencerConfig,
|
||||
) -> (Self, MemPoolHandle<EncodedTransaction>) {
|
||||
let hashable_data = HashableBlockData {
|
||||
block_id: config.genesis_id,
|
||||
transactions: vec![],
|
||||
@ -49,7 +54,7 @@ impl SequencerCore {
|
||||
// as fixing this issue may require actions non-native to program scope
|
||||
let store = SequencerStore::open_db_with_genesis(
|
||||
&config.home.join("rocksdb"),
|
||||
Some(genesis_block),
|
||||
Some(&genesis_block),
|
||||
signing_key,
|
||||
)
|
||||
.unwrap();
|
||||
@ -97,6 +102,22 @@ impl SequencerCore {
|
||||
.expect("Block settlement client should be constructible")
|
||||
});
|
||||
|
||||
let last_bedrock_msg_id = if let Some(client) = block_settlement_client.as_ref() {
|
||||
let (_, msg_id) = client
|
||||
.create_inscribe_tx(&genesis_block)
|
||||
.expect("Inscription transaction with genesis block should be constructible");
|
||||
msg_id.into()
|
||||
} else {
|
||||
channel_genesis_msg_id
|
||||
};
|
||||
|
||||
let indexer_client = Arc::new(
|
||||
jsonrpsee::ws_client::WsClientBuilder::default()
|
||||
.build(config.indexer_rpc_url.clone())
|
||||
.await
|
||||
.expect("Failed to create Indexer client"),
|
||||
);
|
||||
|
||||
let sequencer_core = Self {
|
||||
state,
|
||||
store,
|
||||
@ -104,7 +125,8 @@ impl SequencerCore {
|
||||
chain_height: config.genesis_id,
|
||||
sequencer_config: config,
|
||||
block_settlement_client,
|
||||
last_bedrock_msg_id: channel_genesis_msg_id,
|
||||
indexer_client,
|
||||
last_bedrock_msg_id,
|
||||
};
|
||||
|
||||
(sequencer_core, mempool_handle)
|
||||
@ -129,11 +151,9 @@ impl SequencerCore {
|
||||
}
|
||||
|
||||
pub async fn produce_new_block_and_post_to_settlement_layer(&mut self) -> Result<u64> {
|
||||
let block_data = self.produce_new_block_with_mempool_transactions()?;
|
||||
let block = self.produce_new_block_with_mempool_transactions()?;
|
||||
|
||||
if let Some(client) = self.block_settlement_client.as_mut() {
|
||||
let block =
|
||||
block_data.into_pending_block(self.store.signing_key(), self.last_bedrock_msg_id);
|
||||
let msg_id = client.submit_block_to_bedrock(&block).await?;
|
||||
self.last_bedrock_msg_id = msg_id.into();
|
||||
log::info!("Posted block data to Bedrock");
|
||||
@ -143,7 +163,7 @@ impl SequencerCore {
|
||||
}
|
||||
|
||||
/// Produces new block from transactions in mempool
|
||||
pub fn produce_new_block_with_mempool_transactions(&mut self) -> Result<HashableBlockData> {
|
||||
pub fn produce_new_block_with_mempool_transactions(&mut self) -> Result<Block> {
|
||||
let now = Instant::now();
|
||||
|
||||
let new_block_height = self.chain_height + 1;
|
||||
@ -180,7 +200,7 @@ impl SequencerCore {
|
||||
.clone()
|
||||
.into_pending_block(self.store.signing_key(), self.last_bedrock_msg_id);
|
||||
|
||||
self.store.update(block, &self.state)?;
|
||||
self.store.update(&block, &self.state)?;
|
||||
|
||||
self.chain_height = new_block_height;
|
||||
|
||||
@ -199,7 +219,7 @@ impl SequencerCore {
|
||||
hashable_data.transactions.len(),
|
||||
now.elapsed().as_secs()
|
||||
);
|
||||
Ok(hashable_data)
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &nssa::V02State {
|
||||
@ -229,6 +249,10 @@ impl SequencerCore {
|
||||
.map(|block| block.header.block_id)
|
||||
.min()
|
||||
{
|
||||
info!(
|
||||
"Clearing pending blocks up to id: {}",
|
||||
last_finalized_block_id
|
||||
);
|
||||
(first_pending_block_id..=last_finalized_block_id)
|
||||
.try_for_each(|id| self.store.delete_block_at_id(id))
|
||||
} else {
|
||||
@ -250,6 +274,10 @@ impl SequencerCore {
|
||||
pub fn block_settlement_client(&self) -> Option<BlockSettlementClient> {
|
||||
self.block_settlement_client.clone()
|
||||
}
|
||||
|
||||
pub fn indexer_client(&self) -> IndexerClient {
|
||||
Arc::clone(&self.indexer_client)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -291,6 +319,7 @@ mod tests {
|
||||
signing_key: *sequencer_sign_key_for_testing().value(),
|
||||
bedrock_config: None,
|
||||
retry_pending_blocks_timeout_millis: 1000 * 60 * 4,
|
||||
indexer_rpc_url: "http://localhost:8779".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,7 +365,7 @@ mod tests {
|
||||
async fn common_setup_with_config(
|
||||
config: SequencerConfig,
|
||||
) -> (SequencerCore, MemPoolHandle<EncodedTransaction>) {
|
||||
let (mut sequencer, mempool_handle) = SequencerCore::start_from_config(config);
|
||||
let (mut sequencer, mempool_handle) = SequencerCore::start_from_config(config).await;
|
||||
|
||||
let tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
mempool_handle.push(tx).await.unwrap();
|
||||
@ -348,10 +377,10 @@ mod tests {
|
||||
(sequencer, mempool_handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_from_config() {
|
||||
#[tokio::test]
|
||||
async fn test_start_from_config() {
|
||||
let config = setup_sequencer_config();
|
||||
let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone());
|
||||
let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()).await;
|
||||
|
||||
assert_eq!(sequencer.chain_height, config.genesis_id);
|
||||
assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10);
|
||||
@ -385,8 +414,8 @@ mod tests {
|
||||
assert_eq!(20000, balance_acc_2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_different_intial_accounts_balances() {
|
||||
#[tokio::test]
|
||||
async fn test_start_different_intial_accounts_balances() {
|
||||
let acc1_account_id: Vec<u8> = vec![
|
||||
27, 132, 197, 86, 123, 18, 100, 64, 153, 93, 62, 213, 170, 186, 5, 101, 215, 30, 24,
|
||||
52, 96, 72, 25, 255, 156, 23, 245, 233, 213, 221, 7, 143,
|
||||
@ -410,7 +439,7 @@ mod tests {
|
||||
let initial_accounts = vec![initial_acc1, initial_acc2];
|
||||
|
||||
let config = setup_sequencer_config_variable_initial_accounts(initial_accounts);
|
||||
let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone());
|
||||
let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()).await;
|
||||
|
||||
let acc1_account_id = config.initial_accounts[0]
|
||||
.account_id
|
||||
@ -635,7 +664,7 @@ mod tests {
|
||||
|
||||
let block = sequencer.produce_new_block_with_mempool_transactions();
|
||||
assert!(block.is_ok());
|
||||
assert_eq!(block.unwrap().block_id, genesis_height + 1);
|
||||
assert_eq!(block.unwrap().header.block_id, genesis_height + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -673,6 +702,7 @@ mod tests {
|
||||
let current_height = sequencer
|
||||
.produce_new_block_with_mempool_transactions()
|
||||
.unwrap()
|
||||
.header
|
||||
.block_id;
|
||||
let block = sequencer.store.get_block_at_id(current_height).unwrap();
|
||||
|
||||
@ -710,6 +740,7 @@ mod tests {
|
||||
let current_height = sequencer
|
||||
.produce_new_block_with_mempool_transactions()
|
||||
.unwrap()
|
||||
.header
|
||||
.block_id;
|
||||
let block = sequencer.store.get_block_at_id(current_height).unwrap();
|
||||
assert_eq!(block.body.transactions, vec![tx.clone()]);
|
||||
@ -719,6 +750,7 @@ mod tests {
|
||||
let current_height = sequencer
|
||||
.produce_new_block_with_mempool_transactions()
|
||||
.unwrap()
|
||||
.header
|
||||
.block_id;
|
||||
let block = sequencer.store.get_block_at_id(current_height).unwrap();
|
||||
assert!(block.body.transactions.is_empty());
|
||||
@ -737,7 +769,8 @@ mod tests {
|
||||
// from `acc_1` to `acc_2`. The block created with that transaction will be kept stored in
|
||||
// the temporary directory for the block storage of this test.
|
||||
{
|
||||
let (mut sequencer, mempool_handle) = SequencerCore::start_from_config(config.clone());
|
||||
let (mut sequencer, mempool_handle) =
|
||||
SequencerCore::start_from_config(config.clone()).await;
|
||||
let signing_key = PrivateKey::try_new([1; 32]).unwrap();
|
||||
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
@ -752,6 +785,7 @@ mod tests {
|
||||
let current_height = sequencer
|
||||
.produce_new_block_with_mempool_transactions()
|
||||
.unwrap()
|
||||
.header
|
||||
.block_id;
|
||||
let block = sequencer.store.get_block_at_id(current_height).unwrap();
|
||||
assert_eq!(block.body.transactions, vec![tx.clone()]);
|
||||
@ -759,7 +793,7 @@ mod tests {
|
||||
|
||||
// Instantiating a new sequencer from the same config. This should load the existing block
|
||||
// with the above transaction and update the state to reflect that.
|
||||
let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone());
|
||||
let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()).await;
|
||||
let balance_acc_1 = sequencer.state.get_account_by_id(&acc1_account_id).balance;
|
||||
let balance_acc_2 = sequencer.state.get_account_by_id(&acc2_account_id).balance;
|
||||
|
||||
@ -774,10 +808,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pending_blocks() {
|
||||
#[tokio::test]
|
||||
async fn test_get_pending_blocks() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config);
|
||||
let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config).await;
|
||||
sequencer
|
||||
.produce_new_block_with_mempool_transactions()
|
||||
.unwrap();
|
||||
@ -790,10 +824,10 @@ mod tests {
|
||||
assert_eq!(sequencer.get_pending_blocks().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_blocks() {
|
||||
#[tokio::test]
|
||||
async fn test_delete_blocks() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config);
|
||||
let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config).await;
|
||||
sequencer
|
||||
.produce_new_block_with_mempool_transactions()
|
||||
.unwrap();
|
||||
|
||||
@ -14,12 +14,12 @@ use common::{
|
||||
GetAccountBalanceRequest, GetAccountBalanceResponse, GetAccountRequest,
|
||||
GetAccountResponse, GetAccountsNoncesRequest, GetAccountsNoncesResponse,
|
||||
GetBlockDataRequest, GetBlockDataResponse, GetBlockRangeDataRequest,
|
||||
GetBlockRangeDataResponse, GetGenesisBlockRequest, GetGenesisBlockResponse,
|
||||
GetGenesisIdRequest, GetGenesisIdResponse, GetInitialTestnetAccountsRequest,
|
||||
GetLastBlockRequest, GetLastBlockResponse, GetProgramIdsRequest, GetProgramIdsResponse,
|
||||
GetProofForCommitmentRequest, GetProofForCommitmentResponse,
|
||||
GetTransactionByHashRequest, GetTransactionByHashResponse, HelloRequest, HelloResponse,
|
||||
PostIndexerMessageRequest, PostIndexerMessageResponse, SendTxRequest, SendTxResponse,
|
||||
GetBlockRangeDataResponse, GetGenesisIdRequest, GetGenesisIdResponse,
|
||||
GetInitialTestnetAccountsRequest, GetLastBlockRequest, GetLastBlockResponse,
|
||||
GetProgramIdsRequest, GetProgramIdsResponse, GetProofForCommitmentRequest,
|
||||
GetProofForCommitmentResponse, GetTransactionByHashRequest,
|
||||
GetTransactionByHashResponse, HelloRequest, HelloResponse, SendTxRequest,
|
||||
SendTxResponse,
|
||||
},
|
||||
},
|
||||
transaction::{
|
||||
@ -46,7 +46,6 @@ pub const GET_ACCOUNTS_NONCES: &str = "get_accounts_nonces";
|
||||
pub const GET_ACCOUNT: &str = "get_account";
|
||||
pub const GET_PROOF_FOR_COMMITMENT: &str = "get_proof_for_commitment";
|
||||
pub const GET_PROGRAM_IDS: &str = "get_program_ids";
|
||||
pub const POST_INDEXER_MESSAGE: &str = "post_indexer_message";
|
||||
|
||||
pub const HELLO_FROM_SEQUENCER: &str = "HELLO_FROM_SEQUENCER";
|
||||
|
||||
@ -336,18 +335,6 @@ impl JsonHandler {
|
||||
respond(response)
|
||||
}
|
||||
|
||||
async fn process_indexer_message(&self, request: Request) -> Result<Value, RpcErr> {
|
||||
let _indexer_post_req = PostIndexerMessageRequest::parse(Some(request.params))?;
|
||||
|
||||
// ToDo: Add indexer messages handling
|
||||
|
||||
let response = PostIndexerMessageResponse {
|
||||
status: "Success".to_string(),
|
||||
};
|
||||
|
||||
respond(response)
|
||||
}
|
||||
|
||||
pub async fn process_request_internal(&self, request: Request) -> Result<Value, RpcErr> {
|
||||
match request.method.as_ref() {
|
||||
HELLO => self.process_temp_hello(request).await,
|
||||
@ -364,7 +351,6 @@ impl JsonHandler {
|
||||
GET_TRANSACTION_BY_HASH => self.process_get_transaction_by_hash(request).await,
|
||||
GET_PROOF_FOR_COMMITMENT => self.process_get_proof_by_commitment(request).await,
|
||||
GET_PROGRAM_IDS => self.process_get_program_ids(request).await,
|
||||
POST_INDEXER_MESSAGE => self.process_indexer_message(request).await,
|
||||
_ => Err(RpcErr(RpcError::method_not_found(request.method))),
|
||||
}
|
||||
}
|
||||
@ -377,8 +363,8 @@ mod tests {
|
||||
use base58::ToBase58;
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use common::{
|
||||
block::AccountInitialData, sequencer_client::BasicAuth,
|
||||
test_utils::sequencer_sign_key_for_testing, transaction::EncodedTransaction,
|
||||
config::BasicAuth, test_utils::sequencer_sign_key_for_testing,
|
||||
transaction::EncodedTransaction,
|
||||
};
|
||||
use sequencer_core::{
|
||||
SequencerCore,
|
||||
@ -430,19 +416,20 @@ mod tests {
|
||||
retry_pending_blocks_timeout_millis: 1000 * 60 * 4,
|
||||
bedrock_config: Some(BedrockConfig {
|
||||
channel_id: [42; 32].into(),
|
||||
node_url: "http://localhost:8080".to_string(),
|
||||
node_url: "http://localhost:8080".parse().unwrap(),
|
||||
auth: Some(BasicAuth {
|
||||
username: "user".to_string(),
|
||||
password: None,
|
||||
}),
|
||||
}),
|
||||
indexer_rpc_url: "http://localhost:8779".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn components_for_tests() -> (JsonHandler, Vec<AccountInitialData>, EncodedTransaction) {
|
||||
let config = sequencer_config_for_tests();
|
||||
|
||||
let (mut sequencer_core, mempool_handle) = SequencerCore::start_from_config(config);
|
||||
let (mut sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await;
|
||||
let initial_accounts = sequencer_core.sequencer_config().initial_accounts.clone();
|
||||
|
||||
let signing_key = nssa::PrivateKey::try_new([1; 32]).unwrap();
|
||||
|
||||
@ -7,6 +7,8 @@ edition = "2024"
|
||||
common.workspace = true
|
||||
sequencer_core = { workspace = true, features = ["testnet"] }
|
||||
sequencer_rpc.workspace = true
|
||||
indexer_service_protocol.workspace = true
|
||||
indexer_service_rpc = { workspace = true, features = ["client"] }
|
||||
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
anyhow.workspace = true
|
||||
@ -15,3 +17,4 @@ log.workspace = true
|
||||
actix.workspace = true
|
||||
actix-web.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
@ -162,5 +162,6 @@
|
||||
"auth": {
|
||||
"username": "user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"indexer_rpc_url": "ws://localhost:8779"
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use actix_web::dev::ServerHandle;
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use clap::Parser;
|
||||
use common::rpc_primitives::RpcConfig;
|
||||
use log::{info, warn};
|
||||
use futures::{FutureExt as _, never::Never};
|
||||
use log::{error, info, warn};
|
||||
use sequencer_core::{SequencerCore, config::SequencerConfig};
|
||||
use sequencer_rpc::new_http_server;
|
||||
use tokio::{sync::Mutex, task::JoinHandle};
|
||||
@ -18,19 +19,75 @@ struct Args {
|
||||
home_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Handle to manage the sequencer and its tasks.
|
||||
///
|
||||
/// Implements `Drop` to ensure all tasks are aborted and the HTTP server is stopped when dropped.
|
||||
pub struct SequencerHandle {
|
||||
http_server_handle: ServerHandle,
|
||||
main_loop_handle: JoinHandle<Result<Never>>,
|
||||
retry_pending_blocks_loop_handle: JoinHandle<Result<Never>>,
|
||||
listen_for_bedrock_blocks_loop_handle: JoinHandle<Result<Never>>,
|
||||
}
|
||||
|
||||
impl SequencerHandle {
|
||||
/// Runs the sequencer indefinitely, monitoring its tasks.
|
||||
///
|
||||
/// If no error occurs, this function will never return.
|
||||
async fn run_forever(&mut self) -> Result<Never> {
|
||||
let Self {
|
||||
http_server_handle: _,
|
||||
main_loop_handle,
|
||||
retry_pending_blocks_loop_handle,
|
||||
listen_for_bedrock_blocks_loop_handle,
|
||||
} = self;
|
||||
|
||||
tokio::select! {
|
||||
res = main_loop_handle => {
|
||||
res
|
||||
.context("Main loop task panicked")?
|
||||
.context("Main loop exited unexpectedly")
|
||||
}
|
||||
res = retry_pending_blocks_loop_handle => {
|
||||
res
|
||||
.context("Retry pending blocks loop task panicked")?
|
||||
.context("Retry pending blocks loop exited unexpectedly")
|
||||
}
|
||||
res = listen_for_bedrock_blocks_loop_handle => {
|
||||
res
|
||||
.context("Listen for bedrock blocks loop task panicked")?
|
||||
.context("Listen for bedrock blocks loop exited unexpectedly")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SequencerHandle {
|
||||
fn drop(&mut self) {
|
||||
let Self {
|
||||
http_server_handle,
|
||||
main_loop_handle,
|
||||
retry_pending_blocks_loop_handle,
|
||||
listen_for_bedrock_blocks_loop_handle,
|
||||
} = self;
|
||||
|
||||
main_loop_handle.abort();
|
||||
retry_pending_blocks_loop_handle.abort();
|
||||
listen_for_bedrock_blocks_loop_handle.abort();
|
||||
|
||||
// Can't wait here as Drop can't be async, but anyway stop signal should be sent
|
||||
http_server_handle.stop(true).now_or_never();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn startup_sequencer(
|
||||
app_config: SequencerConfig,
|
||||
) -> Result<(
|
||||
ServerHandle,
|
||||
SocketAddr,
|
||||
JoinHandle<Result<()>>,
|
||||
JoinHandle<Result<()>>,
|
||||
)> {
|
||||
let block_timeout = app_config.block_create_timeout_millis;
|
||||
let retry_pending_blocks_timeout = app_config.retry_pending_blocks_timeout_millis;
|
||||
) -> Result<(SequencerHandle, SocketAddr)> {
|
||||
let block_timeout = Duration::from_millis(app_config.block_create_timeout_millis);
|
||||
let retry_pending_blocks_timeout =
|
||||
Duration::from_millis(app_config.retry_pending_blocks_timeout_millis);
|
||||
let port = app_config.port;
|
||||
|
||||
let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(app_config);
|
||||
let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(app_config).await;
|
||||
|
||||
info!("Sequencer core set up");
|
||||
|
||||
@ -45,69 +102,115 @@ pub async fn startup_sequencer(
|
||||
let http_server_handle = http_server.handle();
|
||||
tokio::spawn(http_server);
|
||||
|
||||
info!("Starting pending block retry loop");
|
||||
let seq_core_wrapped_for_block_retry = seq_core_wrapped.clone();
|
||||
let retry_pending_blocks_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
retry_pending_blocks_timeout,
|
||||
))
|
||||
.await;
|
||||
|
||||
let (pending_blocks, block_settlement_client) = {
|
||||
let sequencer_core = seq_core_wrapped_for_block_retry.lock().await;
|
||||
let client = sequencer_core.block_settlement_client();
|
||||
let pending_blocks = sequencer_core
|
||||
.get_pending_blocks()
|
||||
.expect("Sequencer should be able to retrieve pending blocks");
|
||||
(pending_blocks, client)
|
||||
};
|
||||
|
||||
let Some(client) = block_settlement_client else {
|
||||
continue;
|
||||
};
|
||||
|
||||
info!("Resubmitting {} pending blocks", pending_blocks.len());
|
||||
for block in &pending_blocks {
|
||||
if let Err(e) = client.submit_block_to_bedrock(block).await {
|
||||
warn!(
|
||||
"Failed to resubmit block with id {} with error {}",
|
||||
block.header.block_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!("Starting main sequencer loop");
|
||||
let main_loop_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(block_timeout)).await;
|
||||
let main_loop_handle = tokio::spawn(main_loop(Arc::clone(&seq_core_wrapped), block_timeout));
|
||||
|
||||
info!("Collecting transactions from mempool, block creation");
|
||||
info!("Starting pending block retry loop");
|
||||
let retry_pending_blocks_loop_handle = tokio::spawn(retry_pending_blocks_loop(
|
||||
Arc::clone(&seq_core_wrapped),
|
||||
retry_pending_blocks_timeout,
|
||||
));
|
||||
|
||||
let id = {
|
||||
let mut state = seq_core_wrapped.lock().await;
|
||||
|
||||
state
|
||||
.produce_new_block_and_post_to_settlement_layer()
|
||||
.await?
|
||||
};
|
||||
|
||||
info!("Block with id {id} created");
|
||||
|
||||
info!("Waiting for new transactions");
|
||||
}
|
||||
});
|
||||
info!("Starting bedrock block listening loop");
|
||||
let listen_for_bedrock_blocks_loop_handle =
|
||||
tokio::spawn(listen_for_bedrock_blocks_loop(seq_core_wrapped));
|
||||
|
||||
Ok((
|
||||
http_server_handle,
|
||||
SequencerHandle {
|
||||
http_server_handle,
|
||||
main_loop_handle,
|
||||
retry_pending_blocks_loop_handle,
|
||||
listen_for_bedrock_blocks_loop_handle,
|
||||
},
|
||||
addr,
|
||||
main_loop_handle,
|
||||
retry_pending_blocks_handle,
|
||||
))
|
||||
}
|
||||
|
||||
async fn main_loop(seq_core: Arc<Mutex<SequencerCore>>, block_timeout: Duration) -> Result<Never> {
|
||||
loop {
|
||||
tokio::time::sleep(block_timeout).await;
|
||||
|
||||
info!("Collecting transactions from mempool, block creation");
|
||||
|
||||
let id = {
|
||||
let mut state = seq_core.lock().await;
|
||||
|
||||
state
|
||||
.produce_new_block_and_post_to_settlement_layer()
|
||||
.await?
|
||||
};
|
||||
|
||||
info!("Block with id {id} created");
|
||||
|
||||
info!("Waiting for new transactions");
|
||||
}
|
||||
}
|
||||
|
||||
async fn retry_pending_blocks_loop(
|
||||
seq_core: Arc<Mutex<SequencerCore>>,
|
||||
retry_pending_blocks_timeout: Duration,
|
||||
) -> Result<Never> {
|
||||
loop {
|
||||
tokio::time::sleep(retry_pending_blocks_timeout).await;
|
||||
|
||||
let (pending_blocks, block_settlement_client) = {
|
||||
let sequencer_core = seq_core.lock().await;
|
||||
let client = sequencer_core.block_settlement_client();
|
||||
let pending_blocks = sequencer_core
|
||||
.get_pending_blocks()
|
||||
.expect("Sequencer should be able to retrieve pending blocks");
|
||||
(pending_blocks, client)
|
||||
};
|
||||
|
||||
let Some(client) = block_settlement_client else {
|
||||
continue;
|
||||
};
|
||||
|
||||
info!("Resubmitting {} pending blocks", pending_blocks.len());
|
||||
for block in &pending_blocks {
|
||||
if let Err(e) = client.submit_block_to_bedrock(block).await {
|
||||
warn!(
|
||||
"Failed to resubmit block with id {} with error {}",
|
||||
block.header.block_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn listen_for_bedrock_blocks_loop(seq_core: Arc<Mutex<SequencerCore>>) -> Result<Never> {
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
|
||||
let indexer_client = seq_core.lock().await.indexer_client();
|
||||
|
||||
loop {
|
||||
// TODO: Subscribe from the first pending block ID?
|
||||
let mut subscription = indexer_client
|
||||
.subscribe_to_finalized_blocks()
|
||||
.await
|
||||
.context("Failed to subscribe to finalized blocks")?;
|
||||
|
||||
while let Some(block_id) = subscription.next().await {
|
||||
let block_id = block_id.context("Failed to get next block from subscription")?;
|
||||
|
||||
info!("Received new L2 block with ID {block_id}");
|
||||
|
||||
seq_core
|
||||
.lock()
|
||||
.await
|
||||
.clean_finalized_blocks_from_db(block_id)
|
||||
.with_context(|| {
|
||||
format!("Failed to clean finalized blocks from DB for block ID {block_id}")
|
||||
})?;
|
||||
}
|
||||
|
||||
warn!(
|
||||
"Block subscription closed unexpectedly, reason: {:?}",
|
||||
subscription.close_reason()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn main_runner() -> Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
@ -125,24 +228,12 @@ pub async fn main_runner() -> Result<()> {
|
||||
}
|
||||
|
||||
// ToDo: Add restart on failures
|
||||
let (_, _, main_loop_handle, retry_loop_handle) = startup_sequencer(app_config).await?;
|
||||
let (mut sequencer_handle, _addr) = startup_sequencer(app_config).await?;
|
||||
|
||||
info!("Sequencer running. Monitoring concurrent tasks...");
|
||||
|
||||
tokio::select! {
|
||||
res = main_loop_handle => {
|
||||
match res {
|
||||
Ok(inner_res) => warn!("Main loop exited unexpectedly: {:?}", inner_res),
|
||||
Err(e) => warn!("Main loop task panicked: {:?}", e),
|
||||
}
|
||||
}
|
||||
res = retry_loop_handle => {
|
||||
match res {
|
||||
Ok(inner_res) => warn!("Retry loop exited unexpectedly: {:?}", inner_res),
|
||||
Err(e) => warn!("Retry loop task panicked: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
let Err(err) = sequencer_handle.run_forever().await;
|
||||
error!("Sequencer failed: {err:?}");
|
||||
|
||||
info!("Shutting down sequencer...");
|
||||
|
||||
|
||||
@ -1,13 +1,3 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use common::block::Block;
|
||||
use nssa::V02State;
|
||||
use rocksdb::{
|
||||
BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, WriteBatch,
|
||||
};
|
||||
|
||||
use crate::error::DbError;
|
||||
|
||||
/// Maximal size of stored blocks in base
|
||||
///
|
||||
/// Used to control db size
|
||||
@ -46,7 +36,7 @@ pub struct RocksDBIO {
|
||||
}
|
||||
|
||||
impl RocksDBIO {
|
||||
pub fn open_or_create(path: &Path, start_block: Option<Block>) -> DbResult<Self> {
|
||||
pub fn open_or_create(path: &Path, start_block: Option<&Block>) -> DbResult<Self> {
|
||||
let mut cf_opts = Options::default();
|
||||
cf_opts.set_max_write_buffer_number(16);
|
||||
// ToDo: Add more column families for different data
|
||||
@ -207,7 +197,7 @@ impl RocksDBIO {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn put_meta_first_block_in_db(&self, block: Block) -> DbResult<()> {
|
||||
pub fn put_meta_first_block_in_db(&self, block: &Block) -> DbResult<()> {
|
||||
let cf_meta = self.meta_column();
|
||||
self.db
|
||||
.put_cf(
|
||||
@ -300,7 +290,7 @@ impl RocksDBIO {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn put_block(&self, block: Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
|
||||
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
|
||||
let cf_block = self.block_column();
|
||||
|
||||
if !first {
|
||||
@ -316,7 +306,7 @@ impl RocksDBIO {
|
||||
borsh::to_vec(&block.header.block_id).map_err(|err| {
|
||||
DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_string()))
|
||||
})?,
|
||||
borsh::to_vec(&block).map_err(|err| {
|
||||
borsh::to_vec(block).map_err(|err| {
|
||||
DbError::borsh_cast_message(err, Some("Failed to serialize block data".to_string()))
|
||||
})?,
|
||||
);
|
||||
@ -426,7 +416,7 @@ impl RocksDBIO {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn atomic_update(&self, block: Block, state: &V02State) -> DbResult<()> {
|
||||
pub fn atomic_update(&self, block: &Block, state: &V02State) -> DbResult<()> {
|
||||
let block_id = block.header.block_id;
|
||||
let mut batch = WriteBatch::default();
|
||||
self.put_block(block, false, &mut batch)?;
|
||||
@ -438,4 +428,4 @@ impl RocksDBIO {
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
16
wallet-ffi/Cargo.toml
Normal file
16
wallet-ffi/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "wallet-ffi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
wallet.workspace = true
|
||||
nssa.workspace = true
|
||||
common.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = "0.26"
|
||||
13
wallet-ffi/build.rs
Normal file
13
wallet-ffi/build.rs
Normal file
@ -0,0 +1,13 @@
|
||||
fn main() {
|
||||
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
|
||||
let config =
|
||||
cbindgen::Config::from_file("cbindgen.toml").expect("Unable to read cbindgen.toml");
|
||||
|
||||
cbindgen::Builder::new()
|
||||
.with_crate(crate_dir)
|
||||
.with_config(config)
|
||||
.generate()
|
||||
.expect("Unable to generate bindings")
|
||||
.write_to_file("wallet_ffi.h");
|
||||
}
|
||||
40
wallet-ffi/cbindgen.toml
Normal file
40
wallet-ffi/cbindgen.toml
Normal file
@ -0,0 +1,40 @@
|
||||
language = "C"
|
||||
header = """
|
||||
/**
|
||||
* NSSA Wallet FFI Bindings
|
||||
*
|
||||
* Thread Safety: All functions are thread-safe. The wallet handle can be
|
||||
* shared across threads, but operations are serialized internally.
|
||||
*
|
||||
* Memory Management:
|
||||
* - Functions returning pointers allocate memory that must be freed
|
||||
* - Use the corresponding wallet_ffi_free_* function to free memory
|
||||
* - Never free memory returned by FFI using standard C free()
|
||||
*
|
||||
* Error Handling:
|
||||
* - Functions return WalletFfiError codes
|
||||
* - On error, call wallet_ffi_get_last_error() for detailed message
|
||||
* - The error string must be freed with wallet_ffi_free_error_string()
|
||||
*
|
||||
* Initialization:
|
||||
* 1. Call wallet_ffi_init_runtime() before any other function
|
||||
* 2. Create wallet with wallet_ffi_create_new() or wallet_ffi_open()
|
||||
* 3. Destroy wallet with wallet_ffi_destroy() when done
|
||||
*/
|
||||
"""
|
||||
|
||||
include_guard = "WALLET_FFI_H"
|
||||
include_version = true
|
||||
no_includes = false
|
||||
|
||||
[export]
|
||||
include = ["Ffi.*", "WalletFfiError", "WalletHandle"]
|
||||
|
||||
[enum]
|
||||
rename_variants = "ScreamingSnakeCase"
|
||||
|
||||
[fn]
|
||||
rename_args = "None"
|
||||
|
||||
[struct]
|
||||
rename_fields = "None"
|
||||
395
wallet-ffi/src/account.rs
Normal file
395
wallet-ffi/src/account.rs
Normal file
@ -0,0 +1,395 @@
|
||||
//! Account management functions.
|
||||
|
||||
use std::ptr;
|
||||
|
||||
use nssa::AccountId;
|
||||
|
||||
use crate::{
|
||||
block_on,
|
||||
error::{print_error, WalletFfiError},
|
||||
types::{
|
||||
FfiAccount, FfiAccountList, FfiAccountListEntry, FfiBytes32, FfiProgramId, WalletHandle,
|
||||
},
|
||||
wallet::get_wallet,
|
||||
};
|
||||
|
||||
/// Create a new public account.
|
||||
///
|
||||
/// Public accounts use standard transaction signing and are suitable for
|
||||
/// non-private operations.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `out_account_id`: Output pointer for the new account ID (32 bytes)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful creation
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `out_account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_create_account_public(
|
||||
handle: *mut WalletHandle,
|
||||
out_account_id: *mut FfiBytes32,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if out_account_id.is_null() {
|
||||
print_error("Null output pointer for account_id");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let mut wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let (account_id, _chain_index) = wallet.create_new_account_public(None);
|
||||
|
||||
unsafe {
|
||||
(*out_account_id).data = *account_id.value();
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// Create a new private account.
|
||||
///
|
||||
/// Private accounts use privacy-preserving transactions with nullifiers
|
||||
/// and commitments.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `out_account_id`: Output pointer for the new account ID (32 bytes)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful creation
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `out_account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_create_account_private(
|
||||
handle: *mut WalletHandle,
|
||||
out_account_id: *mut FfiBytes32,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if out_account_id.is_null() {
|
||||
print_error("Null output pointer for account_id");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let mut wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let (account_id, _chain_index) = wallet.create_new_account_private(None);
|
||||
|
||||
unsafe {
|
||||
(*out_account_id).data = *account_id.value();
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// List all accounts in the wallet.
|
||||
///
|
||||
/// Returns both public and private accounts managed by this wallet.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `out_list`: Output pointer for the account list
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful listing
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Memory
|
||||
/// The returned list must be freed with `wallet_ffi_free_account_list()`.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `out_list` must be a valid pointer to a `FfiAccountList` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_list_accounts(
|
||||
handle: *mut WalletHandle,
|
||||
out_list: *mut FfiAccountList,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if out_list.is_null() {
|
||||
print_error("Null output pointer for account list");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let user_data = &wallet.storage().user_data;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
// Public accounts from default signing keys (preconfigured)
|
||||
for account_id in user_data.default_pub_account_signing_keys.keys() {
|
||||
entries.push(FfiAccountListEntry {
|
||||
account_id: FfiBytes32::from_account_id(account_id),
|
||||
is_public: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Public accounts from key tree (generated)
|
||||
for account_id in user_data.public_key_tree.account_id_map.keys() {
|
||||
entries.push(FfiAccountListEntry {
|
||||
account_id: FfiBytes32::from_account_id(account_id),
|
||||
is_public: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Private accounts from default accounts (preconfigured)
|
||||
for account_id in user_data.default_user_private_accounts.keys() {
|
||||
entries.push(FfiAccountListEntry {
|
||||
account_id: FfiBytes32::from_account_id(account_id),
|
||||
is_public: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Private accounts from key tree (generated)
|
||||
for account_id in user_data.private_key_tree.account_id_map.keys() {
|
||||
entries.push(FfiAccountListEntry {
|
||||
account_id: FfiBytes32::from_account_id(account_id),
|
||||
is_public: false,
|
||||
});
|
||||
}
|
||||
|
||||
let count = entries.len();
|
||||
|
||||
if count == 0 {
|
||||
unsafe {
|
||||
(*out_list).entries = ptr::null_mut();
|
||||
(*out_list).count = 0;
|
||||
}
|
||||
} else {
|
||||
let entries_boxed = entries.into_boxed_slice();
|
||||
let entries_ptr = Box::into_raw(entries_boxed) as *mut FfiAccountListEntry;
|
||||
|
||||
unsafe {
|
||||
(*out_list).entries = entries_ptr;
|
||||
(*out_list).count = count;
|
||||
}
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// Free an account list returned by `wallet_ffi_list_accounts`.
|
||||
///
|
||||
/// # Safety
|
||||
/// The list must be either null or a valid list returned by `wallet_ffi_list_accounts`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_free_account_list(list: *mut FfiAccountList) {
|
||||
if list.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let list = &*list;
|
||||
if !list.entries.is_null() && list.count > 0 {
|
||||
let slice = std::slice::from_raw_parts_mut(list.entries, list.count);
|
||||
drop(Box::from_raw(slice as *mut [FfiAccountListEntry]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get account balance.
|
||||
///
|
||||
/// For public accounts, this fetches the balance from the network.
|
||||
/// For private accounts, this returns the locally cached balance.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `account_id`: The account ID (32 bytes)
|
||||
/// - `is_public`: Whether this is a public account
|
||||
/// - `out_balance`: Output for balance as little-endian [u8; 16]
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful query
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
/// - `out_balance` must be a valid pointer to a `[u8; 16]` array
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_get_balance(
|
||||
handle: *mut WalletHandle,
|
||||
account_id: *const FfiBytes32,
|
||||
is_public: bool,
|
||||
out_balance: *mut [u8; 16],
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if account_id.is_null() || out_balance.is_null() {
|
||||
print_error("Null pointer argument");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let account_id = AccountId::new(unsafe { (*account_id).data });
|
||||
|
||||
let balance = if is_public {
|
||||
match block_on(wallet.get_account_balance(account_id)) {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => {
|
||||
print_error(format!("Failed to get balance: {}", e));
|
||||
return WalletFfiError::NetworkError;
|
||||
}
|
||||
Err(e) => return e,
|
||||
}
|
||||
} else {
|
||||
match wallet.get_account_private(&account_id) {
|
||||
Some(account) => account.balance,
|
||||
None => {
|
||||
print_error("Private account not found");
|
||||
return WalletFfiError::AccountNotFound;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
*out_balance = balance.to_le_bytes();
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// Get full public account data from the network.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `account_id`: The account ID (32 bytes)
|
||||
/// - `out_account`: Output pointer for account data
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful query
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Memory
|
||||
/// The account data must be freed with `wallet_ffi_free_account_data()`.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
/// - `out_account` must be a valid pointer to a `FfiAccount` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_get_account_public(
|
||||
handle: *mut WalletHandle,
|
||||
account_id: *const FfiBytes32,
|
||||
out_account: *mut FfiAccount,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if account_id.is_null() || out_account.is_null() {
|
||||
print_error("Null pointer argument");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let account_id = AccountId::new(unsafe { (*account_id).data });
|
||||
|
||||
let account = match block_on(wallet.get_account_public(account_id)) {
|
||||
Ok(Ok(a)) => a,
|
||||
Ok(Err(e)) => {
|
||||
print_error(format!("Failed to get account: {}", e));
|
||||
return WalletFfiError::NetworkError;
|
||||
}
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
// Convert account data to FFI type
|
||||
let data_vec: Vec<u8> = account.data.into();
|
||||
let data_len = data_vec.len();
|
||||
let data_ptr = if data_len > 0 {
|
||||
let data_boxed = data_vec.into_boxed_slice();
|
||||
Box::into_raw(data_boxed) as *const u8
|
||||
} else {
|
||||
ptr::null()
|
||||
};
|
||||
|
||||
let program_owner = FfiProgramId {
|
||||
data: account.program_owner,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
(*out_account).program_owner = program_owner;
|
||||
(*out_account).balance = account.balance.to_le_bytes();
|
||||
(*out_account).nonce = account.nonce.to_le_bytes();
|
||||
(*out_account).data = data_ptr;
|
||||
(*out_account).data_len = data_len;
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// Free account data returned by `wallet_ffi_get_account_public`.
|
||||
///
|
||||
/// # Safety
|
||||
/// The account must be either null or a valid account returned by
|
||||
/// `wallet_ffi_get_account_public`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_free_account_data(account: *mut FfiAccount) {
|
||||
if account.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let account = &*account;
|
||||
if !account.data.is_null() && account.data_len > 0 {
|
||||
let slice = std::slice::from_raw_parts_mut(account.data as *mut u8, account.data_len);
|
||||
drop(Box::from_raw(slice as *mut [u8]));
|
||||
}
|
||||
}
|
||||
}
|
||||
46
wallet-ffi/src/error.rs
Normal file
46
wallet-ffi/src/error.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! Error handling for the FFI layer.
|
||||
//!
|
||||
//! Uses numeric error codes with error messages printed to stderr.
|
||||
|
||||
/// Error codes returned by FFI functions.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WalletFfiError {
|
||||
/// Operation completed successfully
|
||||
Success = 0,
|
||||
/// A null pointer was passed where a valid pointer was expected
|
||||
NullPointer = 1,
|
||||
/// Invalid UTF-8 string
|
||||
InvalidUtf8 = 2,
|
||||
/// Wallet handle is not initialized
|
||||
WalletNotInitialized = 3,
|
||||
/// Configuration error
|
||||
ConfigError = 4,
|
||||
/// Storage/persistence error
|
||||
StorageError = 5,
|
||||
/// Network/RPC error
|
||||
NetworkError = 6,
|
||||
/// Account not found
|
||||
AccountNotFound = 7,
|
||||
/// Key not found for account
|
||||
KeyNotFound = 8,
|
||||
/// Insufficient funds for operation
|
||||
InsufficientFunds = 9,
|
||||
/// Invalid account ID format
|
||||
InvalidAccountId = 10,
|
||||
/// Tokio runtime error
|
||||
RuntimeError = 11,
|
||||
/// Password required but not provided
|
||||
PasswordRequired = 12,
|
||||
/// Block synchronization error
|
||||
SyncError = 13,
|
||||
/// Serialization/deserialization error
|
||||
SerializationError = 14,
|
||||
/// Internal error (catch-all)
|
||||
InternalError = 99,
|
||||
}
|
||||
|
||||
/// Log an error message to stderr.
|
||||
pub fn print_error(msg: impl Into<String>) {
|
||||
eprintln!("[wallet-ffi] {}", msg.into());
|
||||
}
|
||||
253
wallet-ffi/src/keys.rs
Normal file
253
wallet-ffi/src/keys.rs
Normal file
@ -0,0 +1,253 @@
|
||||
//! Key retrieval functions.
|
||||
|
||||
use std::ptr;
|
||||
|
||||
use nssa::{AccountId, PublicKey};
|
||||
|
||||
use crate::{
|
||||
error::{print_error, WalletFfiError},
|
||||
types::{FfiBytes32, FfiPrivateAccountKeys, FfiPublicAccountKey, WalletHandle},
|
||||
wallet::get_wallet,
|
||||
};
|
||||
|
||||
/// Get the public key for a public account.
|
||||
///
|
||||
/// This returns the public key derived from the account's signing key.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `account_id`: The account ID (32 bytes)
|
||||
/// - `out_public_key`: Output pointer for the public key
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful retrieval
|
||||
/// - `KeyNotFound` if the account's key is not in this wallet
|
||||
/// - Error code on other failures
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
/// - `out_public_key` must be a valid pointer to a `FfiPublicAccountKey` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_get_public_account_key(
|
||||
handle: *mut WalletHandle,
|
||||
account_id: *const FfiBytes32,
|
||||
out_public_key: *mut FfiPublicAccountKey,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if account_id.is_null() || out_public_key.is_null() {
|
||||
print_error("Null pointer argument");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let account_id = AccountId::new(unsafe { (*account_id).data });
|
||||
|
||||
let private_key = match wallet.get_account_public_signing_key(&account_id) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
print_error("Public account key not found in wallet");
|
||||
return WalletFfiError::KeyNotFound;
|
||||
}
|
||||
};
|
||||
|
||||
let public_key = PublicKey::new_from_private_key(private_key);
|
||||
|
||||
unsafe {
|
||||
(*out_public_key).public_key.data = *public_key.value();
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// Get keys for a private account.
|
||||
///
|
||||
/// Returns the nullifier public key (NPK) and incoming viewing public key (IPK)
|
||||
/// for the specified private account. These keys are safe to share publicly.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `account_id`: The account ID (32 bytes)
|
||||
/// - `out_keys`: Output pointer for the key data
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful retrieval
|
||||
/// - `AccountNotFound` if the private account is not in this wallet
|
||||
/// - Error code on other failures
|
||||
///
|
||||
/// # Memory
|
||||
/// The keys structure must be freed with `wallet_ffi_free_private_account_keys()`.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
/// - `out_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_get_private_account_keys(
|
||||
handle: *mut WalletHandle,
|
||||
account_id: *const FfiBytes32,
|
||||
out_keys: *mut FfiPrivateAccountKeys,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if account_id.is_null() || out_keys.is_null() {
|
||||
print_error("Null pointer argument");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let account_id = AccountId::new(unsafe { (*account_id).data });
|
||||
|
||||
let (key_chain, _account) = match wallet.storage().user_data.get_private_account(&account_id) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
print_error("Private account not found in wallet");
|
||||
return WalletFfiError::AccountNotFound;
|
||||
}
|
||||
};
|
||||
|
||||
// NPK is a 32-byte array
|
||||
let npk_bytes = key_chain.nullifer_public_key.0;
|
||||
|
||||
// IPK is a compressed secp256k1 point (33 bytes)
|
||||
let ipk_bytes = key_chain.incoming_viewing_public_key.to_bytes();
|
||||
let ipk_len = ipk_bytes.len();
|
||||
let ipk_vec = ipk_bytes.to_vec();
|
||||
let ipk_boxed = ipk_vec.into_boxed_slice();
|
||||
let ipk_ptr = Box::into_raw(ipk_boxed) as *const u8;
|
||||
|
||||
unsafe {
|
||||
(*out_keys).nullifier_public_key.data = npk_bytes;
|
||||
(*out_keys).incoming_viewing_public_key = ipk_ptr;
|
||||
(*out_keys).incoming_viewing_public_key_len = ipk_len;
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// Free private account keys returned by `wallet_ffi_get_private_account_keys`.
|
||||
///
|
||||
/// # Safety
|
||||
/// The keys must be either null or valid keys returned by
|
||||
/// `wallet_ffi_get_private_account_keys`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_free_private_account_keys(keys: *mut FfiPrivateAccountKeys) {
|
||||
if keys.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let keys = &*keys;
|
||||
if !keys.incoming_viewing_public_key.is_null() && keys.incoming_viewing_public_key_len > 0 {
|
||||
let slice = std::slice::from_raw_parts_mut(
|
||||
keys.incoming_viewing_public_key as *mut u8,
|
||||
keys.incoming_viewing_public_key_len,
|
||||
);
|
||||
drop(Box::from_raw(slice as *mut [u8]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an account ID to a Base58 string.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `account_id`: The account ID (32 bytes)
|
||||
///
|
||||
/// # Returns
|
||||
/// - Pointer to null-terminated Base58 string on success
|
||||
/// - Null pointer on error
|
||||
///
|
||||
/// # Memory
|
||||
/// The returned string must be freed with `wallet_ffi_free_string()`.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_account_id_to_base58(
|
||||
account_id: *const FfiBytes32,
|
||||
) -> *mut std::ffi::c_char {
|
||||
if account_id.is_null() {
|
||||
print_error("Null account_id pointer");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let account_id = AccountId::new(unsafe { (*account_id).data });
|
||||
let base58_str = account_id.to_string();
|
||||
|
||||
match std::ffi::CString::new(base58_str) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to create C string: {}", e));
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Base58 string into an account ID.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `base58_str`: Null-terminated Base58 string
|
||||
/// - `out_account_id`: Output pointer for the account ID (32 bytes)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful parsing
|
||||
/// - `InvalidAccountId` if the string is not valid Base58
|
||||
/// - Error code on other failures
|
||||
///
|
||||
/// # Safety
|
||||
/// - `base58_str` must be a valid pointer to a null-terminated C string
|
||||
/// - `out_account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_account_id_from_base58(
|
||||
base58_str: *const std::ffi::c_char,
|
||||
out_account_id: *mut FfiBytes32,
|
||||
) -> WalletFfiError {
|
||||
if base58_str.is_null() || out_account_id.is_null() {
|
||||
print_error("Null pointer argument");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let c_str = unsafe { std::ffi::CStr::from_ptr(base58_str) };
|
||||
let str_slice = match c_str.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
print_error(format!("Invalid UTF-8: {}", e));
|
||||
return WalletFfiError::InvalidUtf8;
|
||||
}
|
||||
};
|
||||
|
||||
let account_id: AccountId = match str_slice.parse() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
print_error(format!("Invalid Base58 account ID: {}", e));
|
||||
return WalletFfiError::InvalidAccountId;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
(*out_account_id).data = *account_id.value();
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
70
wallet-ffi/src/lib.rs
Normal file
70
wallet-ffi/src/lib.rs
Normal file
@ -0,0 +1,70 @@
|
||||
//! NSSA Wallet FFI Library
|
||||
//!
|
||||
//! This crate provides C-compatible bindings for the NSSA wallet functionality.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! 1. Initialize the runtime with `wallet_ffi_init_runtime()`
|
||||
//! 2. Create or open a wallet with `wallet_ffi_create_new()` or `wallet_ffi_open()`
|
||||
//! 3. Use the wallet functions to manage accounts and transfers
|
||||
//! 4. Destroy the wallet with `wallet_ffi_destroy()` when done
|
||||
//!
|
||||
//! # Thread Safety
|
||||
//!
|
||||
//! All functions are thread-safe. The wallet handle uses internal locking
|
||||
//! to ensure safe concurrent access.
|
||||
//!
|
||||
//! # Memory Management
|
||||
//!
|
||||
//! - Functions returning pointers allocate memory that must be freed
|
||||
//! - Use the corresponding `wallet_ffi_free_*` function to free memory
|
||||
//! - Never free memory returned by FFI using standard C `free()`
|
||||
|
||||
pub mod account;
|
||||
pub mod error;
|
||||
pub mod keys;
|
||||
pub mod sync;
|
||||
pub mod transfer;
|
||||
pub mod types;
|
||||
pub mod wallet;
|
||||
|
||||
// Re-export public types for cbindgen
|
||||
pub use error::WalletFfiError as FfiError;
|
||||
use tokio::runtime::Handle;
|
||||
pub use types::*;
|
||||
|
||||
use crate::error::{print_error, WalletFfiError};
|
||||
|
||||
/// Get a reference to the global runtime.
|
||||
pub(crate) fn get_runtime() -> Result<Handle, WalletFfiError> {
|
||||
Handle::try_current().map_err(|_| WalletFfiError::RuntimeError)
|
||||
}
|
||||
|
||||
/// Run an async future on the global runtime, blocking until completion.
|
||||
pub(crate) fn block_on<F: std::future::Future>(future: F) -> Result<F::Output, WalletFfiError> {
|
||||
let runtime = get_runtime()?;
|
||||
Ok(runtime.block_on(future))
|
||||
}
|
||||
|
||||
/// Initialize the global Tokio runtime.
|
||||
///
|
||||
/// This must be called before any async operations (like network calls).
|
||||
/// Safe to call multiple times - subsequent calls are no-ops.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` if the runtime was initialized or already exists
|
||||
/// - `RuntimeError` if runtime creation failed
|
||||
#[no_mangle]
|
||||
pub extern "C" fn wallet_ffi_init_runtime() -> WalletFfiError {
|
||||
let result = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build();
|
||||
|
||||
match result {
|
||||
Ok(_) => WalletFfiError::Success,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to initialize runtime: {}", e));
|
||||
WalletFfiError::RuntimeError
|
||||
}
|
||||
}
|
||||
}
|
||||
151
wallet-ffi/src/sync.rs
Normal file
151
wallet-ffi/src/sync.rs
Normal file
@ -0,0 +1,151 @@
|
||||
//! Block synchronization functions.
|
||||
|
||||
use crate::{
|
||||
block_on,
|
||||
error::{print_error, WalletFfiError},
|
||||
types::WalletHandle,
|
||||
wallet::get_wallet,
|
||||
};
|
||||
|
||||
/// Synchronize private accounts to a specific block.
|
||||
///
|
||||
/// This scans the blockchain from the last synced block to the specified block,
|
||||
/// updating private account balances based on any relevant transactions.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `block_id`: Target block number to sync to
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` if synchronization completed
|
||||
/// - `SyncError` if synchronization failed
|
||||
/// - Error code on other failures
|
||||
///
|
||||
/// # Note
|
||||
/// This operation can take a while for large block ranges. The wallet
|
||||
/// internally uses a progress bar which may output to stdout.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_sync_to_block(
|
||||
handle: *mut WalletHandle,
|
||||
block_id: u64,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let mut wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
match block_on(wallet.sync_to_block(block_id)) {
|
||||
Ok(Ok(())) => WalletFfiError::Success,
|
||||
Ok(Err(e)) => {
|
||||
print_error(format!("Sync failed: {}", e));
|
||||
WalletFfiError::SyncError
|
||||
}
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the last synced block number.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `out_block_id`: Output pointer for the block number
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on success
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `out_block_id` must be a valid pointer to a `u64`
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_get_last_synced_block(
|
||||
handle: *mut WalletHandle,
|
||||
out_block_id: *mut u64,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if out_block_id.is_null() {
|
||||
print_error("Null output pointer");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
*out_block_id = wallet.last_synced_block;
|
||||
}
|
||||
|
||||
WalletFfiError::Success
|
||||
}
|
||||
|
||||
/// Get the current block height from the sequencer.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `out_block_height`: Output pointer for the current block height
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on success
|
||||
/// - `NetworkError` if the sequencer is unreachable
|
||||
/// - Error code on other failures
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `out_block_height` must be a valid pointer to a `u64`
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_get_current_block_height(
|
||||
handle: *mut WalletHandle,
|
||||
out_block_height: *mut u64,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if out_block_height.is_null() {
|
||||
print_error("Null output pointer");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
match block_on(wallet.sequencer_client.get_last_block()) {
|
||||
Ok(Ok(response)) => {
|
||||
unsafe {
|
||||
*out_block_height = response.last_block;
|
||||
}
|
||||
WalletFfiError::Success
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
print_error(format!("Failed to get block height: {:?}", e));
|
||||
WalletFfiError::NetworkError
|
||||
}
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
199
wallet-ffi/src/transfer.rs
Normal file
199
wallet-ffi/src/transfer.rs
Normal file
@ -0,0 +1,199 @@
|
||||
//! Token transfer functions.
|
||||
|
||||
use std::{ffi::CString, ptr};
|
||||
|
||||
use common::error::ExecutionFailureKind;
|
||||
use nssa::AccountId;
|
||||
use wallet::program_facades::native_token_transfer::NativeTokenTransfer;
|
||||
|
||||
use crate::{
|
||||
block_on,
|
||||
error::{print_error, WalletFfiError},
|
||||
types::{FfiBytes32, FfiTransferResult, WalletHandle},
|
||||
wallet::get_wallet,
|
||||
};
|
||||
|
||||
/// Send a public token transfer.
|
||||
///
|
||||
/// Transfers tokens from one public account to another on the network.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `from`: Source account ID (must be owned by this wallet)
|
||||
/// - `to`: Destination account ID
|
||||
/// - `amount`: Amount to transfer as little-endian [u8; 16]
|
||||
/// - `out_result`: Output pointer for transfer result
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` if the transfer was submitted successfully
|
||||
/// - `InsufficientFunds` if the source account doesn't have enough balance
|
||||
/// - `KeyNotFound` if the source account's signing key is not in this wallet
|
||||
/// - Error code on other failures
|
||||
///
|
||||
/// # Memory
|
||||
/// The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `from` must be a valid pointer to a `FfiBytes32` struct
|
||||
/// - `to` must be a valid pointer to a `FfiBytes32` struct
|
||||
/// - `amount` must be a valid pointer to a `[u8; 16]` array
|
||||
/// - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_transfer_public(
|
||||
handle: *mut WalletHandle,
|
||||
from: *const FfiBytes32,
|
||||
to: *const FfiBytes32,
|
||||
amount: *const [u8; 16],
|
||||
out_result: *mut FfiTransferResult,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if from.is_null() || to.is_null() || amount.is_null() || out_result.is_null() {
|
||||
print_error("Null pointer argument");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let from_id = AccountId::new(unsafe { (*from).data });
|
||||
let to_id = AccountId::new(unsafe { (*to).data });
|
||||
let amount = u128::from_le_bytes(unsafe { *amount });
|
||||
|
||||
let transfer = NativeTokenTransfer(&wallet);
|
||||
|
||||
match block_on(transfer.send_public_transfer(from_id, to_id, amount)) {
|
||||
Ok(Ok(response)) => {
|
||||
let tx_hash = CString::new(response.tx_hash)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(ptr::null_mut());
|
||||
|
||||
unsafe {
|
||||
(*out_result).tx_hash = tx_hash;
|
||||
(*out_result).success = true;
|
||||
}
|
||||
WalletFfiError::Success
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
print_error(format!("Transfer failed: {:?}", e));
|
||||
unsafe {
|
||||
(*out_result).tx_hash = ptr::null_mut();
|
||||
(*out_result).success = false;
|
||||
}
|
||||
match e {
|
||||
ExecutionFailureKind::InsufficientFundsError => WalletFfiError::InsufficientFunds,
|
||||
ExecutionFailureKind::KeyNotFoundError => WalletFfiError::KeyNotFound,
|
||||
ExecutionFailureKind::SequencerError => WalletFfiError::NetworkError,
|
||||
ExecutionFailureKind::SequencerClientError(_) => WalletFfiError::NetworkError,
|
||||
_ => WalletFfiError::InternalError,
|
||||
}
|
||||
}
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a public account on the network.
|
||||
///
|
||||
/// This initializes a public account on the blockchain. The account must be
|
||||
/// owned by this wallet.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
/// - `account_id`: Account ID to register
|
||||
/// - `out_result`: Output pointer for registration result
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` if the registration was submitted successfully
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Memory
|
||||
/// The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
/// - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
/// - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_register_public_account(
|
||||
handle: *mut WalletHandle,
|
||||
account_id: *const FfiBytes32,
|
||||
out_result: *mut FfiTransferResult,
|
||||
) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if account_id.is_null() || out_result.is_null() {
|
||||
print_error("Null pointer argument");
|
||||
return WalletFfiError::NullPointer;
|
||||
}
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
let account_id = AccountId::new(unsafe { (*account_id).data });
|
||||
|
||||
let transfer = NativeTokenTransfer(&wallet);
|
||||
|
||||
match block_on(transfer.register_account(account_id)) {
|
||||
Ok(Ok(response)) => {
|
||||
let tx_hash = CString::new(response.tx_hash)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(ptr::null_mut());
|
||||
|
||||
unsafe {
|
||||
(*out_result).tx_hash = tx_hash;
|
||||
(*out_result).success = true;
|
||||
}
|
||||
WalletFfiError::Success
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
print_error(format!("Registration failed: {:?}", e));
|
||||
unsafe {
|
||||
(*out_result).tx_hash = ptr::null_mut();
|
||||
(*out_result).success = false;
|
||||
}
|
||||
match e {
|
||||
ExecutionFailureKind::KeyNotFoundError => WalletFfiError::KeyNotFound,
|
||||
ExecutionFailureKind::SequencerError => WalletFfiError::NetworkError,
|
||||
ExecutionFailureKind::SequencerClientError(_) => WalletFfiError::NetworkError,
|
||||
_ => WalletFfiError::InternalError,
|
||||
}
|
||||
}
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a transfer result returned by `wallet_ffi_transfer_public` or
|
||||
/// `wallet_ffi_register_public_account`.
|
||||
///
|
||||
/// # Safety
|
||||
/// The result must be either null or a valid result from a transfer function.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_free_transfer_result(result: *mut FfiTransferResult) {
|
||||
if result.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = &*result;
|
||||
if !result.tx_hash.is_null() {
|
||||
drop(CString::from_raw(result.tx_hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
151
wallet-ffi/src/types.rs
Normal file
151
wallet-ffi/src/types.rs
Normal file
@ -0,0 +1,151 @@
|
||||
//! C-compatible type definitions for the FFI layer.
|
||||
|
||||
use std::ffi::c_char;
|
||||
|
||||
/// Opaque pointer to the Wallet instance.
|
||||
///
|
||||
/// This type is never instantiated directly - it's used as an opaque handle
|
||||
/// to hide the internal wallet structure from C code.
|
||||
#[repr(C)]
|
||||
pub struct WalletHandle {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
/// 32-byte array type for AccountId, keys, hashes, etc.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FfiBytes32 {
|
||||
pub data: [u8; 32],
|
||||
}
|
||||
|
||||
/// Program ID - 8 u32 values (32 bytes total).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FfiProgramId {
|
||||
pub data: [u32; 8],
|
||||
}
|
||||
|
||||
/// Account data structure - C-compatible version of nssa Account.
|
||||
///
|
||||
/// Note: `balance` and `nonce` are u128 values represented as little-endian
|
||||
/// byte arrays since C doesn't have native u128 support.
|
||||
#[repr(C)]
|
||||
pub struct FfiAccount {
|
||||
pub program_owner: FfiProgramId,
|
||||
/// Balance as little-endian [u8; 16]
|
||||
pub balance: [u8; 16],
|
||||
/// Pointer to account data bytes
|
||||
pub data: *const u8,
|
||||
/// Length of account data
|
||||
pub data_len: usize,
|
||||
/// Nonce as little-endian [u8; 16]
|
||||
pub nonce: [u8; 16],
|
||||
}
|
||||
|
||||
impl Default for FfiAccount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
program_owner: FfiProgramId::default(),
|
||||
balance: [0u8; 16],
|
||||
data: std::ptr::null(),
|
||||
data_len: 0,
|
||||
nonce: [0u8; 16],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public keys for a private account (safe to expose).
|
||||
#[repr(C)]
|
||||
pub struct FfiPrivateAccountKeys {
|
||||
/// Nullifier public key (32 bytes)
|
||||
pub nullifier_public_key: FfiBytes32,
|
||||
/// Incoming viewing public key (compressed secp256k1 point)
|
||||
pub incoming_viewing_public_key: *const u8,
|
||||
/// Length of incoming viewing public key (typically 33 bytes)
|
||||
pub incoming_viewing_public_key_len: usize,
|
||||
}
|
||||
|
||||
impl Default for FfiPrivateAccountKeys {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nullifier_public_key: FfiBytes32::default(),
|
||||
incoming_viewing_public_key: std::ptr::null(),
|
||||
incoming_viewing_public_key_len: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public key info for a public account.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FfiPublicAccountKey {
|
||||
pub public_key: FfiBytes32,
|
||||
}
|
||||
|
||||
/// Single entry in the account list.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FfiAccountListEntry {
|
||||
pub account_id: FfiBytes32,
|
||||
pub is_public: bool,
|
||||
}
|
||||
|
||||
/// List of accounts returned by wallet_ffi_list_accounts.
|
||||
#[repr(C)]
|
||||
pub struct FfiAccountList {
|
||||
pub entries: *mut FfiAccountListEntry,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
impl Default for FfiAccountList {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: std::ptr::null_mut(),
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a transfer operation.
|
||||
#[repr(C)]
|
||||
pub struct FfiTransferResult {
|
||||
/// Transaction hash (null-terminated string, or null on failure)
|
||||
pub tx_hash: *mut c_char,
|
||||
/// Whether the transfer succeeded
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
impl Default for FfiTransferResult {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tx_hash: std::ptr::null_mut(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to convert between Rust and FFI types
|
||||
|
||||
impl FfiBytes32 {
|
||||
/// Create from a 32-byte array.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self { data: bytes }
|
||||
}
|
||||
|
||||
/// Create from an AccountId.
|
||||
pub fn from_account_id(id: &nssa::AccountId) -> Self {
|
||||
Self { data: *id.value() }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&nssa::AccountId> for FfiBytes32 {
|
||||
fn from(id: &nssa::AccountId) -> Self {
|
||||
Self::from_account_id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FfiBytes32> for nssa::AccountId {
|
||||
fn from(bytes: FfiBytes32) -> Self {
|
||||
nssa::AccountId::new(bytes.data)
|
||||
}
|
||||
}
|
||||
279
wallet-ffi/src/wallet.rs
Normal file
279
wallet-ffi/src/wallet.rs
Normal file
@ -0,0 +1,279 @@
|
||||
//! Wallet lifecycle management functions.
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, CStr},
|
||||
path::PathBuf,
|
||||
ptr,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use wallet::WalletCore;
|
||||
|
||||
use crate::{
|
||||
block_on,
|
||||
error::{print_error, WalletFfiError},
|
||||
types::WalletHandle,
|
||||
};
|
||||
|
||||
/// Internal wrapper around WalletCore with mutex for thread safety.
|
||||
pub(crate) struct WalletWrapper {
|
||||
pub core: Mutex<WalletCore>,
|
||||
}
|
||||
|
||||
/// Helper to get the wallet wrapper from an opaque handle.
|
||||
pub(crate) fn get_wallet(
|
||||
handle: *mut WalletHandle,
|
||||
) -> Result<&'static WalletWrapper, WalletFfiError> {
|
||||
if handle.is_null() {
|
||||
print_error("Null wallet handle");
|
||||
return Err(WalletFfiError::NullPointer);
|
||||
}
|
||||
Ok(unsafe { &*(handle as *mut WalletWrapper) })
|
||||
}
|
||||
|
||||
/// Helper to get a mutable reference to the wallet wrapper.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn get_wallet_mut(
|
||||
handle: *mut WalletHandle,
|
||||
) -> Result<&'static mut WalletWrapper, WalletFfiError> {
|
||||
if handle.is_null() {
|
||||
print_error("Null wallet handle");
|
||||
return Err(WalletFfiError::NullPointer);
|
||||
}
|
||||
Ok(unsafe { &mut *(handle as *mut WalletWrapper) })
|
||||
}
|
||||
|
||||
/// Helper to convert a C string to a Rust PathBuf.
|
||||
fn c_str_to_path(ptr: *const c_char, name: &str) -> Result<PathBuf, WalletFfiError> {
|
||||
if ptr.is_null() {
|
||||
print_error(format!("Null pointer for {}", name));
|
||||
return Err(WalletFfiError::NullPointer);
|
||||
}
|
||||
|
||||
let c_str = unsafe { CStr::from_ptr(ptr) };
|
||||
match c_str.to_str() {
|
||||
Ok(s) => Ok(PathBuf::from(s)),
|
||||
Err(e) => {
|
||||
print_error(format!("Invalid UTF-8 in {}: {}", name, e));
|
||||
Err(WalletFfiError::InvalidUtf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to convert a C string to a Rust String.
|
||||
fn c_str_to_string(ptr: *const c_char, name: &str) -> Result<String, WalletFfiError> {
|
||||
if ptr.is_null() {
|
||||
print_error(format!("Null pointer for {}", name));
|
||||
return Err(WalletFfiError::NullPointer);
|
||||
}
|
||||
|
||||
let c_str = unsafe { CStr::from_ptr(ptr) };
|
||||
match c_str.to_str() {
|
||||
Ok(s) => Ok(s.to_string()),
|
||||
Err(e) => {
|
||||
print_error(format!("Invalid UTF-8 in {}: {}", name, e));
|
||||
Err(WalletFfiError::InvalidUtf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new wallet with fresh storage.
|
||||
///
|
||||
/// This initializes a new wallet with a new seed derived from the password.
|
||||
/// Use this for first-time wallet creation.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `config_path`: Path to the wallet configuration file (JSON)
|
||||
/// - `storage_path`: Path where wallet data will be stored
|
||||
/// - `password`: Password for encrypting the wallet seed
|
||||
///
|
||||
/// # Returns
|
||||
/// - Opaque wallet handle on success
|
||||
/// - Null pointer on error (call `wallet_ffi_get_last_error()` for details)
|
||||
///
|
||||
/// # Safety
|
||||
/// All string parameters must be valid null-terminated UTF-8 strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_create_new(
|
||||
config_path: *const c_char,
|
||||
storage_path: *const c_char,
|
||||
password: *const c_char,
|
||||
) -> *mut WalletHandle {
|
||||
let config_path = match c_str_to_path(config_path, "config_path") {
|
||||
Ok(p) => p,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
let storage_path = match c_str_to_path(storage_path, "storage_path") {
|
||||
Ok(p) => p,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
let password = match c_str_to_string(password, "password") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
match WalletCore::new_init_storage(config_path, storage_path, None, password) {
|
||||
Ok(core) => {
|
||||
let wrapper = Box::new(WalletWrapper {
|
||||
core: Mutex::new(core),
|
||||
});
|
||||
Box::into_raw(wrapper) as *mut WalletHandle
|
||||
}
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to create wallet: {}", e));
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an existing wallet from storage.
|
||||
///
|
||||
/// This loads a wallet that was previously created with `wallet_ffi_create_new()`.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `config_path`: Path to the wallet configuration file (JSON)
|
||||
/// - `storage_path`: Path where wallet data is stored
|
||||
///
|
||||
/// # Returns
|
||||
/// - Opaque wallet handle on success
|
||||
/// - Null pointer on error (call `wallet_ffi_get_last_error()` for details)
|
||||
///
|
||||
/// # Safety
|
||||
/// All string parameters must be valid null-terminated UTF-8 strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_open(
|
||||
config_path: *const c_char,
|
||||
storage_path: *const c_char,
|
||||
) -> *mut WalletHandle {
|
||||
let config_path = match c_str_to_path(config_path, "config_path") {
|
||||
Ok(p) => p,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
let storage_path = match c_str_to_path(storage_path, "storage_path") {
|
||||
Ok(p) => p,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
match WalletCore::new_update_chain(config_path, storage_path, None) {
|
||||
Ok(core) => {
|
||||
let wrapper = Box::new(WalletWrapper {
|
||||
core: Mutex::new(core),
|
||||
});
|
||||
Box::into_raw(wrapper) as *mut WalletHandle
|
||||
}
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to open wallet: {}", e));
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Destroy a wallet handle and free its resources.
|
||||
///
|
||||
/// After calling this function, the handle is invalid and must not be used.
|
||||
///
|
||||
/// # Safety
|
||||
/// - The handle must be either null or a valid handle from `wallet_ffi_create_new()` or
|
||||
/// `wallet_ffi_open()`.
|
||||
/// - The handle must not be used after this call.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_destroy(handle: *mut WalletHandle) {
|
||||
if !handle.is_null() {
|
||||
unsafe {
|
||||
drop(Box::from_raw(handle as *mut WalletWrapper));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save wallet state to persistent storage.
|
||||
///
|
||||
/// This should be called periodically or after important operations to ensure
|
||||
/// wallet data is persisted to disk.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Success` on successful save
|
||||
/// - Error code on failure
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_save(handle: *mut WalletHandle) -> WalletFfiError {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return WalletFfiError::InternalError;
|
||||
}
|
||||
};
|
||||
|
||||
match block_on(wallet.store_persistent_data()) {
|
||||
Ok(Ok(())) => WalletFfiError::Success,
|
||||
Ok(Err(e)) => {
|
||||
print_error(format!("Failed to save wallet: {}", e));
|
||||
WalletFfiError::StorageError
|
||||
}
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the sequencer address from the wallet configuration.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `handle`: Valid wallet handle
|
||||
///
|
||||
/// # Returns
|
||||
/// - Pointer to null-terminated string on success (caller must free with
|
||||
/// `wallet_ffi_free_string()`)
|
||||
/// - Null pointer on error
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_get_sequencer_addr(handle: *mut WalletHandle) -> *mut c_char {
|
||||
let wrapper = match get_wallet(handle) {
|
||||
Ok(w) => w,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
let wallet = match wrapper.core.lock() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print_error(format!("Failed to lock wallet: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
|
||||
let addr = wallet.config().sequencer_addr.clone();
|
||||
|
||||
match std::ffi::CString::new(addr) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(e) => {
|
||||
print_error(format!("Invalid sequencer address: {}", e));
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a string returned by wallet FFI functions.
|
||||
///
|
||||
/// # Safety
|
||||
/// The pointer must be either null or a valid string returned by an FFI function.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wallet_ffi_free_string(ptr: *mut c_char) {
|
||||
if !ptr.is_null() {
|
||||
unsafe {
|
||||
drop(std::ffi::CString::from_raw(ptr));
|
||||
}
|
||||
}
|
||||
}
|
||||
676
wallet-ffi/wallet_ffi.h
Normal file
676
wallet-ffi/wallet_ffi.h
Normal file
@ -0,0 +1,676 @@
|
||||
/**
|
||||
* NSSA Wallet FFI Bindings
|
||||
*
|
||||
* Thread Safety: All functions are thread-safe. The wallet handle can be
|
||||
* shared across threads, but operations are serialized internally.
|
||||
*
|
||||
* Memory Management:
|
||||
* - Functions returning pointers allocate memory that must be freed
|
||||
* - Use the corresponding wallet_ffi_free_* function to free memory
|
||||
* - Never free memory returned by FFI using standard C free()
|
||||
*
|
||||
* Error Handling:
|
||||
* - Functions return WalletFfiError codes
|
||||
* - On error, call wallet_ffi_get_last_error() for detailed message
|
||||
* - The error string must be freed with wallet_ffi_free_error_string()
|
||||
*
|
||||
* Initialization:
|
||||
* 1. Call wallet_ffi_init_runtime() before any other function
|
||||
* 2. Create wallet with wallet_ffi_create_new() or wallet_ffi_open()
|
||||
* 3. Destroy wallet with wallet_ffi_destroy() when done
|
||||
*/
|
||||
|
||||
|
||||
#ifndef WALLET_FFI_H
|
||||
#define WALLET_FFI_H
|
||||
|
||||
/* Generated with cbindgen:0.26.0 */
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/**
|
||||
* Error codes returned by FFI functions.
|
||||
*/
|
||||
typedef enum WalletFfiError {
|
||||
/**
|
||||
* Operation completed successfully
|
||||
*/
|
||||
SUCCESS = 0,
|
||||
/**
|
||||
* A null pointer was passed where a valid pointer was expected
|
||||
*/
|
||||
NULL_POINTER = 1,
|
||||
/**
|
||||
* Invalid UTF-8 string
|
||||
*/
|
||||
INVALID_UTF8 = 2,
|
||||
/**
|
||||
* Wallet handle is not initialized
|
||||
*/
|
||||
WALLET_NOT_INITIALIZED = 3,
|
||||
/**
|
||||
* Configuration error
|
||||
*/
|
||||
CONFIG_ERROR = 4,
|
||||
/**
|
||||
* Storage/persistence error
|
||||
*/
|
||||
STORAGE_ERROR = 5,
|
||||
/**
|
||||
* Network/RPC error
|
||||
*/
|
||||
NETWORK_ERROR = 6,
|
||||
/**
|
||||
* Account not found
|
||||
*/
|
||||
ACCOUNT_NOT_FOUND = 7,
|
||||
/**
|
||||
* Key not found for account
|
||||
*/
|
||||
KEY_NOT_FOUND = 8,
|
||||
/**
|
||||
* Insufficient funds for operation
|
||||
*/
|
||||
INSUFFICIENT_FUNDS = 9,
|
||||
/**
|
||||
* Invalid account ID format
|
||||
*/
|
||||
INVALID_ACCOUNT_ID = 10,
|
||||
/**
|
||||
* Tokio runtime error
|
||||
*/
|
||||
RUNTIME_ERROR = 11,
|
||||
/**
|
||||
* Password required but not provided
|
||||
*/
|
||||
PASSWORD_REQUIRED = 12,
|
||||
/**
|
||||
* Block synchronization error
|
||||
*/
|
||||
SYNC_ERROR = 13,
|
||||
/**
|
||||
* Serialization/deserialization error
|
||||
*/
|
||||
SERIALIZATION_ERROR = 14,
|
||||
/**
|
||||
* Internal error (catch-all)
|
||||
*/
|
||||
INTERNAL_ERROR = 99,
|
||||
} WalletFfiError;
|
||||
|
||||
/**
|
||||
* Opaque pointer to the Wallet instance.
|
||||
*
|
||||
* This type is never instantiated directly - it's used as an opaque handle
|
||||
* to hide the internal wallet structure from C code.
|
||||
*/
|
||||
typedef struct WalletHandle {
|
||||
uint8_t _private[0];
|
||||
} WalletHandle;
|
||||
|
||||
/**
|
||||
* 32-byte array type for AccountId, keys, hashes, etc.
|
||||
*/
|
||||
typedef struct FfiBytes32 {
|
||||
uint8_t data[32];
|
||||
} FfiBytes32;
|
||||
|
||||
/**
|
||||
* Single entry in the account list.
|
||||
*/
|
||||
typedef struct FfiAccountListEntry {
|
||||
struct FfiBytes32 account_id;
|
||||
bool is_public;
|
||||
} FfiAccountListEntry;
|
||||
|
||||
/**
|
||||
* List of accounts returned by wallet_ffi_list_accounts.
|
||||
*/
|
||||
typedef struct FfiAccountList {
|
||||
struct FfiAccountListEntry *entries;
|
||||
uintptr_t count;
|
||||
} FfiAccountList;
|
||||
|
||||
/**
|
||||
* Program ID - 8 u32 values (32 bytes total).
|
||||
*/
|
||||
typedef struct FfiProgramId {
|
||||
uint32_t data[8];
|
||||
} FfiProgramId;
|
||||
|
||||
/**
|
||||
* Account data structure - C-compatible version of nssa Account.
|
||||
*
|
||||
* Note: `balance` and `nonce` are u128 values represented as little-endian
|
||||
* byte arrays since C doesn't have native u128 support.
|
||||
*/
|
||||
typedef struct FfiAccount {
|
||||
struct FfiProgramId program_owner;
|
||||
/**
|
||||
* Balance as little-endian [u8; 16]
|
||||
*/
|
||||
uint8_t balance[16];
|
||||
/**
|
||||
* Pointer to account data bytes
|
||||
*/
|
||||
const uint8_t *data;
|
||||
/**
|
||||
* Length of account data
|
||||
*/
|
||||
uintptr_t data_len;
|
||||
/**
|
||||
* Nonce as little-endian [u8; 16]
|
||||
*/
|
||||
uint8_t nonce[16];
|
||||
} FfiAccount;
|
||||
|
||||
/**
|
||||
* Public key info for a public account.
|
||||
*/
|
||||
typedef struct FfiPublicAccountKey {
|
||||
struct FfiBytes32 public_key;
|
||||
} FfiPublicAccountKey;
|
||||
|
||||
/**
|
||||
* Public keys for a private account (safe to expose).
|
||||
*/
|
||||
typedef struct FfiPrivateAccountKeys {
|
||||
/**
|
||||
* Nullifier public key (32 bytes)
|
||||
*/
|
||||
struct FfiBytes32 nullifier_public_key;
|
||||
/**
|
||||
* Incoming viewing public key (compressed secp256k1 point)
|
||||
*/
|
||||
const uint8_t *incoming_viewing_public_key;
|
||||
/**
|
||||
* Length of incoming viewing public key (typically 33 bytes)
|
||||
*/
|
||||
uintptr_t incoming_viewing_public_key_len;
|
||||
} FfiPrivateAccountKeys;
|
||||
|
||||
/**
|
||||
* Result of a transfer operation.
|
||||
*/
|
||||
typedef struct FfiTransferResult {
|
||||
/**
|
||||
* Transaction hash (null-terminated string, or null on failure)
|
||||
*/
|
||||
char *tx_hash;
|
||||
/**
|
||||
* Whether the transfer succeeded
|
||||
*/
|
||||
bool success;
|
||||
} FfiTransferResult;
|
||||
|
||||
/**
|
||||
* Initialize the global Tokio runtime.
|
||||
*
|
||||
* This must be called before any async operations (like network calls).
|
||||
* Safe to call multiple times - subsequent calls are no-ops.
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` if the runtime was initialized or already exists
|
||||
* - `RuntimeError` if runtime creation failed
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_init_runtime(void);
|
||||
|
||||
/**
|
||||
* Create a new public account.
|
||||
*
|
||||
* Public accounts use standard transaction signing and are suitable for
|
||||
* non-private operations.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `out_account_id`: Output pointer for the new account ID (32 bytes)
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful creation
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `out_account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_create_account_public(struct WalletHandle *handle,
|
||||
struct FfiBytes32 *out_account_id);
|
||||
|
||||
/**
|
||||
* Create a new private account.
|
||||
*
|
||||
* Private accounts use privacy-preserving transactions with nullifiers
|
||||
* and commitments.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `out_account_id`: Output pointer for the new account ID (32 bytes)
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful creation
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `out_account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_create_account_private(struct WalletHandle *handle,
|
||||
struct FfiBytes32 *out_account_id);
|
||||
|
||||
/**
|
||||
* List all accounts in the wallet.
|
||||
*
|
||||
* Returns both public and private accounts managed by this wallet.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `out_list`: Output pointer for the account list
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful listing
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Memory
|
||||
* The returned list must be freed with `wallet_ffi_free_account_list()`.
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `out_list` must be a valid pointer to a `FfiAccountList` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_list_accounts(struct WalletHandle *handle,
|
||||
struct FfiAccountList *out_list);
|
||||
|
||||
/**
|
||||
* Free an account list returned by `wallet_ffi_list_accounts`.
|
||||
*
|
||||
* # Safety
|
||||
* The list must be either null or a valid list returned by `wallet_ffi_list_accounts`.
|
||||
*/
|
||||
void wallet_ffi_free_account_list(struct FfiAccountList *list);
|
||||
|
||||
/**
|
||||
* Get account balance.
|
||||
*
|
||||
* For public accounts, this fetches the balance from the network.
|
||||
* For private accounts, this returns the locally cached balance.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `account_id`: The account ID (32 bytes)
|
||||
* - `is_public`: Whether this is a public account
|
||||
* - `out_balance`: Output for balance as little-endian [u8; 16]
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful query
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
* - `out_balance` must be a valid pointer to a `[u8; 16]` array
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_get_balance(struct WalletHandle *handle,
|
||||
const struct FfiBytes32 *account_id,
|
||||
bool is_public,
|
||||
uint8_t (*out_balance)[16]);
|
||||
|
||||
/**
|
||||
* Get full public account data from the network.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `account_id`: The account ID (32 bytes)
|
||||
* - `out_account`: Output pointer for account data
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful query
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Memory
|
||||
* The account data must be freed with `wallet_ffi_free_account_data()`.
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
* - `out_account` must be a valid pointer to a `FfiAccount` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_get_account_public(struct WalletHandle *handle,
|
||||
const struct FfiBytes32 *account_id,
|
||||
struct FfiAccount *out_account);
|
||||
|
||||
/**
|
||||
* Free account data returned by `wallet_ffi_get_account_public`.
|
||||
*
|
||||
* # Safety
|
||||
* The account must be either null or a valid account returned by
|
||||
* `wallet_ffi_get_account_public`.
|
||||
*/
|
||||
void wallet_ffi_free_account_data(struct FfiAccount *account);
|
||||
|
||||
/**
|
||||
* Get the public key for a public account.
|
||||
*
|
||||
* This returns the public key derived from the account's signing key.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `account_id`: The account ID (32 bytes)
|
||||
* - `out_public_key`: Output pointer for the public key
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful retrieval
|
||||
* - `KeyNotFound` if the account's key is not in this wallet
|
||||
* - Error code on other failures
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
* - `out_public_key` must be a valid pointer to a `FfiPublicAccountKey` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_get_public_account_key(struct WalletHandle *handle,
|
||||
const struct FfiBytes32 *account_id,
|
||||
struct FfiPublicAccountKey *out_public_key);
|
||||
|
||||
/**
|
||||
* Get keys for a private account.
|
||||
*
|
||||
* Returns the nullifier public key (NPK) and incoming viewing public key (IPK)
|
||||
* for the specified private account. These keys are safe to share publicly.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `account_id`: The account ID (32 bytes)
|
||||
* - `out_keys`: Output pointer for the key data
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful retrieval
|
||||
* - `AccountNotFound` if the private account is not in this wallet
|
||||
* - Error code on other failures
|
||||
*
|
||||
* # Memory
|
||||
* The keys structure must be freed with `wallet_ffi_free_private_account_keys()`.
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
* - `out_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_get_private_account_keys(struct WalletHandle *handle,
|
||||
const struct FfiBytes32 *account_id,
|
||||
struct FfiPrivateAccountKeys *out_keys);
|
||||
|
||||
/**
|
||||
* Free private account keys returned by `wallet_ffi_get_private_account_keys`.
|
||||
*
|
||||
* # Safety
|
||||
* The keys must be either null or valid keys returned by
|
||||
* `wallet_ffi_get_private_account_keys`.
|
||||
*/
|
||||
void wallet_ffi_free_private_account_keys(struct FfiPrivateAccountKeys *keys);
|
||||
|
||||
/**
|
||||
* Convert an account ID to a Base58 string.
|
||||
*
|
||||
* # Parameters
|
||||
* - `account_id`: The account ID (32 bytes)
|
||||
*
|
||||
* # Returns
|
||||
* - Pointer to null-terminated Base58 string on success
|
||||
* - Null pointer on error
|
||||
*
|
||||
* # Memory
|
||||
* The returned string must be freed with `wallet_ffi_free_string()`.
|
||||
*
|
||||
* # Safety
|
||||
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
*/
|
||||
char *wallet_ffi_account_id_to_base58(const struct FfiBytes32 *account_id);
|
||||
|
||||
/**
|
||||
* Parse a Base58 string into an account ID.
|
||||
*
|
||||
* # Parameters
|
||||
* - `base58_str`: Null-terminated Base58 string
|
||||
* - `out_account_id`: Output pointer for the account ID (32 bytes)
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful parsing
|
||||
* - `InvalidAccountId` if the string is not valid Base58
|
||||
* - Error code on other failures
|
||||
*
|
||||
* # Safety
|
||||
* - `base58_str` must be a valid pointer to a null-terminated C string
|
||||
* - `out_account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_account_id_from_base58(const char *base58_str,
|
||||
struct FfiBytes32 *out_account_id);
|
||||
|
||||
/**
|
||||
* Synchronize private accounts to a specific block.
|
||||
*
|
||||
* This scans the blockchain from the last synced block to the specified block,
|
||||
* updating private account balances based on any relevant transactions.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `block_id`: Target block number to sync to
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` if synchronization completed
|
||||
* - `SyncError` if synchronization failed
|
||||
* - Error code on other failures
|
||||
*
|
||||
* # Note
|
||||
* This operation can take a while for large block ranges. The wallet
|
||||
* internally uses a progress bar which may output to stdout.
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_sync_to_block(struct WalletHandle *handle, uint64_t block_id);
|
||||
|
||||
/**
|
||||
* Get the last synced block number.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `out_block_id`: Output pointer for the block number
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on success
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `out_block_id` must be a valid pointer to a `u64`
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_get_last_synced_block(struct WalletHandle *handle,
|
||||
uint64_t *out_block_id);
|
||||
|
||||
/**
|
||||
* Get the current block height from the sequencer.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `out_block_height`: Output pointer for the current block height
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on success
|
||||
* - `NetworkError` if the sequencer is unreachable
|
||||
* - Error code on other failures
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `out_block_height` must be a valid pointer to a `u64`
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_get_current_block_height(struct WalletHandle *handle,
|
||||
uint64_t *out_block_height);
|
||||
|
||||
/**
|
||||
* Send a public token transfer.
|
||||
*
|
||||
* Transfers tokens from one public account to another on the network.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `from`: Source account ID (must be owned by this wallet)
|
||||
* - `to`: Destination account ID
|
||||
* - `amount`: Amount to transfer as little-endian [u8; 16]
|
||||
* - `out_result`: Output pointer for transfer result
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` if the transfer was submitted successfully
|
||||
* - `InsufficientFunds` if the source account doesn't have enough balance
|
||||
* - `KeyNotFound` if the source account's signing key is not in this wallet
|
||||
* - Error code on other failures
|
||||
*
|
||||
* # Memory
|
||||
* The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `from` must be a valid pointer to a `FfiBytes32` struct
|
||||
* - `to` must be a valid pointer to a `FfiBytes32` struct
|
||||
* - `amount` must be a valid pointer to a `[u8; 16]` array
|
||||
* - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_transfer_public(struct WalletHandle *handle,
|
||||
const struct FfiBytes32 *from,
|
||||
const struct FfiBytes32 *to,
|
||||
const uint8_t (*amount)[16],
|
||||
struct FfiTransferResult *out_result);
|
||||
|
||||
/**
|
||||
* Register a public account on the network.
|
||||
*
|
||||
* This initializes a public account on the blockchain. The account must be
|
||||
* owned by this wallet.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
* - `account_id`: Account ID to register
|
||||
* - `out_result`: Output pointer for registration result
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` if the registration was submitted successfully
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Memory
|
||||
* The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||
* - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_register_public_account(struct WalletHandle *handle,
|
||||
const struct FfiBytes32 *account_id,
|
||||
struct FfiTransferResult *out_result);
|
||||
|
||||
/**
|
||||
* Free a transfer result returned by `wallet_ffi_transfer_public` or
|
||||
* `wallet_ffi_register_public_account`.
|
||||
*
|
||||
* # Safety
|
||||
* The result must be either null or a valid result from a transfer function.
|
||||
*/
|
||||
void wallet_ffi_free_transfer_result(struct FfiTransferResult *result);
|
||||
|
||||
/**
|
||||
* Create a new wallet with fresh storage.
|
||||
*
|
||||
* This initializes a new wallet with a new seed derived from the password.
|
||||
* Use this for first-time wallet creation.
|
||||
*
|
||||
* # Parameters
|
||||
* - `config_path`: Path to the wallet configuration file (JSON)
|
||||
* - `storage_path`: Path where wallet data will be stored
|
||||
* - `password`: Password for encrypting the wallet seed
|
||||
*
|
||||
* # Returns
|
||||
* - Opaque wallet handle on success
|
||||
* - Null pointer on error (call `wallet_ffi_get_last_error()` for details)
|
||||
*
|
||||
* # Safety
|
||||
* All string parameters must be valid null-terminated UTF-8 strings.
|
||||
*/
|
||||
struct WalletHandle *wallet_ffi_create_new(const char *config_path,
|
||||
const char *storage_path,
|
||||
const char *password);
|
||||
|
||||
/**
|
||||
* Open an existing wallet from storage.
|
||||
*
|
||||
* This loads a wallet that was previously created with `wallet_ffi_create_new()`.
|
||||
*
|
||||
* # Parameters
|
||||
* - `config_path`: Path to the wallet configuration file (JSON)
|
||||
* - `storage_path`: Path where wallet data is stored
|
||||
*
|
||||
* # Returns
|
||||
* - Opaque wallet handle on success
|
||||
* - Null pointer on error (call `wallet_ffi_get_last_error()` for details)
|
||||
*
|
||||
* # Safety
|
||||
* All string parameters must be valid null-terminated UTF-8 strings.
|
||||
*/
|
||||
struct WalletHandle *wallet_ffi_open(const char *config_path, const char *storage_path);
|
||||
|
||||
/**
|
||||
* Destroy a wallet handle and free its resources.
|
||||
*
|
||||
* After calling this function, the handle is invalid and must not be used.
|
||||
*
|
||||
* # Safety
|
||||
* - The handle must be either null or a valid handle from `wallet_ffi_create_new()` or
|
||||
* `wallet_ffi_open()`.
|
||||
* - The handle must not be used after this call.
|
||||
*/
|
||||
void wallet_ffi_destroy(struct WalletHandle *handle);
|
||||
|
||||
/**
|
||||
* Save wallet state to persistent storage.
|
||||
*
|
||||
* This should be called periodically or after important operations to ensure
|
||||
* wallet data is persisted to disk.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
*
|
||||
* # Returns
|
||||
* - `Success` on successful save
|
||||
* - Error code on failure
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
*/
|
||||
enum WalletFfiError wallet_ffi_save(struct WalletHandle *handle);
|
||||
|
||||
/**
|
||||
* Get the sequencer address from the wallet configuration.
|
||||
*
|
||||
* # Parameters
|
||||
* - `handle`: Valid wallet handle
|
||||
*
|
||||
* # Returns
|
||||
* - Pointer to null-terminated string on success (caller must free with
|
||||
* `wallet_ffi_free_string()`)
|
||||
* - Null pointer on error
|
||||
*
|
||||
* # Safety
|
||||
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||
*/
|
||||
char *wallet_ffi_get_sequencer_addr(struct WalletHandle *handle);
|
||||
|
||||
/**
|
||||
* Free a string returned by wallet FFI functions.
|
||||
*
|
||||
* # Safety
|
||||
* The pointer must be either null or a valid string returned by an FFI function.
|
||||
*/
|
||||
void wallet_ffi_free_string(char *ptr);
|
||||
|
||||
#endif /* WALLET_FFI_H */
|
||||
@ -26,7 +26,7 @@ itertools.workspace = true
|
||||
sha2.workspace = true
|
||||
futures.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
async-stream = "0.3.6"
|
||||
async-stream.workspace = true
|
||||
indicatif = { version = "0.18.3", features = ["improved_unicode"] }
|
||||
optfield = "0.4.0"
|
||||
url.workspace = true
|
||||
|
||||
@ -4,7 +4,7 @@ use std::{
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::sequencer_client::BasicAuth;
|
||||
use common::config::BasicAuth;
|
||||
use key_protocol::key_management::{
|
||||
KeyChain,
|
||||
key_tree::{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user