add liquid asset search endpoint

This commit is contained in:
mononaut 2026-05-25 14:59:52 +00:00
parent 6dfe5295fe
commit 88d0721033
No known key found for this signature in database
GPG Key ID: BFD16BE592A9CD8D
4 changed files with 171 additions and 4 deletions

View File

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

View File

@ -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<AssetId, (SystemTime, AssetMeta)>,
@ -53,6 +55,39 @@ impl AssetRegistry {
)
}
pub fn search(&self, query: &str, limit: usize) -> Vec<AssetEntry<'_>> {
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<String>, b: &Option<String>) -> 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<AssetEntry<'a>>, Vec<AssetEntry<'a>>)
where
I: IntoIterator<Item = AssetEntry<'a>>,
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,
)
}

View File

@ -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::<Result<Vec<_>>>()?;
Ok((total_num, results))
}
#[cfg(feature = "liquid")]
pub fn search_registry_assets<T, F>(
&self,
search: &str,
limit: usize,
mut map: F,
) -> Result<Vec<T>>
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())
}
}

View File

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
domain: Option<String>,
}
#[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::<usize>().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