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
8 changes: 8 additions & 0 deletions crates/minibf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ where
.route("/addresses/{address}/txs", get(routes::addresses::txs::<D>))
.route("/blocks/latest", get(routes::blocks::latest::<D>))
.route("/blocks/latest/txs", get(routes::blocks::latest_txs::<D>))
.route(
"/blocks/latest/txs/cbor",
get(routes::blocks::latest_txs_cbor::<D>),
)
.route(
"/blocks/{hash_or_number}",
get(routes::blocks::by_hash_or_number::<D>),
Expand All @@ -381,6 +385,10 @@ where
"/blocks/{hash_or_number}/txs",
get(routes::blocks::by_hash_or_number_txs::<D>),
)
.route(
"/blocks/{hash_or_number}/txs/cbor",
get(routes::blocks::by_hash_or_number_txs_cbor::<D>),
)
.route(
"/blocks/{hash_or_number}/addresses",
get(routes::blocks::by_hash_or_number_addresses::<D>),
Expand Down
20 changes: 20 additions & 0 deletions crates/minibf/src/mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2270,6 +2271,25 @@ impl<'a> IntoModel<Vec<String>> for BlockModelBuilder<'a> {
}
}

impl<'a> IntoModel<Vec<BlockContentTxsCborInner>> for BlockModelBuilder<'a> {
type SortKey = ();

fn into_model(self) -> Result<Vec<BlockContentTxsCborInner>, StatusCode> {
let block = &self.block;

let txs = block
.txs()
.iter()
.map(|tx| BlockContentTxsCborInner {
tx_hash: tx.hash().to_string(),
cbor: hex::encode(tx.encode()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says "no re-encoding takes place", but tx is a &MultiEraTx<'_>, and in the definition of its encode() I see several minicbor::* calls. 🤔

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we're hex::encode’ing all elements even before pagination's .skip(…).take(…) above, but that's probably cheap enough?

@slowbackspace slowbackspace Jul 22, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — "no re-encoding" was imprecise, reworded.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah — deliberately kept for symmetry with the sibling /txs handlers, which also materialize the full list before skip/take. Worst case is a few hundred txs at a few KB each, so the wasted hex is on the order of a megabyte of throwaway allocation per request — micro/milliseconds next to the archive read. If it ever shows up in a profile the fix is to slice the tx list before mapping, but that felt like premature restructuring here.

})
.collect();

Ok(txs)
}
}

impl<'a> IntoModel<Vec<BlockContentAddressesInner>> for BlockModelBuilder<'a> {
type SortKey = ();

Expand Down
219 changes: 218 additions & 1 deletion crates/minibf/src/routes/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -401,6 +403,34 @@ where
))
}

pub async fn by_hash_or_number_txs_cbor<D>(
Path(hash_or_number): Path<String>,
Query(params): Query<PaginationParameters>,
State(domain): State<Facade<D>>,
) -> Result<Json<Vec<BlockContentTxsCborInner>>, 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<BlockContentTxsCborInner> = 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<D>(
Path(hash_or_number): Path<String>,
Query(params): Query<PaginationParameters>,
Expand Down Expand Up @@ -459,6 +489,36 @@ where
))
}

pub async fn latest_txs_cbor<D>(
Query(params): Query<PaginationParameters>,
State(domain): State<Facade<D>>,
) -> Result<Json<Vec<BlockContentTxsCborInner>>, 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<BlockContentTxsCborInner> = 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::*;
Expand Down Expand Up @@ -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<BlockContentTxsCborInner> =
serde_json::from_slice(&bytes).expect("failed to parse txs cbor");

let hashes: Vec<String> = 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<BlockContentTxsCborInner> =
serde_json::from_slice(&bytes).expect("failed to parse desc txs cbor");

let hashes: Vec<String> = 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<BlockContentTxsCborInner> =
serde_json::from_slice(&bytes).expect("failed to parse paginated txs cbor");

let expected: Vec<String> = block.tx_hashes.iter().skip(1).take(1).cloned().collect();
let hashes: Vec<String> = 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();
Expand All @@ -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<BlockContentTxsCborInner> =
serde_json::from_slice(&bytes).expect("failed to parse latest txs cbor");

let hashes: Vec<String> = 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little asymmetry -- blocks_latest lacks the _paginated test

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added blocks_latest_txs_cbor_paginated in 91d079b.

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<BlockContentTxsCborInner> =
serde_json::from_slice(&bytes).expect("failed to parse desc latest txs cbor");

let hashes: Vec<String> = 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<BlockContentTxsCborInner> =
serde_json::from_slice(&bytes).expect("failed to parse paginated latest txs cbor");

let expected: Vec<String> = block.tx_hashes.iter().skip(1).take(1).cloned().collect();
let hashes: Vec<String> = 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;
}
}
2 changes: 2 additions & 0 deletions docs/content/apis/minibf.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading