diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 99a24b015..e7a808240 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -928,6 +928,8 @@ pub struct MinibfConfig { pub url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] max_scan_items: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ipfs_gateways: Option>, } impl MinibfConfig { @@ -938,6 +940,7 @@ impl MinibfConfig { token_registry_url: None, url: None, max_scan_items: None, + ipfs_gateways: None, } } @@ -958,6 +961,19 @@ impl MinibfConfig { pub fn max_scan_items(&self) -> u64 { self.max_scan_items.unwrap_or(default_max_scan_items()) } + + /// These are the base URLs of the HTTP gateways, in order. The gateways + /// resolve `ipfs://` governance-anchor URLs. Dolos sends a request to each + /// gateway in order. Dolos stops at the first gateway that serves the + /// content. cardano-db-sync resolves `ipfs://` anchors the same way. + /// + /// An absent setting gives the default list. An empty list stops IPFS + /// resolution, so `ipfs://` anchors do not resolve. + pub fn ipfs_gateways(&self) -> Vec { + self.ipfs_gateways + .clone() + .unwrap_or_else(default_ipfs_gateways) + } } #[derive(Deserialize, Serialize, Clone)] @@ -1036,6 +1052,18 @@ fn default_max_scan_items() -> u64 { 3000 } +fn default_ipfs_gateways() -> Vec { + // The main public gateway is first. A fallback gateway is second. If the + // first gateway returns HTTP 429, the resolver uses the fallback. + // cardano-db-sync uses the same two-entry shape. There the second entry is + // a private gateway of Blockfrost that pins the content. Dolos has no + // private infrastructure, so Dolos names a second public gateway. + vec![ + "https://ipfs.io".to_string(), + "https://gateway.pinata.cloud".to_string(), + ] +} + #[derive(Deserialize, Serialize, Clone, Default)] pub struct ServeConfig { pub ouroboros: Option, diff --git a/crates/minibf/Cargo.toml b/crates/minibf/Cargo.toml index 871395a8a..aba33301e 100644 --- a/crates/minibf/Cargo.toml +++ b/crates/minibf/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [dependencies] tracing.workspace = true serde.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["net"] } tokio-util.workspace = true pallas.workspace = true tower = { workspace = true, features = ["util"] } diff --git a/crates/minibf/src/lib.rs b/crates/minibf/src/lib.rs index 68c21c159..a85afd125 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -638,6 +638,14 @@ where "/governance/proposals/{gov_action_id}", get(routes::governance::proposal_by_gov_action_id::), ) + .route( + "/governance/proposals/{tx_hash}/{cert_index}/metadata", + get(routes::governance::proposal_metadata::), + ) + .route( + "/governance/proposals/{gov_action_id}/metadata", + get(routes::governance::proposal_metadata_by_gov_action::), + ) .route( "/governance/proposals/{tx_hash}/{cert_index}/withdrawals", get(routes::governance::proposal_withdrawals::), diff --git a/crates/minibf/src/mapping.rs b/crates/minibf/src/mapping.rs index af3212780..d3602b76b 100644 --- a/crates/minibf/src/mapping.rs +++ b/crates/minibf/src/mapping.rs @@ -23,8 +23,10 @@ use pallas::{ use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, ops::Deref, - sync::Arc, + sync::{Arc, LazyLock}, time::Duration, }; @@ -35,6 +37,7 @@ use blockfrost_openapi::models::{ block_content_addresses_inner::BlockContentAddressesInner, block_content_addresses_inner_transactions_inner::BlockContentAddressesInnerTransactionsInner, block_content_txs_cbor_inner::BlockContentTxsCborInner, + dreps_inner_metadata_error::{Code, DrepsInnerMetadataError}, script_utxos_inner::ScriptUtxosInner, tx_content::TxContent, tx_content_cbor::TxContentCbor, @@ -149,13 +152,19 @@ pub fn bech32_gov_action(tx: &Hash<32>, idx: u32) -> Result /// `00 01` and the bare 32-byte form are both aliases it serves. An index /// past `u32` can never name a proposal; it saturates so the lookup misses /// instead of failing the parse. +/// +/// CIP-129 identifiers carry the Bech32 checksum, so this decodes with Bech32 +/// and rejects the Bech32m checksum that `bech32::decode` would also accept. pub fn parse_gov_action_id(id: &str) -> Result<(Hash<32>, u32), StatusCode> { - let (hrp, payload) = bech32::decode(id).map_err(|_| StatusCode::BAD_REQUEST)?; + let parsed = bech32::primitives::decode::CheckedHrpstring::new::(id) + .map_err(|_| StatusCode::BAD_REQUEST)?; - if hrp != GOV_ACTION_HRP { + if parsed.hrp() != GOV_ACTION_HRP { return Err(StatusCode::BAD_REQUEST); } + let payload: Vec = parsed.byte_iter().collect(); + let Some((tx, idx)) = payload.split_at_checked(32) else { return Err(StatusCode::BAD_REQUEST); }; @@ -235,6 +244,666 @@ pub async fn pool_offchain_metadata( parse_pool_offchain_metadata(body.as_ref(), expected_hash) } +/// This structure contains governance metadata from an anchor. +pub struct AnchorMetadata { + /// This field contains the JSON body for `json_metadata`. + pub json: serde_json::Value, + + /// This field contains the raw body in the PostgreSQL `bytea` format. + /// The value starts with `\x` and then contains lowercase hexadecimal + /// bytes. + pub bytes: String, +} + +const MAX_ANCHOR_METADATA_BYTES: usize = 3_000_000; +const MAX_ANCHOR_REDIRECTS: usize = 3; + +/// This is the total timeout for one governance-anchor request. It is more than +/// a plain HTTP metadata request needs. An `ipfs://` anchor resolves through a +/// public gateway. A cold content lookup on that gateway can take several +/// seconds. +const ANCHOR_FETCH_TIMEOUT: Duration = Duration::from_secs(15); + +struct PublicDnsResolver; + +impl reqwest::dns::Resolve for PublicDnsResolver { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + let host = name.as_str().to_owned(); + + Box::pin(async move { + let addresses = tokio::net::lookup_host((host.as_str(), 0)) + .await + .map_err(|error| Box::new(error) as Box)? + .filter(|address| is_public_ip(address.ip())) + .collect::>(); + + if addresses.is_empty() { + return Err(Box::new(io::Error::new( + io::ErrorKind::PermissionDenied, + "the host has no public IP address", + )) + as Box); + } + + Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs) + }) + } +} + +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + + !(a == 0 + || a == 10 + || (a == 100 && (64..=127).contains(&b)) + || a == 127 + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && (c == 0 || c == 2)) + || (a == 192 && b == 88 && c == 99) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224) +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + if let Some(ip) = ip.to_ipv4() { + return is_public_ipv4(ip); + } + + let [a, b, ..] = ip.segments(); + + (a & 0xe000) == 0x2000 + && !(a == 0x2001 && b < 0x0200) + && !(a == 0x2001 && b == 0x0db8) + && a != 0x2002 + && !(a == 0x3fff && (b & 0xf000) == 0) +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_ipv4(ip), + IpAddr::V6(ip) => is_public_ipv6(ip), + } +} + +fn is_public_http_url(url: &reqwest::Url) -> bool { + if url.scheme() != "http" && url.scheme() != "https" { + return false; + } + + let Some(host) = url.host_str() else { + return false; + }; + + let host = host.trim_end_matches('.'); + + if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") { + return false; + } + + // `host_str` keeps the brackets around an IPv6 literal, so the client never + // resolves it through the DNS filter. Strip them to classify the literal. + let literal = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + + literal.parse::().map(is_public_ip).unwrap_or(true) +} + +/// This function returns the shared HTTP client for governance-anchor fetches. +/// +/// This function creates the client one time and stores the result. All +/// metadata requests use this client and its connection pool. If the builder +/// returns an error, this function stores and returns the same error. +fn anchor_http_client() -> Result<&'static reqwest::Client, &'static reqwest::Error> { + static CLIENT: LazyLock> = LazyLock::new(|| { + reqwest::Client::builder() + .timeout(ANCHOR_FETCH_TIMEOUT) + .no_proxy() + .dns_resolver(Arc::new(PublicDnsResolver)) + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= MAX_ANCHOR_REDIRECTS { + attempt.error("too many redirects") + } else if is_public_http_url(attempt.url()) { + attempt.follow() + } else { + attempt.error("the redirect target is not public") + } + })) + .user_agent("Dolos MiniBF") + .build() + }); + + CLIENT.as_ref() +} + +fn offchain_hash_mismatch_error( + url: &str, + expected_hash: &[u8], + actual_hash: &[u8], +) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + Code::HashMismatch, + format!( + "Hash mismatch when fetching metadata from {url}. Expected \"{}\" but got \"{}\".", + hex::encode(expected_hash), + hex::encode(actual_hash), + ), + ) +} + +fn offchain_http_response_error(url: &str, status: StatusCode) -> DrepsInnerMetadataError { + let reason = status.canonical_reason().unwrap_or("Unknown"); + + DrepsInnerMetadataError::new( + Code::HttpResponseError, + format!( + "The server at {url} returned HTTP status {} \"{reason}\".", + status.as_u16(), + ), + ) +} + +fn offchain_connection_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + Code::ConnectionError, + format!("The client cannot connect to {url}."), + ) +} + +fn offchain_decode_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + Code::DecodeError, + format!("The client cannot parse JSON from {url}."), + ) +} + +fn offchain_size_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + Code::SizeExceeded, + format!("The response from {url} is larger than {MAX_ANCHOR_METADATA_BYTES} bytes."), + ) +} + +fn offchain_unknown_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + Code::UnknownError, + format!("The API cannot create an HTTP client for {url}."), + ) +} + +fn offchain_no_gateway_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + Code::UnknownError, + format!("The API cannot resolve {url} without an IPFS gateway."), + ) +} + +async fn read_anchor_body( + mut response: reqwest::Response, + url: &str, +) -> Result, DrepsInnerMetadataError> { + if response + .content_length() + .is_some_and(|size| size > MAX_ANCHOR_METADATA_BYTES as u64) + { + return Err(offchain_size_error(url)); + } + + let capacity = response + .content_length() + .unwrap_or_default() + .min(MAX_ANCHOR_METADATA_BYTES as u64) as usize; + let mut body = Vec::with_capacity(capacity); + + loop { + let chunk = response + .chunk() + .await + .map_err(|_| offchain_connection_error(url))?; + + let Some(chunk) = chunk else { + break; + }; + + if chunk.len() > MAX_ANCHOR_METADATA_BYTES - body.len() { + return Err(offchain_size_error(url)); + } + + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +/// This function resolves a governance-anchor URL to an ordered list of HTTP(S) +/// URLs. +/// +/// An `ipfs://[/]` URL expands to one +/// `/ipfs/[/]` candidate for each gateway, in gateway +/// order. The caller sends a request to each candidate in order until one +/// serves the content. The function also accepts a redundant `ipfs/` after the +/// scheme. +/// +/// The CID keeps its original case. The CID stays in the path, so it does not +/// pass through URL host normalization. That normalization lowercases a base58 +/// CIDv0. +/// +/// Every other URL gives a single candidate, unchanged. An `ipfs://` URL with +/// no gateways gives no candidates. +fn resolve_anchor_urls(url: &str, ipfs_gateways: &[String]) -> Vec { + let Some(rest) = url.strip_prefix("ipfs://") else { + return vec![url.to_string()]; + }; + + let rest = rest.strip_prefix("ipfs/").unwrap_or(rest); + + ipfs_gateways + .iter() + .map(|gateway| format!("{}/ipfs/{rest}", gateway.trim_end_matches('/'))) + .collect() +} + +/// This function fetches metadata from a governance-anchor URL. +/// +/// The function resolves an `ipfs://` URL through `ipfs_gateways`. It sends a +/// request to each gateway in order. It stops at the first gateway that serves +/// content with a hash equal to `expected_hash`. The function fetches every +/// other URL directly. +/// +/// The function returns the JSON body and the raw bytes. If every candidate +/// fails, the function returns a typed error from the last candidate. Every +/// error keeps the original on-chain URL. +pub async fn anchor_offchain_metadata( + url: &str, + expected_hash: &[u8], + ipfs_gateways: &[String], +) -> (Option, Option) { + let candidates = resolve_anchor_urls(url, ipfs_gateways); + + // The candidate list is empty only for an `ipfs://` URL with no gateway. A + // direct HTTP(S) URL always gives one candidate. + if candidates.is_empty() { + return (None, Some(offchain_no_gateway_error(url))); + } + + let client = match anchor_http_client() { + Ok(client) => client, + Err(error) => { + tracing::error!(%error, "cannot create the HTTP client for governance metadata"); + return (None, Some(offchain_unknown_error(url))); + } + }; + + let mut last_error = None; + + for candidate in candidates { + match fetch_anchor_candidate(client, &candidate, url, expected_hash).await { + Ok(metadata) => return (Some(metadata), None), + Err(error) => last_error = Some(error), + } + } + + (None, last_error) +} + +/// This function fetches one resolved candidate URL for +/// [`anchor_offchain_metadata`]. The function makes sure that the body hash +/// matches `expected_hash`. +/// +/// `request_url` is the URL that the function fetches. For an `ipfs://` anchor, +/// `request_url` is a gateway URL. `original_url` is the on-chain URL. Every +/// error keeps `original_url`, so the caller reports what the chain recorded. +async fn fetch_anchor_candidate( + client: &reqwest::Client, + request_url: &str, + original_url: &str, + expected_hash: &[u8], +) -> Result { + let request_url = match reqwest::Url::parse(request_url) { + Ok(url) if is_public_http_url(&url) => url, + _ => return Err(offchain_connection_error(original_url)), + }; + + let response = client + .get(request_url) + .send() + .await + .map_err(|_| offchain_connection_error(original_url))?; + + if response.status() != StatusCode::OK { + return Err(offchain_http_response_error( + original_url, + response.status(), + )); + } + + let body = read_anchor_body(response, original_url).await?; + + let actual_hash = Hasher::<256>::hash(body.as_ref()); + + if actual_hash.as_ref() != expected_hash { + return Err(offchain_hash_mismatch_error( + original_url, + expected_hash, + actual_hash.as_ref(), + )); + } + + serde_json::from_slice(body.as_ref()) + .map(|json| AnchorMetadata { + json, + bytes: format!("\\x{}", hex::encode(body.as_slice())), + }) + .map_err(|_| offchain_decode_error(original_url)) +} + +#[cfg(test)] +mod anchor_tests { + use super::*; + use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpListener}, + thread::{self, JoinHandle}, + }; + + struct FixedDnsResolver(SocketAddr); + + impl reqwest::dns::Resolve for FixedDnsResolver { + fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving { + let address = self.0; + let addresses = Box::new(std::iter::once(address)) as reqwest::dns::Addrs; + + Box::pin(async move { Ok(addresses) }) + } + } + + fn serve_body(body: Vec, content_length: Option) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("Cannot bind the test server."); + let address = listener + .local_addr() + .expect("Cannot read the test server address."); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("Cannot accept the test request."); + let mut request = [0; 1024]; + let _ = stream.read(&mut request); + + let content_length = content_length + .map(|size| format!("Content-Length: {size}\r\n")) + .unwrap_or_default(); + let headers = format!("HTTP/1.1 200 OK\r\n{content_length}Connection: close\r\n\r\n"); + + stream + .write_all(headers.as_bytes()) + .expect("Cannot write the test response headers."); + let _ = stream.write_all(&body); + }); + + (format!("http://{address}/metadata"), server) + } + + async fn get_local_response(url: &str) -> reqwest::Response { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("Cannot create the test HTTP client.") + .get(url) + .send() + .await + .expect("Cannot get the test response.") + } + + fn local_candidate_client(url: &str) -> (reqwest::Client, String) { + let port = reqwest::Url::parse(url) + .expect("Cannot parse the test URL.") + .port() + .expect("The test URL has no port."); + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let client = reqwest::Client::builder() + .no_proxy() + .dns_resolver(Arc::new(FixedDnsResolver(address))) + .build() + .expect("Cannot create the test HTTP client."); + + (client, format!("http://anchor.test:{port}/metadata")) + } + + #[tokio::test] + async fn anchor_fetch_rejects_loopback_before_connection() { + let listener = TcpListener::bind("127.0.0.1:0").expect("Cannot bind the test server."); + listener + .set_nonblocking(true) + .expect("Cannot configure the test server."); + let url = format!( + "http://{}/metadata", + listener + .local_addr() + .expect("Cannot read the test server address.") + ); + + let (metadata, error) = anchor_offchain_metadata(&url, &[0; 32], &[]).await; + + assert!(metadata.is_none()); + assert_eq!( + error.expect("The connection error is absent.").code, + Code::ConnectionError + ); + assert_eq!( + listener + .accept() + .expect_err("The client made a connection.") + .kind(), + io::ErrorKind::WouldBlock + ); + } + + #[tokio::test] + async fn anchor_body_rejects_oversized_content_length() { + let (url, server) = serve_body(Vec::new(), Some(MAX_ANCHOR_METADATA_BYTES + 1)); + let response = get_local_response(&url).await; + + let error = read_anchor_body(response, &url) + .await + .expect_err("The oversized response was accepted."); + + server.join().expect("The test server did not stop."); + assert_eq!(error.code, Code::SizeExceeded); + } + + #[tokio::test] + async fn anchor_body_rejects_oversized_stream() { + let body = vec![b'x'; MAX_ANCHOR_METADATA_BYTES + 1]; + let (url, server) = serve_body(body, None); + let response = get_local_response(&url).await; + + let error = read_anchor_body(response, &url) + .await + .expect_err("The oversized response was accepted."); + + server.join().expect("The test server did not stop."); + assert_eq!(error.code, Code::SizeExceeded); + } + + #[tokio::test] + async fn anchor_body_accepts_size_limit() { + let mut body = Vec::with_capacity(MAX_ANCHOR_METADATA_BYTES); + body.push(b'"'); + body.resize(MAX_ANCHOR_METADATA_BYTES - 1, b'x'); + body.push(b'"'); + let (url, server) = serve_body(body, Some(MAX_ANCHOR_METADATA_BYTES)); + let response = get_local_response(&url).await; + + let body = read_anchor_body(response, &url) + .await + .expect("The response at the size limit was rejected."); + + server.join().expect("The test server did not stop."); + assert_eq!(body.len(), MAX_ANCHOR_METADATA_BYTES); + } + + #[tokio::test] + async fn anchor_candidate_rejects_hash_mismatch() { + let body = br#"{"name":"Dolos"}"#.to_vec(); + let (url, server) = serve_body(body, None); + let (client, request_url) = local_candidate_client(&url); + let original_url = "ipfs://bafy-mismatched"; + + let error = fetch_anchor_candidate(&client, &request_url, original_url, &[0; 32]) + .await + .err() + .expect("The hash mismatch was accepted."); + + server.join().expect("The test server did not stop."); + assert_eq!(error.code, Code::HashMismatch); + assert!(error.message.contains(original_url)); + } + + #[tokio::test] + async fn anchor_candidate_accepts_valid_json_and_hash() { + let body = br#"{"name":"Dolos"}"#.to_vec(); + let expected_hex = hex::encode(body.as_slice()); + let expected_hash = Hasher::<256>::hash(body.as_ref()); + let (url, server) = serve_body(body, None); + let (client, request_url) = local_candidate_client(&url); + + let metadata = fetch_anchor_candidate( + &client, + &request_url, + "ipfs://bafy-valid-json", + expected_hash.as_ref(), + ) + .await + .expect("The valid metadata was rejected."); + + server.join().expect("The test server did not stop."); + assert_eq!(metadata.json["name"], "Dolos"); + assert_eq!(metadata.bytes, format!("\\x{expected_hex}")); + } + + #[tokio::test] + async fn anchor_candidate_rejects_invalid_json() { + let body = b"not JSON".to_vec(); + let expected_hash = Hasher::<256>::hash(body.as_ref()); + let (url, server) = serve_body(body, None); + let (client, request_url) = local_candidate_client(&url); + let original_url = "ipfs://bafy-invalid-json"; + + let error = + fetch_anchor_candidate(&client, &request_url, original_url, expected_hash.as_ref()) + .await + .err() + .expect("The invalid JSON was accepted."); + + server.join().expect("The test server did not stop."); + assert_eq!(error.code, Code::DecodeError); + assert!(error.message.contains(original_url)); + } + + #[test] + fn public_url_predicate_blocks_non_public_targets() { + let public = [ + "https://example.com/metadata", + "http://1.1.1.1/metadata", + "https://[2606:4700:4700::1111]/metadata", + ]; + for url in public { + let url = reqwest::Url::parse(url).expect("Cannot parse the URL."); + assert!(is_public_http_url(&url), "{url} is not public"); + } + + let blocked = [ + "ftp://example.com/metadata", + "https://localhost/metadata", + "https://service.localhost/metadata", + "http://127.0.0.1/metadata", + "http://10.0.0.1/metadata", + "http://169.254.169.254/metadata", + "http://[::1]/metadata", + "http://[::ffff:127.0.0.1]/metadata", + "http://[fd00::1]/metadata", + ]; + for url in blocked { + let url = reqwest::Url::parse(url).expect("Cannot parse the URL."); + assert!(!is_public_http_url(&url), "{url} is public"); + } + } + + #[test] + fn resolve_anchor_urls_expands_ipfs_across_gateways_in_order() { + assert_eq!( + resolve_anchor_urls( + "ipfs://bafkreigmd7xasljkmisbal5pu2xcqolr2fkre4jnlllgqrof4wctadxa7m", + &[ + "https://ipfs.io".to_string(), + "https://gateway.pinata.cloud".to_string(), + ], + ), + vec![ + "https://ipfs.io/ipfs/bafkreigmd7xasljkmisbal5pu2xcqolr2fkre4jnlllgqrof4wctadxa7m" + .to_string(), + "https://gateway.pinata.cloud/ipfs/bafkreigmd7xasljkmisbal5pu2xcqolr2fkre4jnlllgqrof4wctadxa7m" + .to_string(), + ], + ); + } + + #[test] + fn resolve_anchor_urls_keeps_cid_path_and_trims_gateway_slash() { + assert_eq!( + resolve_anchor_urls( + "ipfs://bafyfoo/dir/doc.json", + &["https://gateway.example/".to_string()] + ), + vec!["https://gateway.example/ipfs/bafyfoo/dir/doc.json".to_string()], + ); + } + + #[test] + fn resolve_anchor_urls_tolerates_redundant_ipfs_prefix() { + assert_eq!( + resolve_anchor_urls("ipfs://ipfs/bafyfoo", &["https://ipfs.io".to_string()]), + vec!["https://ipfs.io/ipfs/bafyfoo".to_string()], + ); + } + + #[test] + fn resolve_anchor_urls_preserves_cidv0_case() { + // A base58 CIDv0 is case-sensitive. The CID stays in the path, so it + // does not pass through host normalization. That normalization + // lowercases the CID and corrupts it. + assert_eq!( + resolve_anchor_urls( + "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + &["https://ipfs.io".to_string()], + ), + vec!["https://ipfs.io/ipfs/QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG".to_string()], + ); + } + + #[test] + fn resolve_anchor_urls_passes_http_through_as_single_candidate() { + for url in [ + "https://example.com/metadata.json", + "http://example.com/a?b=c", + ] { + assert_eq!( + resolve_anchor_urls(url, &["https://ipfs.io".to_string()]), + vec![url.to_string()], + ); + } + } + + #[test] + fn resolve_anchor_urls_without_gateways_yields_no_candidates() { + assert!(resolve_anchor_urls("ipfs://bafyfoo", &[]).is_empty()); + } +} + pub trait IntoModel where T: serde::Serialize, diff --git a/crates/minibf/src/routes/governance/mod.rs b/crates/minibf/src/routes/governance/mod.rs index 8ad8027d5..1cc4704e1 100644 --- a/crates/minibf/src/routes/governance/mod.rs +++ b/crates/minibf/src/routes/governance/mod.rs @@ -11,8 +11,11 @@ use axum::{ }; use blockfrost_openapi::models::{ proposal::{self, Proposal}, + proposal_metadata::ProposalMetadata, + proposal_metadata_v2::ProposalMetadataV2, proposal_withdrawals_inner::ProposalWithdrawalsInner, proposals_inner::{GovernanceType, ProposalsInner}, + DrepsInnerMetadataError, }; use dolos_cardano::{ model::{DRepState, FixedNamespace as _, ProposalAction, ProposalState}, @@ -30,7 +33,10 @@ use pallas::{ use crate::{ error::Error, - mapping::{bech32, bech32_gov_action, parse_gov_action_id, stake_cred_to_address, IntoModel}, + mapping::{ + anchor_offchain_metadata, bech32, bech32_gov_action, parse_gov_action_id, + stake_cred_to_address, AnchorMetadata, IntoModel, + }, pagination::{Order, Pagination, PaginationParameters}, Facade, }; @@ -462,6 +468,111 @@ where Ok(Json(page)) } +/// This function builds the common fields for a proposal metadata response. +/// +/// The anchor contains the metadata URL and hash from the proposal procedure. +/// The function returns metadata or an error, but not both. +async fn proposal_metadata_parts( + tx: &Hash<32>, + idx: u32, + anchor: &pallas::ledger::primitives::conway::Anchor, + ipfs_gateways: &[String], +) -> Result< + ( + String, + String, + Option, + Option, + ), + StatusCode, +> { + let id = bech32_gov_action(tx, idx)?; + let hash = hex::encode(anchor.content_hash); + + let (metadata, error) = + anchor_offchain_metadata(&anchor.url, anchor.content_hash.as_ref(), ipfs_gateways).await; + + Ok((id, hash, metadata, error)) +} + +/// This endpoint returns proposal metadata by transaction hash and certificate +/// index. +/// +/// An absent anchor causes a 404 response. +/// A failed metadata fetch causes a 404 response. +/// The `/{gov_action_id}/metadata` endpoint returns the anchor and an error +/// object. +pub async fn proposal_metadata( + Path((tx_hash, cert_index)): Path<(String, u32)>, + State(domain): State>, +) -> Result, Error> +where + Option: From, +{ + let tx: Hash<32> = tx_hash.parse().map_err(|_| StatusCode::BAD_REQUEST)?; + + let state = domain + .read_cardano_entity::(ProposalState::build_entity_key(tx, cert_index))? + .ok_or(StatusCode::NOT_FOUND)?; + + let anchor = state.anchor.as_ref().ok_or(StatusCode::NOT_FOUND)?; + + let gateways = domain.config.ipfs_gateways(); + let (id, hash, metadata, _error) = + proposal_metadata_parts(&tx, cert_index, anchor, &gateways).await?; + + let metadata = metadata.ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(ProposalMetadata { + id, + tx_hash: hex::encode(tx), + cert_index: cert_index.try_into().map_err(|_| StatusCode::BAD_REQUEST)?, + url: anchor.url.clone(), + hash, + json_metadata: Some(metadata.json), + bytes: metadata.bytes, + })) +} + +/// This endpoint returns proposal metadata by CIP-129 governance-action ID. +/// +/// The endpoint returns the anchor for a failed metadata fetch. +/// It returns null metadata fields and an error object. +pub async fn proposal_metadata_by_gov_action( + Path(gov_action_id): Path, + State(domain): State>, +) -> Result, Error> +where + Option: From, +{ + let (tx, idx) = parse_gov_action_id(&gov_action_id).map_err(|_| Error::InvalidGovActionId)?; + + let state = domain + .read_cardano_entity::(ProposalState::build_entity_key(tx, idx))? + .ok_or(StatusCode::NOT_FOUND)?; + + let anchor = state.anchor.as_ref().ok_or(StatusCode::NOT_FOUND)?; + + let gateways = domain.config.ipfs_gateways(); + let (id, hash, metadata, error) = proposal_metadata_parts(&tx, idx, anchor, &gateways).await?; + + let (json_metadata, bytes) = match metadata { + Some(AnchorMetadata { json, bytes }) => (Some(json), Some(bytes)), + None => (None, None), + }; + + Ok(Json(ProposalMetadataV2 { + id, + tx_hash: hex::encode(tx), + cert_index: idx.try_into().map_err(|_| StatusCode::BAD_REQUEST)?, + url: anchor.url.clone(), + hash, + json_metadata, + bytes, + error: error.map(Box::new), + })) +} + /// One page of a proposal's treasury withdrawals, ordered the way the action /// itself lists them. /// @@ -944,6 +1055,75 @@ mod tests { assert_eq!(proposals[1].id, bech32_gov_action(&tx, 1).unwrap()); } + #[tokio::test] + async fn governance_proposal_metadata_returns_404_when_fetch_fails() { + let app = proposal_app(); + let tx = tx_hash_of_block(&app, 0); + + assert_status( + &app, + &format!("/governance/proposals/{tx}/0/metadata"), + StatusCode::NOT_FOUND, + ) + .await; + } + + #[tokio::test] + async fn governance_proposal_metadata_by_gov_action_returns_anchor_error() { + let app = proposal_app(); + let tx = tx_hash_of_block(&app, 0); + let tx_hash: Hash<32> = tx.parse().expect("Cannot parse the transaction hash."); + let id = bech32_gov_action(&tx_hash, 0).expect("Cannot encode the governance action ID."); + + let (status, body) = app + .get_bytes(&format!("/governance/proposals/{id}/metadata")) + .await; + + assert_eq!(status, StatusCode::OK); + + let metadata: ProposalMetadataV2 = + serde_json::from_slice(&body).expect("Cannot parse the proposal metadata."); + + assert_eq!(metadata.id, id); + assert_eq!(metadata.tx_hash, tx); + assert_eq!(metadata.cert_index, 0); + assert_eq!(metadata.url, "https://example.invalid/proposal"); + assert_eq!(metadata.hash, hex::encode([6u8; 32])); + assert!(metadata.json_metadata.is_none()); + assert!(metadata.bytes.is_none()); + assert_eq!( + metadata.error.expect("The fetch error is absent.").code, + blockfrost_openapi::models::dreps_inner_metadata_error::Code::ConnectionError + ); + } + + #[tokio::test] + async fn governance_proposal_metadata_by_gov_action_bad_request() { + let app = proposal_app(); + + // a malformed CIP-129 id is a 400, not a lookup that misses + for id in ["not-bech32", &missing_drep()] { + let path = format!("/governance/proposals/{id}/metadata"); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + } + + #[test] + fn governance_action_id_rejects_bech32m() { + let tx = Hash::<32>::from([0x11u8; 32]); + let payload = [tx.as_slice(), &[0]].concat(); + let hrp = Hrp::parse_unchecked("gov_action"); + + let bech32m = bech32::encode::(hrp, payload.as_slice()) + .expect("Cannot encode the Bech32m governance action ID."); + assert_eq!(parse_gov_action_id(&bech32m), Err(StatusCode::BAD_REQUEST)); + + // The same payload with the Bech32 checksum still parses. + let canonical = bech32::encode::(hrp, payload.as_slice()) + .expect("Cannot encode the Bech32 governance action ID."); + assert_eq!(parse_gov_action_id(&canonical).unwrap(), (tx, 0)); + } + #[tokio::test] async fn governance_proposals_orders_and_paginates() { let app = proposal_app(); diff --git a/crates/testing/src/synthetic.rs b/crates/testing/src/synthetic.rs index 2c7187b88..971956d68 100644 --- a/crates/testing/src/synthetic.rs +++ b/crates/testing/src/synthetic.rs @@ -722,7 +722,9 @@ fn sample_transaction( reward_account: Bytes::from(reward_account.to_vec()), gov_action, anchor: Anchor { - url: "https://dolos.test/proposal".to_string(), + // `example.invalid` cannot resolve (RFC 6761). As a result, + // a fetch of this anchor always returns a connection error. + url: "https://example.invalid/proposal".to_string(), content_hash: Hash::from([6u8; 32]), }, })