diff --git a/crates/minibf/src/lib.rs b/crates/minibf/src/lib.rs index f67cc703..0c4fdcc8 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -365,6 +365,10 @@ where .route("/addresses/{address}/txs", get(routes::addresses::txs::)) .route("/blocks/latest", get(routes::blocks::latest::)) .route("/blocks/latest/txs", get(routes::blocks::latest_txs::)) + .route( + "/blocks/latest/txs/cbor", + get(routes::blocks::latest_txs_cbor::), + ) .route( "/blocks/{hash_or_number}", get(routes::blocks::by_hash_or_number::), @@ -381,6 +385,10 @@ where "/blocks/{hash_or_number}/txs", get(routes::blocks::by_hash_or_number_txs::), ) + .route( + "/blocks/{hash_or_number}/txs/cbor", + get(routes::blocks::by_hash_or_number_txs_cbor::), + ) .route( "/blocks/{hash_or_number}/addresses", get(routes::blocks::by_hash_or_number_addresses::), diff --git a/crates/minibf/src/mapping.rs b/crates/minibf/src/mapping.rs index af754fe4..068f5210 100644 --- a/crates/minibf/src/mapping.rs +++ b/crates/minibf/src/mapping.rs @@ -33,6 +33,7 @@ use blockfrost_openapi::models::{ block_content::BlockContent, block_content_addresses_inner::BlockContentAddressesInner, block_content_addresses_inner_transactions_inner::BlockContentAddressesInnerTransactionsInner, + block_content_txs_cbor_inner::BlockContentTxsCborInner, tx_content::TxContent, tx_content_cbor::TxContentCbor, tx_content_delegations_inner::TxContentDelegationsInner, @@ -2270,6 +2271,25 @@ impl<'a> IntoModel> for BlockModelBuilder<'a> { } } +impl<'a> IntoModel> for BlockModelBuilder<'a> { + type SortKey = (); + + fn into_model(self) -> Result, StatusCode> { + let block = &self.block; + + let txs = block + .txs() + .iter() + .map(|tx| BlockContentTxsCborInner { + tx_hash: tx.hash().to_string(), + cbor: hex::encode(tx.encode()), + }) + .collect(); + + Ok(txs) + } +} + impl<'a> IntoModel> for BlockModelBuilder<'a> { type SortKey = (); diff --git a/crates/minibf/src/routes/blocks.rs b/crates/minibf/src/routes/blocks.rs index 8fe55011..eef5c1d0 100644 --- a/crates/minibf/src/routes/blocks.rs +++ b/crates/minibf/src/routes/blocks.rs @@ -3,7 +3,9 @@ use axum::{ http::StatusCode, Json, }; -use blockfrost_openapi::models::block_content::BlockContent; +use blockfrost_openapi::models::{ + block_content::BlockContent, block_content_txs_cbor_inner::BlockContentTxsCborInner, +}; use dolos_cardano::ChainSummary; use dolos_core::{archive::Skippable as _, ArchiveStore as _, BlockBody, Domain}; use futures::future::try_join_all; @@ -401,6 +403,34 @@ where )) } +pub async fn by_hash_or_number_txs_cbor( + Path(hash_or_number): Path, + Query(params): Query, + State(domain): State>, +) -> Result>, Error> +where + D: Domain + Clone + Send + Sync + 'static, +{ + let pagination = Pagination::try_from(params)?; + let hash_or_number = parse_hash_or_number(&hash_or_number)?; + let block = load_block_by_hash_or_number(&domain, &hash_or_number).await?; + + let model = BlockModelBuilder::new(&block)?; + + let txs: Vec = model.into_model()?; + let txs = match pagination.order { + Order::Asc => txs, + Order::Desc => txs.into_iter().rev().collect(), + }; + + Ok(Json( + txs.into_iter() + .skip(pagination.skip()) + .take(pagination.count) + .collect(), + )) +} + pub async fn by_hash_or_number_addresses( Path(hash_or_number): Path, Query(params): Query, @@ -459,6 +489,36 @@ where )) } +pub async fn latest_txs_cbor( + Query(params): Query, + State(domain): State>, +) -> Result>, Error> +where + D: Domain + Clone + Send + Sync + 'static, +{ + let pagination = Pagination::try_from(params)?; + let (_, tip) = domain + .archive() + .get_tip() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + + let model = BlockModelBuilder::new(&tip)?; + + let txs: Vec = model.into_model()?; + let txs = match pagination.order { + Order::Asc => txs, + Order::Desc => txs.into_iter().rev().collect(), + }; + + Ok(Json( + txs.into_iter() + .skip(pagination.skip()) + .take(pagination.count) + .collect(), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -570,6 +630,92 @@ mod tests { assert_eq!(txs, reversed); } + #[tokio::test] + async fn blocks_by_hash_or_number_txs_cbor_happy_path() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let path = format!("/blocks/{}/txs/cbor", block.block_hash); + let (status, bytes) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let txs: Vec = + serde_json::from_slice(&bytes).expect("failed to parse txs cbor"); + + let hashes: Vec = txs.iter().map(|tx| tx.tx_hash.clone()).collect(); + assert_eq!(hashes, block.tx_hashes); + + for tx in txs { + let cbor = hex::decode(&tx.cbor).expect("cbor is not valid hex"); + let decoded = pallas::ledger::traverse::MultiEraTx::decode(&cbor) + .expect("cbor is not a decodable tx"); + assert_eq!(decoded.hash().to_string(), tx.tx_hash); + } + } + + #[tokio::test] + async fn blocks_by_hash_or_number_txs_cbor_order_desc() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let path = format!("/blocks/{}/txs/cbor?order=desc", block.block_hash); + let (status, bytes) = app.get_bytes(&path).await; + assert_eq!(status, StatusCode::OK); + + let txs: Vec = + serde_json::from_slice(&bytes).expect("failed to parse desc txs cbor"); + + let hashes: Vec = txs.iter().map(|tx| tx.tx_hash.clone()).collect(); + let mut reversed = block.tx_hashes.clone(); + reversed.reverse(); + assert_eq!(hashes, reversed); + } + + #[tokio::test] + async fn blocks_by_hash_or_number_txs_cbor_paginated() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let path = format!("/blocks/{}/txs/cbor?count=1&page=2", block.block_hash); + let (status, bytes) = app.get_bytes(&path).await; + assert_eq!(status, StatusCode::OK); + + let txs: Vec = + serde_json::from_slice(&bytes).expect("failed to parse paginated txs cbor"); + + let expected: Vec = block.tx_hashes.iter().skip(1).take(1).cloned().collect(); + let hashes: Vec = txs.iter().map(|tx| tx.tx_hash.clone()).collect(); + assert_eq!(hashes, expected); + } + + #[tokio::test] + async fn blocks_by_hash_or_number_txs_cbor_bad_request() { + let app = TestApp::new(); + let path = format!("/blocks/{}/txs/cbor", invalid_block()); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn blocks_by_hash_or_number_txs_cbor_not_found() { + let app = TestApp::new(); + let path = format!("/blocks/{}/txs/cbor", missing_block()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn blocks_by_hash_or_number_txs_cbor_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + assert_status( + &app, + "/blocks/1/txs/cbor", + StatusCode::INTERNAL_SERVER_ERROR, + ) + .await; + } + #[tokio::test] async fn blocks_latest_txs_order_asc() { let app = TestApp::new(); @@ -595,4 +741,75 @@ mod tests { reversed.reverse(); assert_eq!(txs, reversed); } + + #[tokio::test] + async fn blocks_latest_txs_cbor_happy_path() { + let app = TestApp::new(); + let block = app.vectors().blocks.last().expect("missing block vectors"); + let (status, bytes) = app.get_bytes("/blocks/latest/txs/cbor").await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let txs: Vec = + serde_json::from_slice(&bytes).expect("failed to parse latest txs cbor"); + + let hashes: Vec = txs.iter().map(|tx| tx.tx_hash.clone()).collect(); + assert_eq!(hashes, block.tx_hashes); + + for tx in txs { + let cbor = hex::decode(&tx.cbor).expect("cbor is not valid hex"); + let decoded = pallas::ledger::traverse::MultiEraTx::decode(&cbor) + .expect("cbor is not a decodable tx"); + assert_eq!(decoded.hash().to_string(), tx.tx_hash); + } + } + + #[tokio::test] + async fn blocks_latest_txs_cbor_order_desc() { + let app = TestApp::new(); + let block = app.vectors().blocks.last().expect("missing block vectors"); + let (status, bytes) = app.get_bytes("/blocks/latest/txs/cbor?order=desc").await; + assert_eq!(status, StatusCode::OK); + + let txs: Vec = + serde_json::from_slice(&bytes).expect("failed to parse desc latest txs cbor"); + + let hashes: Vec = txs.iter().map(|tx| tx.tx_hash.clone()).collect(); + let mut reversed = block.tx_hashes.clone(); + reversed.reverse(); + assert_eq!(hashes, reversed); + } + + #[tokio::test] + async fn blocks_latest_txs_cbor_paginated() { + let app = TestApp::new(); + let block = app.vectors().blocks.last().expect("missing block vectors"); + let (status, bytes) = app + .get_bytes("/blocks/latest/txs/cbor?count=1&page=2") + .await; + assert_eq!(status, StatusCode::OK); + + let txs: Vec = + serde_json::from_slice(&bytes).expect("failed to parse paginated latest txs cbor"); + + let expected: Vec = block.tx_hashes.iter().skip(1).take(1).cloned().collect(); + let hashes: Vec = txs.iter().map(|tx| tx.tx_hash.clone()).collect(); + assert_eq!(hashes, expected); + } + + #[tokio::test] + async fn blocks_latest_txs_cbor_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + assert_status( + &app, + "/blocks/latest/txs/cbor", + StatusCode::INTERNAL_SERVER_ERROR, + ) + .await; + } } diff --git a/docs/content/apis/minibf.mdx b/docs/content/apis/minibf.mdx index 2fbdfa4e..d81e9ee5 100644 --- a/docs/content/apis/minibf.mdx +++ b/docs/content/apis/minibf.mdx @@ -120,12 +120,14 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list | `/assets/{subject}/transactions` | Get transactions involving a specific asset | | `/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 | | `/blocks/slot/{slot_number}` | Get block by slot number | | `/blocks/{hash_or_number}` | Get block information | | `/blocks/{hash_or_number}/addresses` | Get addresses in a specific block | | `/blocks/{hash_or_number}/next` | Get next block | | `/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/parameters` | Get latest epoch parameters | | `/epochs/{epoch}/blocks` | Get blocks in a specific epoch | | `/epochs/{epoch}/parameters` | Get epoch parameters |