Expose cashtoken authchain information and enable configurable ipfs gateway for bcmr

This commit is contained in:
Jakob Notland 2026-06-23 10:20:22 +02:00
parent d5fa393870
commit 5ca12bdf15
5 changed files with 144 additions and 20 deletions

View file

@ -63,3 +63,9 @@ name = "debug"
type = "bool"
doc = "Enable debug RPC endpoints (e.g. txchain inspection). Do not use in production."
default = "false"
[[param]]
name = "riften_ipfs_gateway"
type = "String"
doc = "Optional IPFS gateway prefix for the local riften-ipfs pinning node (e.g. 'http://127.0.0.1:3002/ipfs/'). When set, ipfs:// BCMR content is fetched from here first, before the public gateways — so content pinned on the riften node is loadable without waiting for public DHT propagation. Empty disables it."
default = "\"\".to_string()"

View file

@ -41,18 +41,25 @@ const WELL_KNOWN_PATH: &str = "/.well-known/bitcoin-cash-metadata-registry.json"
/// Resolve an on-chain BCMR URI into one or more candidate URLs to try in order.
///
/// Handles three cases:
/// - `ipfs://<cid>` → a single random IPFS gateway URL.
/// - `ipfs://<cid>` → the local riften-ipfs gateway first (if configured), then a random
/// public IPFS gateway. Trying the riften node first means content pinned there is
/// loadable immediately, without waiting for public DHT propagation.
/// - Bare domain (authority only, no path) → BCMR v2 well-known URI first, then
/// the bare root URL as a fallback for bug-compatibility with tokens that
/// served the registry directly at `/`.
/// - Anything with a path → used as-is (with `https://` prepended if missing).
fn resolve_uri_candidates(uri: &str) -> Vec<String> {
fn resolve_uri_candidates(uri: &str, riften_gateway: &str) -> Vec<String> {
if let Some(stripped) = uri.strip_prefix("ipfs://") {
return vec![format!(
let mut urls = Vec::new();
if !riften_gateway.is_empty() {
urls.push(format!("{riften_gateway}{stripped}"));
}
urls.push(format!(
"{}{}",
IPFS_GATEWAYS.choose(&mut thread_rng()).unwrap(),
stripped
)];
));
return urls;
}
let (scheme, rest) = match uri.split_once("://") {
@ -79,6 +86,9 @@ pub struct BCMRDownloader {
db: SqlitePool,
keep_running: Arc<AtomicBool>,
download_task: Option<JoinHandle<()>>,
/// IPFS gateway prefix for the local riften-ipfs node, tried before public gateways.
/// Empty disables it.
riften_gateway: String,
}
const LOOP_SLEEP_TIME: Duration = Duration::from_secs(10);
@ -97,6 +107,7 @@ async fn fetch_bcmr(
client: &reqwest::Client,
urls: &[String],
expected_hash: &str,
riften_gateway: &str,
) -> (
Option<(String, String)>,
String, /* error */
@ -106,7 +117,7 @@ async fn fetch_bcmr(
let mut errors: Vec<String> = Vec::new();
for url in urls {
for resolved in resolve_uri_candidates(url) {
for resolved in resolve_uri_candidates(url, riften_gateway) {
let (contents, actual_hash) =
match get_url(client, &resolved, DOWNLOAD_TIMEOUT, MAX_BCMR_SIZE).await {
Ok(c) => c,
@ -138,7 +149,12 @@ async fn fetch_bcmr(
}
}
async fn process_entry(client: &reqwest::Client, db: &SqlitePool, entry: &AuthChainEntry) {
async fn process_entry(
client: &reqwest::Client,
db: &SqlitePool,
entry: &AuthChainEntry,
riften_gateway: &str,
) {
info!(
"bcmr: Downloading BCMR for token {}, txid {}",
entry.token_id, entry.txid
@ -163,7 +179,8 @@ async fn process_entry(client: &reqwest::Client, db: &SqlitePool, entry: &AuthCh
}
};
let (json_str, error, is_fatal) = fetch_bcmr(client, &bcmr.uris, &hex::encode(bcmr.hash)).await;
let (json_str, error, is_fatal) =
fetch_bcmr(client, &bcmr.uris, &hex::encode(bcmr.hash), riften_gateway).await;
let (json_str, actual_hash) = match json_str {
Some(j) => j,
@ -234,17 +251,24 @@ async fn process_entry(client: &reqwest::Client, db: &SqlitePool, entry: &AuthCh
}
impl BCMRDownloader {
pub fn new(db: SqlitePool) -> Self {
pub fn new(db: SqlitePool, riften_gateway: String) -> Self {
if riften_gateway.is_empty() {
info!("bcmr: riften IPFS gateway not configured; using public gateways only");
} else {
info!("bcmr: using riften IPFS gateway {riften_gateway} before public gateways");
}
Self {
db,
keep_running: Arc::new(AtomicBool::new(true)),
download_task: None,
riften_gateway,
}
}
pub fn start(&mut self) -> Result<()> {
let db = self.db.clone();
let keep_running = self.keep_running.clone();
let riften_gateway = self.riften_gateway.clone();
self.download_task = Some(tokio::spawn(async move {
let client = reqwest::Client::new();
@ -274,7 +298,7 @@ impl BCMRDownloader {
for chunk in queue.chunks(MAX_PARALLEL_DOWNLOADS) {
let futures: Vec<_> = chunk
.iter()
.map(|entry| process_entry(&client, &db, entry))
.map(|entry| process_entry(&client, &db, entry, &riften_gateway))
.collect();
futures::future::join_all(futures).await;
}
@ -304,7 +328,7 @@ mod tests {
#[test]
fn bare_domain_resolves_to_well_known_then_root() {
let candidates = resolve_uri_candidates("chipnet-16.paryonusd.com");
let candidates = resolve_uri_candidates("chipnet-16.paryonusd.com", "");
assert_eq!(
candidates,
vec![
@ -317,7 +341,7 @@ mod tests {
#[test]
fn bare_domain_with_scheme_and_trailing_slash() {
let candidates = resolve_uri_candidates("https://example.com/");
let candidates = resolve_uri_candidates("https://example.com/", "");
assert_eq!(
candidates,
vec![
@ -329,7 +353,7 @@ mod tests {
#[test]
fn url_with_path_is_used_as_is() {
let candidates = resolve_uri_candidates("https://example.com/path/registry.json");
let candidates = resolve_uri_candidates("https://example.com/path/registry.json", "");
assert_eq!(
candidates,
vec!["https://example.com/path/registry.json".to_string()]
@ -338,7 +362,7 @@ mod tests {
#[test]
fn schemeless_url_with_path_gets_https() {
let candidates = resolve_uri_candidates("example.com/registry.json");
let candidates = resolve_uri_candidates("example.com/registry.json", "");
assert_eq!(
candidates,
vec!["https://example.com/registry.json".to_string()]
@ -346,16 +370,26 @@ mod tests {
}
#[test]
fn ipfs_uri_uses_a_gateway() {
let candidates = resolve_uri_candidates("ipfs://QmAbc123");
fn ipfs_uri_uses_a_public_gateway_when_no_riften_gateway() {
let candidates = resolve_uri_candidates("ipfs://QmAbc123", "");
assert_eq!(candidates.len(), 1);
assert!(candidates[0].ends_with("/QmAbc123"));
assert!(IPFS_GATEWAYS.iter().any(|gw| candidates[0].starts_with(gw)));
}
#[test]
fn ipfs_uri_tries_riften_gateway_first() {
let candidates =
resolve_uri_candidates("ipfs://QmAbc123", "http://127.0.0.1:3002/ipfs/");
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0], "http://127.0.0.1:3002/ipfs/QmAbc123");
assert!(candidates[1].ends_with("/QmAbc123"));
assert!(IPFS_GATEWAYS.iter().any(|gw| candidates[1].starts_with(gw)));
}
#[test]
fn domain_with_port_is_treated_as_bare() {
let candidates = resolve_uri_candidates("example.com:8443");
let candidates = resolve_uri_candidates("example.com:8443", "");
assert_eq!(
candidates,
vec![

View file

@ -350,6 +350,33 @@ pub async fn update_bcmr_failure(
Ok(())
}
/// Return the current authhead of a token's auth chain: the txid of the latest (highest
/// block height) auth_chain_entry. The authhead UTXO is always output 0 of that tx
/// (see `compute_outpoint_hash(&txid, 0)` in the indexer), so the outpoint is `<txid>:0`.
/// Returns None if the token has no auth chain entry.
pub async fn get_current_authhead(
pool: &SqlitePool,
token_hex: &str,
) -> Result<Option<(Txid, usize)>> {
let sql = "SELECT txid, height FROM auth_chain_entry
WHERE token_id = ? ORDER BY height DESC LIMIT 1";
let token_blob = display_hex_to_blob::<TokenID>(token_hex)?;
let row = sqlx::query(sql)
.bind(token_blob)
.fetch_optional(pool)
.await?;
if let Some(r) = row {
let txid_blob: Vec<u8> = r.get(0);
let height: i64 = r.get(1);
let txid = Txid::from_blob(&txid_blob).context("failed to decode txid blob")?;
Ok(Some((txid, height as usize)))
} else {
Ok(None)
}
}
pub async fn get_token_bcmr(pool: &SqlitePool, token_hex: &str) -> Result<Option<ParsedBCMR>> {
let sql = r#"
SELECT symbol, decimals, name, description, icon, web, actual_hash, expected_hash

View file

@ -346,7 +346,8 @@ async fn start_program(
}
});
let mut bcmrdownloader = BCMRDownloader::new(db.bcmr_w.clone());
let mut bcmrdownloader =
BCMRDownloader::new(db.bcmr_w.clone(), config.riften_ipfs_gateway.clone());
bcmrdownloader.start()?;
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
@ -606,7 +607,11 @@ async fn launch() -> _ {
)
.mount(
"/bcmr",
routes![rpc::bcmr::token_bcmr, rpc::bcmr::token_bcmr_all],
routes![
rpc::bcmr::token_bcmr,
rpc::bcmr::token_bcmr_all,
rpc::bcmr::token_authhead
],
)
.mount(
"/oracle",

View file

@ -10,9 +10,12 @@ use serde_json::json;
use serde_json::Value;
use crate::db::bcmr::get_well_known_bcmr;
use crate::db::{bcmr::get_token_bcmr, DB};
use crate::db::{
bcmr::{get_current_authhead, get_token_bcmr},
DB,
};
use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult};
use crate::rpc::response::{cached_ok, CACHE_BCMR};
use crate::rpc::response::{cached_ok, CACHE_BCMR, CACHE_NONE};
/// Fetches BCMR data for token from on-chain registry.
/// Status: Stable
@ -93,3 +96,52 @@ pub async fn token_bcmr_all(category: Option<&str>, db: &State<DB>) -> CachedApi
Ok(cached_ok(json!(bcmr_entries), CACHE_BCMR))
}
/// Returns the current authhead of a token's auth chain — the outpoint that the next
/// BCMR-revision transaction must spend. The authhead is always output 0 of the latest
/// auth chain transaction, so `outpoint` is `<txid>:0`.
///
/// Not cached: consumers (e.g. the riften-ipfs staging-pin flow) need the live head to
/// verify that an unbroadcast revision tx spends it.
///
/// Returns `null` if the token has no auth chain entry.
///
/// **Response Example:**
///
/// ```json
/// {
/// "txid": "8289fa09272ac7930e219a2314965096a441f2f2fffdddd1f766f2cd3734fe28",
/// "vout": 0,
/// "outpoint": "8289fa09272ac7930e219a2314965096a441f2f2fffdddd1f766f2cd3734fe28:0",
/// "height": 123456
/// }
/// ```
#[get("/token/<category>/authhead")]
pub async fn token_authhead(category: Option<&str>, db: &State<DB>) -> CachedApiResult<Value> {
let token_id_hex = category
.context("category missing")
.map_err(|e| bad_request(ApiErrorCode::MissingCategory, &format!("Error: {e}")))?;
let token_id = token_id_hex.parse::<TokenID>().map_err(|e| {
bad_request(
ApiErrorCode::InvalidTokenId,
&format!("Invalid token ID: {e}"),
)
})?;
let head = get_current_authhead(&db.bcmr_r, &token_id.to_string())
.await
.map_err(db_error)?;
let body = match head {
Some((txid, height)) => json!({
"txid": txid.to_string(),
"vout": 0,
"outpoint": format!("{txid}:0"),
"height": height,
}),
None => Value::Null,
};
Ok(cached_ok(body, CACHE_NONE))
}