fix: use base58 encoding for account in Explorer & some formatting chores

This commit is contained in:
Daniil Polyakov 2026-02-13 23:54:50 +03:00
parent 439392cf26
commit 8b16318c38
16 changed files with 169 additions and 119 deletions

6
Cargo.lock generated
View File

@ -2484,7 +2484,6 @@ dependencies = [
"console_error_panic_hook",
"console_log",
"env_logger",
"hex",
"indexer_service_protocol",
"indexer_service_rpc",
"jsonrpsee",
@ -3427,12 +3426,16 @@ dependencies = [
name = "indexer_service_protocol"
version = "0.1.0"
dependencies = [
"anyhow",
"base58",
"base64 0.22.1",
"common",
"hex",
"nssa",
"nssa_core",
"schemars 1.2.0",
"serde",
"serde_with",
]
[[package]]
@ -8257,7 +8260,6 @@ dependencies = [
"amm_core",
"anyhow",
"async-stream",
"base58",
"base64 0.22.1",
"borsh",
"bytemuck",

View File

@ -26,9 +26,6 @@ console_log = "1.0"
# Date/Time
chrono.workspace = true
# Hex encoding/decoding
hex.workspace = true
# URL encoding
urlencoding = "2.1"

View File

@ -1,3 +1,5 @@
use std::str::FromStr as _;
use indexer_service_protocol::{Account, AccountId, Block, BlockId, HashType, Transaction};
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
@ -25,13 +27,6 @@ pub async fn get_account(account_id: AccountId) -> Result<Account, ServerFnError
.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> {
@ -42,12 +37,8 @@ pub async fn search(query: String) -> Result<SearchResults, ServerFnError> {
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 = HashType(hash_array);
// Try as hash
if let Ok(hash) = HashType::from_str(&query) {
// Try as block hash
if let Ok(block) = client.get_block_by_hash(hash).await {
blocks.push(block);
@ -57,12 +48,13 @@ pub async fn search(query: String) -> Result<SearchResults, ServerFnError> {
if let Ok(tx) = client.get_transaction(hash).await {
transactions.push(tx);
}
}
// Try as account ID
let account_id = AccountId { value: hash_array };
if let Ok(account) = client.get_account(account_id).await {
accounts.push((account_id, account));
}
// Try as account ID
if let Ok(account_id) = AccountId::from_str(&query)
&& let Ok(account) = client.get_account(account_id).await
{
accounts.push((account_id, account));
}
// Try as block ID

View File

@ -2,12 +2,10 @@ 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: Account) -> impl IntoView {
let account_id_str = format_utils::format_account_id(&account_id);
let account_id_str = account_id.to_string();
view! {
<div class="account-preview">
@ -20,7 +18,7 @@ pub fn AccountPreview(account_id: AccountId, account: Account) -> impl IntoView
</div>
{move || {
let Account { program_owner, balance, data, nonce } = &account;
let program_id = format_utils::format_program_id(program_owner);
let program_id = program_owner.to_string();
view! {
<div class="account-preview-body">
<div class="account-field">

View File

@ -32,8 +32,8 @@ pub fn BlockPreview(block: Block) -> impl IntoView {
let tx_count = transactions.len();
let hash_str = hex::encode(hash.0);
let prev_hash_str = hex::encode(prev_block_hash.0);
let hash_str = hash.to_string();
let prev_hash_str = prev_block_hash.to_string();
let time_str = format_utils::format_timestamp(timestamp);
let status_str = match &bedrock_status {
BedrockStatus::Pending => "Pending",

View File

@ -15,7 +15,7 @@ fn transaction_type_info(tx: &Transaction) -> (&'static str, &'static str) {
#[component]
pub fn TransactionPreview(transaction: Transaction) -> impl IntoView {
let hash = transaction.hash();
let hash_str = hex::encode(hash.0);
let hash_str = hash.to_string();
let (type_name, type_class) = transaction_type_info(&transaction);
// Get additional metadata based on transaction type

View File

@ -1,7 +1,5 @@
//! 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;
@ -9,25 +7,3 @@ pub fn format_timestamp(timestamp: u64) -> String {
.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()
}

View File

@ -1,8 +1,10 @@
use std::str::FromStr as _;
use indexer_service_protocol::{Account, AccountId};
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::{api, components::TransactionPreview, format_utils};
use crate::{api, components::TransactionPreview};
/// Account page component
#[component]
@ -17,16 +19,7 @@ pub fn AccountPage() -> impl IntoView {
// 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
}
})
AccountId::from_str(&account_id_str).ok()
};
// Load account data
@ -98,8 +91,8 @@ pub fn AccountPage() -> impl IntoView {
} = 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 account_id_str = acc_id.to_string();
let program_id = program_owner.to_string();
let balance_str = balance.to_string();
let nonce_str = nonce.to_string();
let data_len = data.0.len();

View File

@ -1,3 +1,5 @@
use std::str::FromStr as _;
use indexer_service_protocol::{BedrockStatus, Block, BlockBody, BlockHeader, BlockId, HashType};
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
@ -25,11 +27,8 @@ pub fn BlockPage() -> impl IntoView {
}
// 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(HashType(hash_array)));
if let Ok(hash) = HashType::from_str(&id_str) {
return Some(BlockIdOrHash::Hash(hash));
}
None
@ -68,10 +67,10 @@ pub fn BlockPage() -> impl IntoView {
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 hash_str = hash.to_string();
let prev_hash = prev_block_hash.to_string();
let timestamp_str = format_utils::format_timestamp(timestamp);
let signature_str = hex::encode(signature.0);
let signature_str = signature.to_string();
let status = match &bedrock_status {
BedrockStatus::Pending => "Pending",
BedrockStatus::Safe => "Safe",

View File

@ -1,3 +1,5 @@
use std::str::FromStr as _;
use indexer_service_protocol::{
HashType, PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, PublicMessage, PublicTransaction, Transaction, WitnessSet,
@ -5,7 +7,7 @@ use indexer_service_protocol::{
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::{api, format_utils};
use crate::api;
/// Transaction page component
#[component]
@ -14,15 +16,10 @@ pub fn TransactionPage() -> impl IntoView {
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(HashType(hash_array))
} else {
None
}
})
params
.read()
.get("hash")
.and_then(|s| HashType::from_str(&s).ok())
},
|hash_opt| async move {
match hash_opt {
@ -42,7 +39,7 @@ pub fn TransactionPage() -> impl IntoView {
.get()
.map(|result| match result {
Ok(tx) => {
let tx_hash = format_utils::format_hash(&tx.hash().0);
let tx_hash = tx.hash().to_string();
let tx_type = match &tx {
Transaction::Public(_) => "Public Transaction",
Transaction::PrivacyPreserving(_) => "Privacy-Preserving Transaction",
@ -86,10 +83,7 @@ pub fn TransactionPage() -> impl IntoView {
proof,
} = witness_set;
let program_id_str = program_id
.iter()
.map(|n| format!("{:08x}", n))
.collect::<String>();
let program_id_str = program_id.to_string();
let proof_len = proof.0.len();
let signatures_count = signatures_and_public_keys.len();
@ -123,7 +117,7 @@ pub fn TransactionPage() -> impl IntoView {
.into_iter()
.zip(nonces.into_iter())
.map(|(account_id, nonce)| {
let account_id_str = format_utils::format_account_id(&account_id);
let account_id_str = account_id.to_string();
view! {
<div class="account-item">
<A href=format!("/account/{}", account_id_str)>
@ -197,7 +191,7 @@ pub fn TransactionPage() -> impl IntoView {
.into_iter()
.zip(nonces.into_iter())
.map(|(account_id, nonce)| {
let account_id_str = format_utils::format_account_id(&account_id);
let account_id_str = account_id.to_string();
view! {
<div class="account-item">
<A href=format!("/account/{}", account_id_str)>

View File

@ -10,8 +10,12 @@ nssa = { workspace = true, optional = true }
common = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_with.workspace = true
schemars.workspace = true
base64.workspace = true
base58.workspace = true
hex.workspace = true
anyhow.workspace = true
[features]
# Enable conversion to/from NSSA core types

View File

@ -6,6 +6,18 @@ use crate::*;
// Account-related conversions
// ============================================================================
impl From<[u32; 8]> for ProgramId {
fn from(value: [u32; 8]) -> Self {
Self(value)
}
}
impl From<ProgramId> for [u32; 8] {
fn from(value: ProgramId) -> Self {
value.0
}
}
impl From<nssa_core::account::AccountId> for AccountId {
fn from(value: nssa_core::account::AccountId) -> Self {
Self {
@ -31,7 +43,7 @@ impl From<nssa_core::account::Account> for Account {
} = value;
Self {
program_owner,
program_owner: program_owner.into(),
balance,
data: data.into(),
nonce,
@ -51,7 +63,7 @@ impl TryFrom<Account> for nssa_core::account::Account {
} = value;
Ok(nssa_core::account::Account {
program_owner,
program_owner: program_owner.into(),
balance,
data: data.try_into()?,
nonce,
@ -230,7 +242,7 @@ impl From<nssa::public_transaction::Message> for PublicMessage {
instruction_data,
} = value;
Self {
program_id,
program_id: program_id.into(),
account_ids: account_ids.into_iter().map(Into::into).collect(),
nonces,
instruction_data,
@ -247,7 +259,7 @@ impl From<PublicMessage> for nssa::public_transaction::Message {
instruction_data,
} = value;
Self::new_preserialized(
program_id,
program_id.into(),
account_ids.into_iter().map(Into::into).collect(),
nonces,
instruction_data,

View File

@ -3,23 +3,81 @@
//! Currently it mostly mimics types from `nssa_core`, but it's important to have a separate crate
//! to define a stable interface for the indexer service RPCs which evolves in its own way.
use std::{fmt::Display, str::FromStr};
use anyhow::anyhow;
use base58::{FromBase58 as _, ToBase58 as _};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};
#[cfg(feature = "convert")]
mod convert;
pub type Nonce = u128;
pub type ProgramId = [u32; 8];
#[derive(
Debug, Copy, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr, JsonSchema,
)]
pub struct ProgramId(pub [u32; 8]);
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
impl Display for ProgramId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let bytes: Vec<u8> = self.0.iter().flat_map(|n| n.to_be_bytes()).collect();
write!(f, "{}", bytes.to_base58())
}
}
impl FromStr for ProgramId {
type Err = hex::FromHexError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = s
.from_base58()
.map_err(|_| hex::FromHexError::InvalidStringLength)?;
if bytes.len() != 32 {
return Err(hex::FromHexError::InvalidStringLength);
}
let mut arr = [0u32; 8];
for (i, chunk) in bytes.chunks_exact(4).enumerate() {
arr[i] = u32::from_be_bytes(chunk.try_into().unwrap());
}
Ok(ProgramId(arr))
}
}
#[derive(
Debug, Copy, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr, JsonSchema,
)]
pub struct AccountId {
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded account ID")]
pub value: [u8; 32],
}
impl Display for AccountId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value.to_base58())
}
}
impl FromStr for AccountId {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = s
.from_base58()
.map_err(|err| anyhow!("invalid base58: {err:?}"))?;
if bytes.len() != 32 {
return Err(anyhow!(
"invalid length: expected 32 bytes, got {}",
bytes.len()
));
}
let mut value = [0u8; 32];
value.copy_from_slice(&bytes);
Ok(AccountId { value })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct Account {
pub program_owner: ProgramId,
@ -48,13 +106,27 @@ pub struct BlockHeader {
pub signature: Signature,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr, JsonSchema)]
pub struct Signature(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded signature")]
pub [u8; 64],
#[schemars(with = "String", description = "hex-encoded signature")] pub [u8; 64],
);
impl Display for Signature {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
impl FromStr for Signature {
type Err = hex::FromHexError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut bytes = [0u8; 64];
hex::decode_to_slice(s, &mut bytes)?;
Ok(Signature(bytes))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct BlockBody {
pub transactions: Vec<Transaction>,
@ -196,12 +268,26 @@ pub struct Data(
pub Vec<u8>,
);
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct HashType(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded hash")]
pub [u8; 32],
);
#[derive(
Debug, Copy, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr, JsonSchema,
)]
pub struct HashType(pub [u8; 32]);
impl Display for HashType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
impl FromStr for HashType {
type Err = hex::FromHexError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut bytes = [0u8; 32];
hex::decode_to_slice(s, &mut bytes)?;
Ok(HashType(bytes))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct MantleMsgId(

View File

@ -4,8 +4,8 @@ use indexer_service_protocol::{
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment,
CommitmentSetDigest, Data, EncryptedAccountData, HashType, MantleMsgId,
PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, PublicMessage, PublicTransaction, Signature, Transaction,
WitnessSet,
ProgramDeploymentTransaction, ProgramId, PublicMessage, PublicTransaction, Signature,
Transaction, WitnessSet,
};
use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned};
@ -35,7 +35,7 @@ impl MockIndexerService {
accounts.insert(
*account_id,
Account {
program_owner: [i as u32; 8],
program_owner: ProgramId([i as u32; 8]),
balance: 1000 * (i as u128 + 1),
data: Data(vec![0xaa, 0xbb, 0xcc]),
nonce: i as u128,
@ -73,7 +73,7 @@ impl MockIndexerService {
0 | 1 => Transaction::Public(PublicTransaction {
hash: tx_hash,
message: PublicMessage {
program_id: [1u32; 8],
program_id: ProgramId([1u32; 8]),
account_ids: vec![
account_ids[tx_idx as usize % account_ids.len()],
account_ids[(tx_idx as usize + 1) % account_ids.len()],
@ -95,7 +95,7 @@ impl MockIndexerService {
],
nonces: vec![block_id as u128],
public_post_states: vec![Account {
program_owner: [1u32; 8],
program_owner: ProgramId([1u32; 8]),
balance: 500,
data: Data(vec![0xdd, 0xee]),
nonce: block_id as u128,

View File

@ -22,7 +22,6 @@ clap.workspace = true
base64.workspace = true
bytemuck.workspace = true
borsh.workspace = true
base58.workspace = true
hex.workspace = true
rand.workspace = true
itertools.workspace = true

View File

@ -1,5 +1,4 @@
use anyhow::Result;
use base58::ToBase58;
use clap::Subcommand;
use itertools::Itertools as _;
use key_protocol::key_management::key_tree::chain_index::ChainIndex;
@ -104,8 +103,7 @@ impl WalletSubcommand for NewSubcommand {
.unwrap();
println!(
"Generated new account with account_id Private/{} at path {chain_index}",
account_id.to_bytes().to_base58()
"Generated new account with account_id Private/{account_id} at path {chain_index}",
);
println!("With npk {}", hex::encode(key.nullifer_public_key.0));
println!(