diff --git a/crates/minibf/src/lib.rs b/crates/minibf/src/lib.rs index 6a9bf1a43..911847685 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -563,6 +563,10 @@ where "/assets/{subject}/transactions", get(routes::assets::by_subject_transactions::), ) + .route( + "/assets/{subject}/txs", + get(routes::assets::by_subject_txs::), + ) .route( "/metadata/txs/labels/{label}", get(routes::metadata::by_label_json::), diff --git a/crates/minibf/src/routes/assets.rs b/crates/minibf/src/routes/assets.rs index 85482f585..84b4a18e3 100644 --- a/crates/minibf/src/routes/assets.rs +++ b/crates/minibf/src/routes/assets.rs @@ -681,12 +681,19 @@ pub async fn by_subject_transactions( ) -> Result>, Error> where D: Domain + Clone + Send + Sync + 'static, + Option: From, { let pagination = Pagination::try_from(params)?; pagination.enforce_max_scan_limit(domain.config.max_scan_items())?; let subject = hex::decode(&subject).map_err(|_| Error::InvalidAsset)?; + // Blockfrost returns 404 for a valid but unknown asset, same as `/addresses`. + let entity_key = pallas::crypto::hash::Hasher::<256>::hash(subject.as_slice()); + if !domain.cardano_entity_exists::(entity_key.as_slice())? { + return Err(StatusCode::NOT_FOUND.into()); + } + let (start_slot, end_slot) = pagination.start_and_end_slots(&domain).await?; let stream = domain.query().blocks_by_asset_stream( &subject, @@ -729,6 +736,24 @@ where Ok(Json(transactions)) } +/// Alias of `/assets/{subject}/transactions` that returns only the tx hashes. +/// Same scan, same pagination, thinner payload. +pub async fn by_subject_txs( + path: Path, + params: Query, + state: State>, +) -> Result>, Error> +where + D: Domain + Clone + Send + Sync + 'static, + Option: From, +{ + let Json(transactions) = by_subject_transactions(path, params, state).await?; + + let hashes = transactions.into_iter().map(|x| x.tx_hash).collect(); + + Ok(Json(hashes)) +} + fn collect_minted_subjects( block: &[u8], policy: &[u8], @@ -928,6 +953,214 @@ mod tests { assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; } + #[tokio::test] + async fn assets_by_subject_transactions_happy_path() { + let app = TestApp::new(); + let asset = app.vectors().asset_unit.as_str(); + let path = format!("/assets/{asset}/transactions"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse asset transactions"); + assert!(!items.is_empty()); + + // every tx must sit exactly where the synthetic chain placed it + for item in items { + let (block_number, tx_index) = app.vectors().tx_position(&item.tx_hash); + assert_eq!(item.block_height as u64, block_number); + assert_eq!(item.tx_index as usize, tx_index); + } + } + + #[tokio::test] + async fn assets_by_subject_transactions_slot_constrained() { + let app = TestApp::new(); + let asset = app.vectors().asset_unit.as_str(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let path = format!( + "/assets/{asset}/transactions?from={}&to={}", + block.block_number, block.block_number + ); + let (status, bytes) = app.get_bytes(&path).await; + assert_eq!(status, StatusCode::OK); + + let items: Vec = + serde_json::from_slice(&bytes).expect("failed to parse asset transactions"); + assert!(!items.is_empty()); + for item in items { + assert!(block.tx_hashes.contains(&item.tx_hash)); + } + } + + #[tokio::test] + async fn assets_by_subject_transactions_paginated() { + let app = TestApp::new(); + let asset = app.vectors().asset_unit.as_str(); + let path_page_1 = format!("/assets/{asset}/transactions?page=1&count=2"); + let path_page_2 = format!("/assets/{asset}/transactions?page=2&count=2"); + + let (status_1, bytes_1) = app.get_bytes(&path_page_1).await; + let (status_2, bytes_2) = app.get_bytes(&path_page_2).await; + + assert_eq!(status_1, StatusCode::OK); + assert_eq!(status_2, StatusCode::OK); + + let page_1: Vec = + serde_json::from_slice(&bytes_1).expect("failed to parse transactions page 1"); + let page_2: Vec = + serde_json::from_slice(&bytes_2).expect("failed to parse transactions page 2"); + + assert_eq!(page_1.len(), 2); + assert_eq!(page_2.len(), 2); + + let page_1_hashes: std::collections::HashSet<_> = + page_1.into_iter().map(|x| x.tx_hash).collect(); + let page_2_hashes: std::collections::HashSet<_> = + page_2.into_iter().map(|x| x.tx_hash).collect(); + assert!(page_1_hashes.is_disjoint(&page_2_hashes)); + } + + #[tokio::test] + async fn assets_by_subject_transactions_order_asc() { + let app = TestApp::new(); + let asset = app.vectors().asset_unit.as_str(); + let path = format!("/assets/{asset}/transactions?order=asc&count=5"); + let (status, bytes) = app.get_bytes(&path).await; + assert_eq!(status, StatusCode::OK); + + let asc: Vec = + serde_json::from_slice(&bytes).expect("failed to parse transactions asc"); + assert!(!asc.is_empty()); + let asc_pos: Vec<_> = asc.iter().map(|x| (x.block_height, x.tx_index)).collect(); + assert!(asc_pos.windows(2).all(|w| w[0] < w[1])); + } + + #[tokio::test] + async fn assets_by_subject_transactions_order_desc() { + let app = TestApp::new(); + let asset = app.vectors().asset_unit.as_str(); + let path = format!("/assets/{asset}/transactions?order=desc&count=5"); + let (status, bytes) = app.get_bytes(&path).await; + assert_eq!(status, StatusCode::OK); + + let desc: Vec = + serde_json::from_slice(&bytes).expect("failed to parse transactions desc"); + assert!(!desc.is_empty()); + let desc_pos: Vec<_> = desc.iter().map(|x| (x.block_height, x.tx_index)).collect(); + assert!(desc_pos.windows(2).all(|w| w[0] > w[1])); + } + + #[tokio::test] + async fn assets_by_subject_transactions_bad_request() { + let app = TestApp::new(); + let path = format!("/assets/{}/transactions", invalid_asset()); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn assets_by_subject_transactions_not_found() { + let app = TestApp::new(); + let path = format!("/assets/{}/transactions", missing_asset()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn assets_by_subject_transactions_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::IndexStoreError)); + let asset = app.vectors().asset_unit.as_str(); + let path = format!("/assets/{asset}/transactions"); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn assets_by_subject_txs_happy_path() { + let app = TestApp::new(); + let asset = app.vectors().asset_unit.as_str(); + let path = format!("/assets/{asset}/txs"); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + let hashes: Vec = + serde_json::from_slice(&bytes).expect("failed to parse asset txs"); + assert!(!hashes.is_empty()); + + let known: std::collections::HashSet<&String> = app + .vectors() + .blocks + .iter() + .flat_map(|block| block.tx_hashes.iter()) + .collect(); + for hash in &hashes { + assert!(known.contains(hash), "unknown tx hash {hash}"); + } + } + + #[tokio::test] + async fn assets_by_subject_txs_matches_transactions() { + let app = TestApp::new(); + let asset = app.vectors().asset_unit.as_str(); + + for query in [ + "", + "?order=desc", + "?page=2&count=2", + "?order=desc&page=1&count=3", + ] { + let (status, bytes) = app + .get_bytes(&format!("/assets/{asset}/transactions{query}")) + .await; + assert_eq!(status, StatusCode::OK); + let expected: Vec = + serde_json::from_slice::>(&bytes) + .expect("failed to parse asset transactions") + .into_iter() + .map(|x| x.tx_hash) + .collect(); + + assert!(!expected.is_empty(), "no transactions for query {query:?}"); + + let (status, bytes) = app.get_bytes(&format!("/assets/{asset}/txs{query}")).await; + assert_eq!(status, StatusCode::OK); + let actual: Vec = + serde_json::from_slice(&bytes).expect("failed to parse asset txs"); + + assert_eq!(actual, expected, "mismatch for query {query:?}"); + } + } + + #[tokio::test] + async fn assets_by_subject_txs_bad_request() { + let app = TestApp::new(); + let path = format!("/assets/{}/txs", invalid_asset()); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn assets_by_subject_txs_not_found() { + let app = TestApp::new(); + let path = format!("/assets/{}/txs", missing_asset()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn assets_by_subject_txs_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::IndexStoreError)); + let asset = app.vectors().asset_unit.as_str(); + let path = format!("/assets/{asset}/txs"); + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + fn unminted_policy() -> &'static str { "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } diff --git a/docs/content/apis/minibf.mdx b/docs/content/apis/minibf.mdx index 0ee21531a..499a2f109 100644 --- a/docs/content/apis/minibf.mdx +++ b/docs/content/apis/minibf.mdx @@ -109,6 +109,7 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list | `/accounts/{stake_address}/delegations` | Get delegations for a stake address | | `/accounts/{stake_address}/registrations` | Get registrations for a stake address | | `/accounts/{stake_address}/rewards` | Get rewards for a stake address | +| `/accounts/{stake_address}/transactions` | Get transactions for a stake address | | `/accounts/{stake_address}/utxos` | Get UTXOs for a stake address | | `/accounts/{stake_address}/withdrawals` | Get withdrawals for a stake address | | `/addresses/{address}` | Get general information for a specific address | @@ -117,9 +118,11 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list | `/addresses/{address}/txs` | Alias of `/addresses/{address}/transactions` | | `/addresses/{address}/utxos` | Get UTXOs for a specific address | | `/addresses/{address}/utxos/{asset}` | Get UTXOs for a specific address and asset | +| `/assets/policy/{policy_id}` | Get assets minted under a specific policy | | `/assets/{subject}` | Get asset information | | `/assets/{subject}/addresses` | Get addresses holding a specific asset | | `/assets/{subject}/transactions` | Get transactions involving a specific asset | +| `/assets/{subject}/txs` | Alias of `/assets/{subject}/transactions` (hashes only) | | `/blocks/latest` | Get latest block information | | `/blocks/latest/txs` | Get transactions for the latest block | | `/blocks/latest/txs/cbor` | Get transactions with CBOR data for the latest block | @@ -130,9 +133,13 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list | `/blocks/{hash_or_number}/previous` | Get previous block | | `/blocks/{hash_or_number}/txs` | Get transactions for a specific block | | `/blocks/{hash_or_number}/txs/cbor` | Get transactions with CBOR data for a specific block | +| `/epochs/latest` | Get latest epoch information | | `/epochs/latest/parameters` | Get latest epoch parameters | +| `/epochs/{epoch}` | Get information for a specific epoch | | `/epochs/{epoch}/blocks` | Get blocks in a specific epoch | +| `/epochs/{epoch}/next` | Get epochs following a specific epoch | | `/epochs/{epoch}/parameters` | Get epoch parameters | +| `/epochs/{epoch}/previous` | Get epochs preceding a specific epoch | | `/epochs/{epoch}/stakes` | Get epoch stake distribution | | `/epochs/{epoch}/stakes/{pool_id}` | Get epoch stake distribution for a specific pool | | `/genesis` | Get genesis information | @@ -141,11 +148,14 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list | `/metadata/txs/labels/{label}/cbor` | Get CBOR metadata for transactions with a specific label | | `/network` | Get network information | | `/network/eras` | Get network eras information | +| `/pools` | Get list of registered stake pools | | `/pools/extended` | Get extended pool information | +| `/pools/retiring` | Get stake pools retiring in upcoming epochs | | `/pools/{id}` | Get information for a specific pool | | `/pools/{id}/delegators` | Get delegators for a specific pool | | `/pools/{id}/history` | Get history for a specific pool | | `/pools/{id}/metadata` | Get metadata for a specific pool | +| `/pools/{id}/relays` | Get relays for a specific pool | | `/scripts/{script_hash}` | Get script information | | `/scripts/{script_hash}/json` | Get script JSON | | `/scripts/{script_hash}/cbor` | Get script CBOR |