Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/elements/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
106 changes: 105 additions & 1 deletion src/elements/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)>,
Expand Down Expand Up @@ -53,6 +55,39 @@ impl AssetRegistry {
)
}

pub fn search(&self, query: &str, limit: usize) -> Vec<AssetEntry<'_>> {
let query = query.trim();
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")?;
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -192,3 +227,72 @@ 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>,
Comment thread
junderw marked this conversation as resolved.
{
let mut matches = vec![];
let mut remaining = vec![];

for (asset_id, metadata) in candidates {
let position = field(metadata).and_then(|field| {
// registry fields are ascii, so we don't need full unicode case-folding
ascii_ci_find(field, query).map(|position| (position, field))
});
Comment thread
mononaut marked this conversation as resolved.

if let Some((position, field)) = position {
if matches.len() >= SEARCH_SORT_CANDIDATE_LIMIT {
continue;
}
matches.push((position, field, asset_id, metadata));
Comment thread
mononaut marked this conversation as resolved.
} else {
remaining.push((asset_id, metadata));
}
}

matches.sort_unstable_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| ascii_ci_cmp(a.1, b.1))
.then_with(|| a.2.cmp(b.2))
});

(
matches
.into_iter()
.take(limit)
.map(|(_, _, asset_id, metadata)| (asset_id, metadata))
.collect(),
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<usize> {
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())
}
34 changes: 33 additions & 1 deletion src/new_index/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -271,6 +271,15 @@ impl Query {
)
}

#[cfg(feature = "liquid")]
pub fn lookup_registry_asset(&self, asset_id: &AssetId) -> Result<Option<AssetMeta>> {
let asset_db = self
.asset_db
.as_ref()
.chain_err(|| "asset registry unavailable")?;
Ok(asset_db.read().unwrap().get(asset_id).cloned())
}
Comment thread
mononaut marked this conversation as resolved.

#[cfg(feature = "liquid")]
pub fn list_registry_assets(
&self,
Expand Down Expand Up @@ -298,4 +307,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())
}
}
81 changes: 80 additions & 1 deletion src/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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);

Comment thread
mononaut marked this conversation as resolved.
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
Expand All @@ -1800,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)?;
Expand Down
Loading