Merge branch 'arjentix/full-bedrock-integration' into Pravdyvy/indexer-state-management

This commit is contained in:
Pravdyvy 2026-02-03 11:11:19 +02:00
commit e1df915357
98 changed files with 6933 additions and 686 deletions

1306
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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.

View File

@ -1,6 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Message {
L2BlockFinalized { l2_block_height: u64 },
}

View File

@ -1 +0,0 @@
pub mod indexer;

55
common/src/config.rs Normal file
View 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)
}
}

View File

@ -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];

View File

@ -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,
}

View File

@ -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
View File

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

View 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"

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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
View 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,
})
})
}

View 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 = []

View File

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

View File

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

View File

@ -1,11 +1,14 @@
use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction};
use jsonrpsee::{core::SubscriptionResult, proc_macros::rpc, types::ErrorObjectOwned};
use jsonrpsee::proc_macros::rpc;
#[cfg(feature = "server")]
use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned};
#[cfg(all(not(feature = "server"), not(feature = "client")))]
compile_error!("At least one of `server` or `client` features must be enabled.");
#[cfg_attr(feature = "server", rpc(server))]
#[cfg_attr(feature = "client", rpc(client))]
#[cfg_attr(all(feature = "server", not(feature = "client")), rpc(server))]
#[cfg_attr(all(feature = "client", not(feature = "server")), rpc(client))]
#[cfg_attr(all(feature = "server", feature = "client"), rpc(server, client))]
pub trait Rpc {
#[method(name = "get_schema")]
fn get_schema(&self) -> Result<serde_json::Value, ErrorObjectOwned> {
@ -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>;
}

View 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))
}

View File

@ -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();

View 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())
}
}

View 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(())
}
}

View File

@ -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,
})
})
}

View File

@ -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"

View File

@ -1 +0,0 @@
pub mod service;

View File

@ -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!()
}
}

View File

@ -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

View 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)
}

View File

@ -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}")
}

View File

@ -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(),
}
}
}

View File

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

View File

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

View File

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

View File

@ -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 = []

View File

@ -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);

View File

@ -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>,
}

View File

@ -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();

View File

@ -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();

View File

@ -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

View File

@ -162,5 +162,6 @@
"auth": {
"username": "user"
}
}
},
"indexer_rpc_url": "ws://localhost:8779"
}

View File

@ -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...");

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 */

View File

@ -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

View File

@ -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::{