From 88d072103305bba83e2e92ea28571a8a271e7057 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 14:59:52 +0000 Subject: [PATCH 1/3] add liquid asset search endpoint --- src/elements/mod.rs | 2 +- src/elements/registry.rs | 82 +++++++++++++++++++++++++++++++++++++++- src/new_index/query.rs | 25 +++++++++++- src/rest.rs | 66 +++++++++++++++++++++++++++++++- 4 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/elements/mod.rs b/src/elements/mod.rs index 3807f7a..1887a4b 100644 --- a/src/elements/mod.rs +++ b/src/elements/mod.rs @@ -9,7 +9,7 @@ mod registry; use asset::get_issuance_entropy; pub use asset::{lookup_asset, LiquidAsset}; -pub use registry::{AssetRegistry, AssetSorting}; +pub use registry::{AssetMeta, AssetRegistry, AssetSorting}; #[derive(Serialize, Deserialize, Clone)] pub struct IssuanceValue { diff --git a/src/elements/registry.rs b/src/elements/registry.rs index 8a1aa35..78314a3 100644 --- a/src/elements/registry.rs +++ b/src/elements/registry.rs @@ -14,6 +14,8 @@ use crate::errors::*; // (in number of hex characters, not bytes) const DIR_PARTITION_LEN: usize = 2; +const SEARCH_SORT_CANDIDATE_LIMIT: usize = 2000; + pub struct AssetRegistry { directory: path::PathBuf, assets_cache: HashMap, @@ -53,6 +55,39 @@ impl AssetRegistry { ) } + pub fn search(&self, query: &str, limit: usize) -> Vec> { + let query = query.trim().to_lowercase(); + if query.is_empty() || limit == 0 { + return vec![]; + } + + let (mut results, candidates) = search_by( + self.assets_cache + .iter() + .map(|(asset_id, (_, metadata))| (asset_id, metadata)), + &query, + limit, + |metadata| metadata.ticker.as_deref(), + ); + + if results.len() < limit { + let (name_matches, candidates) = + search_by(candidates, &query, limit - results.len(), |metadata| { + Some(&metadata.name) + }); + results.extend(name_matches); + + if results.len() < limit { + let (domain_matches, _) = + search_by(candidates, &query, limit - results.len(), AssetMeta::domain); + results.extend(domain_matches); + } + } + + results.truncate(limit); + results + } + pub fn fs_sync(&mut self) -> Result<()> { for entry in fs::read_dir(&self.directory).chain_err(|| "failed reading asset dir")? { let entry = entry.chain_err(|| "invalid fh")?; @@ -126,7 +161,7 @@ pub struct AssetMeta { } impl AssetMeta { - fn domain(&self) -> Option<&str> { + pub(crate) fn domain(&self) -> Option<&str> { self.entity["domain"].as_str() } } @@ -192,3 +227,48 @@ fn lc_cmp_opt(a: &Option, b: &Option) -> cmp::Ordering { .map(|a| a.to_lowercase()) .cmp(&b.as_ref().map(|b| b.to_lowercase())) } + +fn search_by<'a, I, F>( + candidates: I, + query: &str, + limit: usize, + field: F, +) -> (Vec>, Vec>) +where + I: IntoIterator>, + F: Fn(&AssetMeta) -> Option<&str>, +{ + let mut matches = vec![]; + let mut remaining = vec![]; + + for (asset_id, metadata) in candidates { + let position = field(metadata).and_then(|field| { + let lc_field = field.to_lowercase(); + lc_field.find(query).map(|position| (position, lc_field)) + }); + + if let Some((position, field)) = position { + if matches.len() >= SEARCH_SORT_CANDIDATE_LIMIT { + continue; + } + matches.push((position, field, asset_id, metadata)); + } else { + remaining.push((asset_id, metadata)); + } + } + + matches.sort_unstable_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(b.2)) + }); + + ( + matches + .into_iter() + .take(limit) + .map(|(_, _, asset_id, metadata)| (asset_id, metadata)) + .collect(), + remaining, + ) +} diff --git a/src/new_index/query.rs b/src/new_index/query.rs index 94016aa..2d0017a 100644 --- a/src/new_index/query.rs +++ b/src/new_index/query.rs @@ -14,7 +14,7 @@ use crate::util::{is_spendable, BlockId, Bytes, TransactionStatus}; #[cfg(feature = "liquid")] use crate::{ chain::{asset::AssetRegistryLock, AssetId}, - elements::{lookup_asset, AssetRegistry, AssetSorting, LiquidAsset}, + elements::{lookup_asset, AssetMeta, AssetRegistry, AssetSorting, LiquidAsset}, }; const FEE_ESTIMATES_TTL: u64 = 60; // seconds @@ -298,4 +298,27 @@ impl Query { .collect::>>()?; Ok((total_num, results)) } + + #[cfg(feature = "liquid")] + pub fn search_registry_assets( + &self, + search: &str, + limit: usize, + mut map: F, + ) -> Result> + where + F: FnMut(&AssetId, &AssetMeta) -> T, + { + let asset_db = self + .asset_db + .as_ref() + .chain_err(|| "asset registry unavailable")?; + Ok(asset_db + .read() + .unwrap() + .search(search, limit) + .into_iter() + .map(|(asset_id, metadata)| map(asset_id, metadata)) + .collect()) + } } diff --git a/src/rest.rs b/src/rest.rs index 27006a6..1334b6a 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -35,7 +35,7 @@ use hyperlocal::UnixServerExt; use std::{cmp, fs}; #[cfg(feature = "liquid")] use { - crate::elements::{peg::PegoutValue, AssetSorting, IssuanceValue}, + crate::elements::{peg::PegoutValue, AssetMeta, AssetSorting, IssuanceValue}, elements::{ confidential::{Asset, Nonce, Value}, encode, AssetId, @@ -59,6 +59,12 @@ const MULTI_ADDRESS_LIMIT: usize = 300; const ASSETS_PER_PAGE: usize = 25; #[cfg(feature = "liquid")] const ASSETS_MAX_PER_PAGE: usize = 100; +#[cfg(feature = "liquid")] +const ASSETS_SEARCH_DEFAULT_LIMIT: usize = 15; +#[cfg(feature = "liquid")] +const ASSETS_SEARCH_MAX_LIMIT: usize = 100; +#[cfg(feature = "liquid")] +const ASSETS_SEARCH_MAX_QUERY_LEN: usize = 64; const TTL_LONG: u32 = 157_784_630; // ttl for static resources (5 years) const TTL_SHORT: u32 = 10; // ttl for volatie resources @@ -132,6 +138,32 @@ impl BlockValue { } } +#[cfg(feature = "liquid")] +#[derive(Serialize)] +struct AssetRegistrySearchResult { + asset_id: AssetId, + name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + ticker: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + domain: Option, +} + +#[cfg(feature = "liquid")] +impl AssetRegistrySearchResult { + fn new(asset_id: &AssetId, meta: &AssetMeta) -> Self { + let domain = meta.domain().map(String::from); + Self { + asset_id: *asset_id, + name: meta.name.clone(), + ticker: meta.ticker.clone(), + domain, + } + } +} + /// Calculate the difficulty of a BlockHeader /// using Bitcoin Core code ported to Rust. /// @@ -1774,6 +1806,38 @@ fn handle_request( json_response(recent, TTL_MEMPOOL_RECENT) } + #[cfg(feature = "liquid")] + (&Method::GET, Some(&"assets"), Some(&"registry"), Some(&"search"), None, None) => { + let search = query_params.get("q").map(|q| q.trim()).unwrap_or(""); + let assets = if search.is_empty() { + vec![] + } else if search.chars().count() > ASSETS_SEARCH_MAX_QUERY_LEN { + return Err(HttpError( + StatusCode::BAD_REQUEST, + "search query too long".to_string(), + )); + } else { + let limit = query_params + .get("limit") + .and_then(|n| n.parse::().ok()) + .unwrap_or(ASSETS_SEARCH_DEFAULT_LIMIT) + .min(ASSETS_SEARCH_MAX_LIMIT); + + query + .search_registry_assets(search, limit, AssetRegistrySearchResult::new) + .map_err(|e| { + HttpError(StatusCode::SERVICE_UNAVAILABLE, e.description().to_string()) + })? + }; + + Ok(Response::builder() + // Disable caching because we don't currently support caching with query string params + .header("Cache-Control", "no-store") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&assets)?)) + .unwrap()) + } + #[cfg(feature = "liquid")] (&Method::GET, Some(&"assets"), Some(&"registry"), None, None, None) => { let start_index: usize = query_params From 237a2df61eb4b153fe5bf211588ceb9306839a3e Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 17:43:29 +0000 Subject: [PATCH 2/3] add individual liquid asset registry data endpoint --- src/new_index/query.rs | 9 +++++++++ src/rest.rs | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/new_index/query.rs b/src/new_index/query.rs index 2d0017a..1f49aff 100644 --- a/src/new_index/query.rs +++ b/src/new_index/query.rs @@ -271,6 +271,15 @@ impl Query { ) } + #[cfg(feature = "liquid")] + pub fn lookup_registry_asset(&self, asset_id: &AssetId) -> Result> { + let asset_db = self + .asset_db + .as_ref() + .chain_err(|| "asset registry unavailable")?; + Ok(asset_db.read().unwrap().get(asset_id).cloned()) + } + #[cfg(feature = "liquid")] pub fn list_registry_assets( &self, diff --git a/src/rest.rs b/src/rest.rs index 1334b6a..106d81b 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -1864,6 +1864,21 @@ fn handle_request( .unwrap()) } + #[cfg(feature = "liquid")] + (&Method::GET, Some(&"assets"), Some(&"registry"), Some(asset_str), None, None) => { + let asset_id = AssetId::from_str(asset_str)?; + let registry_entry = query + .lookup_registry_asset(&asset_id) + .map_err(|e| { + HttpError(StatusCode::SERVICE_UNAVAILABLE, e.description().to_string()) + })? + .ok_or_else(|| { + HttpError::not_found("Asset id not found in registry".to_string()) + })?; + + json_response(registry_entry, TTL_SHORT) + } + #[cfg(feature = "liquid")] (&Method::GET, Some(&"asset"), Some(asset_str), None, None, None) => { let asset_id = AssetId::from_str(asset_str)?; From f11f6ab09a896350c15393c30a3131ec3abb9349 Mon Sep 17 00:00:00 2001 From: mononaut Date: Fri, 29 May 2026 09:13:53 +0000 Subject: [PATCH 3/3] avoid lowercase allocations during asset registry search --- src/elements/registry.rs | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/elements/registry.rs b/src/elements/registry.rs index 78314a3..199b2bf 100644 --- a/src/elements/registry.rs +++ b/src/elements/registry.rs @@ -56,7 +56,7 @@ impl AssetRegistry { } pub fn search(&self, query: &str, limit: usize) -> Vec> { - let query = query.trim().to_lowercase(); + let query = query.trim(); if query.is_empty() || limit == 0 { return vec![]; } @@ -65,21 +65,21 @@ impl AssetRegistry { self.assets_cache .iter() .map(|(asset_id, (_, metadata))| (asset_id, metadata)), - &query, + query, limit, |metadata| metadata.ticker.as_deref(), ); if results.len() < limit { let (name_matches, candidates) = - search_by(candidates, &query, limit - results.len(), |metadata| { + search_by(candidates, query, limit - results.len(), |metadata| { Some(&metadata.name) }); results.extend(name_matches); if results.len() < limit { let (domain_matches, _) = - search_by(candidates, &query, limit - results.len(), AssetMeta::domain); + search_by(candidates, query, limit - results.len(), AssetMeta::domain); results.extend(domain_matches); } } @@ -243,8 +243,8 @@ where for (asset_id, metadata) in candidates { let position = field(metadata).and_then(|field| { - let lc_field = field.to_lowercase(); - lc_field.find(query).map(|position| (position, lc_field)) + // registry fields are ascii, so we don't need full unicode case-folding + ascii_ci_find(field, query).map(|position| (position, field)) }); if let Some((position, field)) = position { @@ -259,7 +259,7 @@ where matches.sort_unstable_by(|a, b| { a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) + .then_with(|| ascii_ci_cmp(a.1, b.1)) .then_with(|| a.2.cmp(b.2)) }); @@ -272,3 +272,27 @@ where remaining, ) } + +// zero-allocation case-insensitive ASCII substring search +// returns the byte offset of the first match +fn ascii_ci_find(haystack: &str, needle: &str) -> Option { + let (haystack, needle) = (haystack.as_bytes(), needle.as_bytes()); + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle)) +} + +// zero-allocation case-insensitive ASCII string comparison +fn ascii_ci_cmp(a: &str, b: &str) -> cmp::Ordering { + let (a, b) = (a.as_bytes(), b.as_bytes()); + for i in 0..a.len().min(b.len()) { + match a[i].to_ascii_lowercase().cmp(&b[i].to_ascii_lowercase()) { + cmp::Ordering::Equal => continue, + ord => return ord, + } + } + a.len().cmp(&b.len()) +}