Skip to content
Open
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
332 changes: 317 additions & 15 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ default-run = "electrs"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(has_error_description_deprecated)"] }

[features]
liquid = ["elements"]
liquid = ["elements", "reqwest"]
electrum-discovery = ["electrum-client"]
bench = []
otlp-tracing = [
Expand Down Expand Up @@ -59,7 +59,7 @@ serde_json = "1.0.60"
signal-hook = "0.4"
stderrlog = "0.6"
sysconf = ">=0.3.4"
time = { version = "0.3", features = ["formatting"] }
time = { version = "0.3", features = ["formatting", "parsing"] }
tiny_http = "0.12.0"
url = "2.2.0"
hyper = { version = "1", features = ["http1", "server"] }
Expand All @@ -73,6 +73,7 @@ tracing-subscriber = { version = "0.3.17", default-features = false, features =
opentelemetry-semantic-conventions = { version = "0.12.0", optional = true }
tracing = { version = "0.1.40", default-features = false, features = ["attributes"], optional = true }
rand = "0.9.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }

# optional dependencies for electrum-discovery
electrum-client = { version = "0.8", optional = true }
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ In addition to electrs's original configuration options, a few new options are a

Additional options with the `liquid` feature:
- `--parent-network <network>` - the parent network this chain is pegged to.
- `--asset-registry-url <url>` - base URL for the Liquid asset registry v2 service.

Asset registry timeouts can be configured in milliseconds with the
`ELECTRS_ASSET_REGISTRY_CONNECT_TIMEOUT_MS` and
`ELECTRS_ASSET_REGISTRY_REQUEST_TIMEOUT_MS` environment variables. They default
to 2000 ms and 5000 ms, respectively.

The registry URL must be a public HTTP(S) URL without embedded credentials.
Successful per-asset lookups are cached for 15 seconds. After expiry, electrs
serves the cached metadata with `X-Asset-Registry-Status: stale` and
`Cache-Control: no-store` while one background refresh runs; an initial lookup
failure returns a gateway error instead of an incomplete asset response.

Additional options with the `electrum-discovery` feature:
- `--electrum-hosts <json>` - a json map of the public hosts where the electrum server is reachable, in the [`server.features` format](https://electrum-protocol.readthedocs.io/en/latest/protocol-methods.html#server-features).
Expand Down
16 changes: 9 additions & 7 deletions src/bin/electrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use electrs::{
use electrs::otlp_trace;

#[cfg(feature = "liquid")]
use electrs::elements::AssetRegistry;
use electrs::elements::RegistryClient;
use electrs::metrics::MetricOpts;

/// Default salt rotation interval in seconds (24 hours)
Expand Down Expand Up @@ -115,19 +115,21 @@ fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<(
}

#[cfg(feature = "liquid")]
let asset_db = config.asset_db_path.as_ref().map(|db_dir| {
let asset_db = Arc::new(RwLock::new(AssetRegistry::new(db_dir.clone())));
AssetRegistry::spawn_sync(asset_db.clone());
asset_db
});
let asset_registry = config
.asset_registry_url
.as_ref()
.map(|url| RegistryClient::new(url.as_url().clone()))
.transpose()
.chain_err(|| "failed creating asset registry client")?
.map(Arc::new);

let query = Arc::new(Query::new(
Arc::clone(&chain),
Arc::clone(&mempool),
Arc::clone(&daemon),
Arc::clone(&config),
#[cfg(feature = "liquid")]
asset_db,
asset_registry,
));

// TODO: configuration for which servers to start
Expand Down
136 changes: 132 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use stderrlog;
#[cfg(feature = "liquid")]
use url::Url;

use crate::chain::Network;
use crate::daemon::CookieGetter;
Expand Down Expand Up @@ -45,6 +47,35 @@ impl fmt::Debug for SensitiveAuth {
}
}

#[cfg(feature = "liquid")]
#[derive(Clone)]
pub struct SensitiveUrl(Url);

#[cfg(feature = "liquid")]
impl SensitiveUrl {
pub fn new(url: Url) -> Self {
Self(url)
}

pub fn as_url(&self) -> &Url {
&self.0
}

pub fn into_url(self) -> Url {
self.0
}
}

#[cfg(feature = "liquid")]
impl fmt::Debug for SensitiveUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut redacted = self.0.clone();
let _ = redacted.set_password(None);
let _ = redacted.set_username("");
write!(f, "{}", redacted)
}
}

#[derive(Debug, Clone)]
pub struct Config {
// See below for the documentation of each field:
Expand Down Expand Up @@ -114,7 +145,7 @@ pub struct Config {
#[cfg(feature = "liquid")]
pub parent_network: BNetwork,
#[cfg(feature = "liquid")]
pub asset_db_path: Option<PathBuf>,
pub asset_registry_url: Option<SensitiveUrl>,

#[cfg(feature = "electrum-discovery")]
pub electrum_public_hosts: Option<crate::electrum::ServerHosts>,
Expand Down Expand Up @@ -356,9 +387,17 @@ impl Config {
.takes_value(true),
)
.arg(
Arg::with_name("asset_registry_url")
Comment thread
Randy808 marked this conversation as resolved.
.long("asset-registry-url")
.help("Base URL for the Liquid asset registry v2 service")
.takes_value(true),
)
.arg(
// Retained so upgraded deployments receive an actionable migration error
// instead of clap's generic unknown-argument message.
Arg::with_name("asset_db_path")
.long("asset-db-path")
.help("Directory for liquid/elements asset db")
.hidden(true)
.takes_value(true),
);

Expand Down Expand Up @@ -397,7 +436,21 @@ impl Config {
});

#[cfg(feature = "liquid")]
let asset_db_path = m.value_of("asset_db_path").map(PathBuf::from);
if m.value_of("asset_db_path").is_some() {
clap::Error::with_description(
"--asset-db-path is no longer supported; the on-disk asset registry has been \
replaced by the v2 HTTP registry — configure it with --asset-registry-url",
clap::ErrorKind::InvalidValue,
)
.exit();
}
#[cfg(feature = "liquid")]
let asset_registry_url = match m.value_of("asset_registry_url") {
Some(value) => Some(parse_asset_registry_url(value).unwrap_or_else(|e| {
clap::Error::with_description(&e, clap::ErrorKind::InvalidValue).exit()
})),
None => None,
};

let default_daemon_port = match network_type {
#[cfg(not(feature = "liquid"))]
Expand Down Expand Up @@ -602,7 +655,7 @@ impl Config {
#[cfg(feature = "liquid")]
parent_network,
#[cfg(feature = "liquid")]
asset_db_path,
asset_registry_url,

#[cfg(feature = "electrum-discovery")]
electrum_public_hosts,
Expand Down Expand Up @@ -650,6 +703,31 @@ impl RpcLogging {
}
}

#[cfg(feature = "liquid")]
fn parse_asset_registry_url(value: &str) -> std::result::Result<SensitiveUrl, String> {
let url = Url::parse(value).map_err(|error| {
format!(
"--asset-registry-url is not a valid URL: {} (did you forget the http:// or \
https:// scheme?)",
error
)
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(format!(
"--asset-registry-url must use http or https (got scheme '{}')",
url.scheme()
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(
"--asset-registry-url must not contain a username or password; configure a public \
registry URL"
.to_string(),
);
}
Ok(SensitiveUrl::new(url))
}

pub fn get_network_subdir(network: Network) -> Option<&'static str> {
match network {
#[cfg(not(feature = "liquid"))]
Expand Down Expand Up @@ -699,6 +777,10 @@ impl CookieGetter for CookieFile {
#[cfg(test)]
mod tests {
use super::SensitiveAuth;
#[cfg(feature = "liquid")]
use super::{parse_asset_registry_url, SensitiveUrl};
#[cfg(feature = "liquid")]
use url::Url;

#[test]
fn sensitive_auth_debug_redacts_password() {
Expand All @@ -709,4 +791,50 @@ mod tests {
assert_eq!(rendered, r#"UserPass("poc-user", "<sensitive>")"#);
assert!(!rendered.contains(password));
}

#[cfg(feature = "liquid")]
#[test]
fn sensitive_url_debug_redacts_userinfo() {
let url = SensitiveUrl::new(Url::parse("https://user:pass@registry.example/api").unwrap());

let rendered = format!("{:?}", url);
assert_eq!(rendered, "https://registry.example/api");
assert!(!rendered.contains("user"));
assert!(!rendered.contains("pass"));
}

#[cfg(feature = "liquid")]
#[test]
fn sensitive_url_debug_does_not_leak_password_with_empty_user() {
let url = SensitiveUrl::new(Url::parse("https://:pass@registry.example/api").unwrap());

let rendered = format!("{:?}", url);
assert_eq!(rendered, "https://registry.example/api");
assert!(!rendered.contains("pass"));
}

#[cfg(feature = "liquid")]
#[test]
fn parse_asset_registry_url_rejects_missing_scheme() {
let error = parse_asset_registry_url("registry.example.com/api").unwrap_err();

assert!(error.contains("http://"));
assert!(error.contains("https://"));
}

#[cfg(feature = "liquid")]
#[test]
fn parse_asset_registry_url_rejects_wrong_scheme() {
let error = parse_asset_registry_url("ftp://registry.example/api").unwrap_err();

assert!(error.contains("http or https"));
}

#[cfg(feature = "liquid")]
#[test]
fn parse_asset_registry_url_rejects_credentialed_url() {
let error = parse_asset_registry_url("https://user:pass@registry.example/api").unwrap_err();

assert!(error.contains("must not contain a username or password"));
}
}
9 changes: 2 additions & 7 deletions src/elements/asset.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};

use bitcoin::hashes::{sha256, Hash};
use elements::confidential::{Asset, Value};
Expand All @@ -9,7 +8,7 @@ use elements::{issuance::ContractHash, AssetId, AssetIssuance, OutPoint, Transac

use crate::chain::{BNetwork, BlockHash, Network, Txid};
use crate::elements::peg::{get_pegin_data, get_pegout_data, PeginInfo, PegoutInfo};
use crate::elements::registry::{AssetMeta, AssetRegistry};
use crate::elements::registry::AssetMeta;
use crate::errors::*;
use crate::new_index::schema::{TxHistoryInfo, TxHistoryKey, TxHistoryRow};
use crate::new_index::{db::DBFlush, ChainQuery, DBRow, Mempool, Query};
Expand Down Expand Up @@ -351,9 +350,8 @@ fn asset_history_row(

pub fn lookup_asset(
query: &Query,
registry: Option<&Arc<RwLock<AssetRegistry>>>,
asset_id: &AssetId,
meta: Option<&AssetMeta>, // may optionally be provided if already known
meta: Option<AssetMeta>,
) -> Result<Option<LiquidAsset>> {
if query.network().pegged_asset() == Some(asset_id) {
let (chain_stats, mempool_stats) = pegged_asset_stats(query, asset_id);
Expand All @@ -380,9 +378,6 @@ pub fn lookup_asset(
Ok(if let Some(row) = row {
let reissuance_token = parse_asset_id(&row.reissuance_token);

let meta = meta
.cloned()
.or_else(|| registry.and_then(|r| r.read().unwrap().get(asset_id).cloned()));
let stats = issued_asset_stats(query.chain(), &mempool, asset_id, &reissuance_token);
let status = query.get_tx_status(&deserialize(&row.issuance_txid).unwrap());

Expand Down
5 changes: 4 additions & 1 deletion src/elements/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ mod registry;

use asset::get_issuance_entropy;
pub use asset::{lookup_asset, LiquidAsset};
pub use registry::{AssetRegistry, AssetSorting};
pub use registry::{
AssetMeta, AssetSearchFilters, AssetSorting, RegistryAsset, RegistryAssetList, RegistryClient,
RegistryContract, RegistryError, RegistryIcon,
};

#[derive(Serialize, Deserialize, Clone)]
pub struct IssuanceValue {
Expand Down
Loading
Loading