From c0ec083aec1eee6373d8a4fcf4a5d02b63003f47 Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Wed, 22 Jul 2026 16:28:53 +0200 Subject: [PATCH 1/4] fix(minibf): return affected addresses from block addresses endpoint The /blocks/{hash_or_number}/addresses endpoint returned a plain list of tx hashes instead of Blockfrost's address entries. Rebuild it to return addresses affected by the block's transactions, resolving spent inputs through the archive to include input-side addresses. Co-Authored-By: Claude Fable 5 --- crates/minibf/src/mapping.rs | 92 ++++++++++++++++---- crates/minibf/src/routes/blocks.rs | 135 +++++++++++++++++++++++++++-- 2 files changed, 203 insertions(+), 24 deletions(-) diff --git a/crates/minibf/src/mapping.rs b/crates/minibf/src/mapping.rs index 13c0cdc4..b9d43955 100644 --- a/crates/minibf/src/mapping.rs +++ b/crates/minibf/src/mapping.rs @@ -22,7 +22,7 @@ use pallas::{ }; use serde::{Deserialize, Serialize}; use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, ops::Deref, time::Duration, }; @@ -1986,6 +1986,7 @@ pub struct BlockModelBuilder<'a> { previous: Option>, next: Option>, tip: Option>, + deps: HashMap>, } impl<'a> BlockModelBuilder<'a> { @@ -1998,9 +1999,33 @@ impl<'a> BlockModelBuilder<'a> { next: None, tip: None, chain: None, + deps: HashMap::new(), }) } + pub fn required_input_deps(&self) -> Vec { + let mut seen_tx_hashes = HashSet::new(); + + self.block + .txs() + .iter() + .flat_map(|tx| tx.consumes()) + .map(|input| *input.hash()) + .filter(|hash| seen_tx_hashes.insert(*hash)) + .collect() + } + + pub fn load_dep(&mut self, key: TxHash, cbor: &'a EraCbor) -> Result<(), StatusCode> { + let era = try_into_or_500!(cbor.0); + + let tx = MultiEraTx::decode_for_era(era, &cbor.1) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + self.deps.insert(key, tx); + + Ok(()) + } + pub fn with_chain(self, chain: &'a ChainSummary) -> Self { Self { chain: Some(chain), @@ -2311,21 +2336,58 @@ impl<'a> IntoModel> for BlockModelBuilder<'a> { fn into_model(self) -> Result, StatusCode> { let block = &self.block; - let addresses = block - .txs() - .iter() - .flat_map(|tx| { - tx.produces() - .iter() - .map(|(_, output)| BlockContentAddressesInner { - address: output.address().unwrap().to_string(), - transactions: vec![BlockContentAddressesInnerTransactionsInner { - tx_hash: tx.hash().to_string(), - }], - }) - .collect::>() + + // BTreeMap keeps entries sorted alphabetically by address, matching + // Blockfrost. Tx hashes are appended in block order and deduped per + // address by collecting each tx's touched addresses into a set first. + let mut by_address: BTreeMap> = BTreeMap::new(); + + // Phase-2-failed txs are deliberately NOT skipped here, diverging + // from Blockfrost: their collateral inputs and collateral-return + // outputs move funds, so those addresses are affected in ledger + // terms - considered a bug in BF, not behavior worth reproducing + for tx in block.txs() { + let mut touched = BTreeSet::new(); + + for (_, output) in tx.produces() { + let address = output + .address() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + touched.insert(address.to_string()); + } + + for input in tx.consumes() { + let as_output = self + .deps + .get(input.hash()) + .and_then(|dep| dep.output_at(input.index() as usize)); + + if let Some(output) = as_output { + let address = output + .address() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + touched.insert(address.to_string()); + } + } + + let tx_hash = tx.hash().to_string(); + + for address in touched { + by_address.entry(address).or_default().push(tx_hash.clone()); + } + } + + let addresses = by_address + .into_iter() + .map(|(address, tx_hashes)| BlockContentAddressesInner { + address, + transactions: tx_hashes + .into_iter() + .map(|tx_hash| BlockContentAddressesInnerTransactionsInner { tx_hash }) + .collect(), }) - .sorted_by(|x, y| x.address.cmp(&y.address)) .collect(); Ok(addresses) diff --git a/crates/minibf/src/routes/blocks.rs b/crates/minibf/src/routes/blocks.rs index eef5c1d0..1cf74c3b 100644 --- a/crates/minibf/src/routes/blocks.rs +++ b/crates/minibf/src/routes/blocks.rs @@ -4,7 +4,8 @@ use axum::{ Json, }; use blockfrost_openapi::models::{ - block_content::BlockContent, block_content_txs_cbor_inner::BlockContentTxsCborInner, + block_content::BlockContent, block_content_addresses_inner::BlockContentAddressesInner, + block_content_txs_cbor_inner::BlockContentTxsCborInner, }; use dolos_cardano::ChainSummary; use dolos_core::{archive::Skippable as _, ArchiveStore as _, BlockBody, Domain}; @@ -435,7 +436,7 @@ pub async fn by_hash_or_number_addresses( Path(hash_or_number): Path, Query(params): Query, State(domain): State>, -) -> Result>, Error> +) -> Result>, Error> where D: Domain + Clone + Send + Sync + 'static, { @@ -443,16 +444,24 @@ where 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 mut builder = BlockModelBuilder::new(&block)?; - let txs: Vec = model.into_model()?; - let txs = match pagination.order { - Order::Asc => txs, - Order::Desc => txs.into_iter().rev().collect(), - }; + let deps = builder.required_input_deps(); + let deps = domain.get_tx_batch(deps).await?; + + for (key, cbor) in deps.iter() { + if let Some(cbor) = cbor { + builder.load_dep(*key, cbor)?; + } + } + + let addresses: Vec = builder.into_model()?; + // Blockfrost sorts this endpoint alphabetically by address and ignores + // the `order` param; only count/page apply. Ok(Json( - txs.into_iter() + addresses + .into_iter() .skip(pagination.skip()) .take(pagination.count) .collect(), @@ -812,4 +821,112 @@ mod tests { ) .await; } + + async fn get_addresses(app: &TestApp, path: &str) -> Vec { + let (status, bytes) = app.get_bytes(path).await; + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + serde_json::from_slice(&bytes).expect("failed to parse block addresses") + } + + #[tokio::test] + async fn blocks_by_hash_or_number_addresses_happy_path() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + let address = app.vectors().address.clone(); + + let path = format!("/blocks/{}/addresses", block.block_hash); + let addresses = get_addresses(&app, &path).await; + + // entries are sorted alphabetically by address + let sorted: Vec<_> = addresses.iter().map(|a| a.address.clone()).collect(); + let mut expected = sorted.clone(); + expected.sort(); + assert_eq!(sorted, expected, "addresses must be sorted alphabetically"); + + // the first tx of each fixture block pays the fixture address; its + // entry must reference that tx exactly once even though the address + // can appear in multiple outputs of it (dedup per address per tx) + let entry = addresses + .iter() + .find(|a| a.address == address) + .expect("fixture address missing from block addresses"); + + let tx_hashes: Vec<_> = entry + .transactions + .iter() + .map(|t| t.tx_hash.clone()) + .collect(); + assert_eq!(tx_hashes, vec![block.tx_hashes[0].clone()]); + + // every tx must contribute at least its own output address + assert!(addresses.len() >= block.tx_hashes.len()); + } + + #[tokio::test] + async fn blocks_by_hash_or_number_addresses_ignores_order_param() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + + let asc = get_addresses( + &app, + &format!("/blocks/{}/addresses?order=asc", block.block_hash), + ) + .await; + let desc = get_addresses( + &app, + &format!("/blocks/{}/addresses?order=desc", block.block_hash), + ) + .await; + + // Blockfrost sorts alphabetically regardless of the order param + assert_eq!(asc, desc); + } + + #[tokio::test] + async fn blocks_by_hash_or_number_addresses_paginated() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + + let all = get_addresses(&app, &format!("/blocks/{}/addresses", block.block_hash)).await; + assert!(all.len() > 1, "fixture must produce multiple addresses"); + + let page = get_addresses( + &app, + &format!("/blocks/{}/addresses?count=1&page=2", block.block_hash), + ) + .await; + + assert_eq!(page, vec![all[1].clone()]); + } + + #[tokio::test] + async fn blocks_by_hash_or_number_addresses_bad_request() { + let app = TestApp::new(); + let path = format!("/blocks/{}/addresses", invalid_block()); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn blocks_by_hash_or_number_addresses_not_found() { + let app = TestApp::new(); + let path = format!("/blocks/{}/addresses", missing_block()); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn blocks_by_hash_or_number_addresses_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::ArchiveStoreError)); + assert_status( + &app, + "/blocks/1/addresses", + StatusCode::INTERNAL_SERVER_ERROR, + ) + .await; + } } From 337c89687e373ea46d6ccfb8c058bf20c774c602 Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Thu, 23 Jul 2026 11:49:54 +0200 Subject: [PATCH 2/4] docs(minibf): note graceful degradation on missing input deps Co-Authored-By: Claude Fable 5 --- crates/minibf/src/routes/blocks.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/minibf/src/routes/blocks.rs b/crates/minibf/src/routes/blocks.rs index 1cf74c3b..df6edcab 100644 --- a/crates/minibf/src/routes/blocks.rs +++ b/crates/minibf/src/routes/blocks.rs @@ -449,6 +449,9 @@ where let deps = builder.required_input_deps(); let deps = domain.get_tx_batch(deps).await?; + // deps missing from the archive (possible on nodes without full history) + // are skipped, omitting their addresses from the response — the same + // graceful degradation as /txs/{hash}/utxos for (key, cbor) in deps.iter() { if let Some(cbor) = cbor { builder.load_dep(*key, cbor)?; From 2c9a4014c4974d79be5a7d825f4f5e12fb47f5ce Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Thu, 23 Jul 2026 11:55:28 +0200 Subject: [PATCH 3/4] refactor(minibf): use EraCbor accessors in BlockModelBuilder::load_dep Co-Authored-By: Claude Fable 5 --- crates/minibf/src/mapping.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/minibf/src/mapping.rs b/crates/minibf/src/mapping.rs index b9d43955..98451e9c 100644 --- a/crates/minibf/src/mapping.rs +++ b/crates/minibf/src/mapping.rs @@ -2016,9 +2016,9 @@ impl<'a> BlockModelBuilder<'a> { } pub fn load_dep(&mut self, key: TxHash, cbor: &'a EraCbor) -> Result<(), StatusCode> { - let era = try_into_or_500!(cbor.0); + let era = try_into_or_500!(cbor.era()); - let tx = MultiEraTx::decode_for_era(era, &cbor.1) + let tx = MultiEraTx::decode_for_era(era, cbor.cbor()) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; self.deps.insert(key, tx); From 263de740c9796c98133522afe9272a7cea32b8b5 Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Thu, 23 Jul 2026 12:18:19 +0200 Subject: [PATCH 4/4] fix(minibf): resolve spent collateral returns via produces_at output_at indexes regular outputs only, so an input spending a failed tx's collateral return (produced at index |outputs|) would not resolve and its address would be omitted. produces_at is validity-aware and matches the existing lookup paths in the addresses/assets routes. Co-Authored-By: Claude Fable 5 --- crates/minibf/src/mapping.rs | 2 +- crates/minibf/src/routes/blocks.rs | 78 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/minibf/src/mapping.rs b/crates/minibf/src/mapping.rs index 98451e9c..6ee8e148 100644 --- a/crates/minibf/src/mapping.rs +++ b/crates/minibf/src/mapping.rs @@ -2361,7 +2361,7 @@ impl<'a> IntoModel> for BlockModelBuilder<'a> { let as_output = self .deps .get(input.hash()) - .and_then(|dep| dep.output_at(input.index() as usize)); + .and_then(|dep| dep.produces_at(input.index() as usize)); if let Some(output) = as_output { let address = output diff --git a/crates/minibf/src/routes/blocks.rs b/crates/minibf/src/routes/blocks.rs index df6edcab..8b334799 100644 --- a/crates/minibf/src/routes/blocks.rs +++ b/crates/minibf/src/routes/blocks.rs @@ -908,6 +908,84 @@ mod tests { assert_eq!(page, vec![all[1].clone()]); } + #[tokio::test] + async fn blocks_by_hash_or_number_addresses_past_the_end_page() { + let app = TestApp::new(); + let block = app.vectors().blocks.first().expect("missing block vectors"); + + let path = format!("/blocks/{}/addresses?count=100&page=99", block.block_hash); + let addresses = get_addresses(&app, &path).await; + + assert!( + addresses.is_empty(), + "past-the-end page must be an empty 200" + ); + } + + /// Exercises the `produces_at` edge for input-side address resolution. + /// Invalid script txs don't produce their regular outputs, but they can + /// produce a collateral return at the next output index. When a later tx + /// spends that collateral return, block-address mapping must resolve the + /// collateral-return address from the dependency tx CBOR. + #[test] + fn block_addresses_resolves_spent_collateral_return_address() { + use dolos_core::EraCbor; + use dolos_testing::synthetic::{build_synthetic_blocks, SyntheticBlockConfig}; + use pallas::ledger::traverse::{Era, MultiEraBlock, MultiEraTx}; + + // Invalid Conway tx with no regular outputs and one collateral-return + // output at index 0. + let dep_tx_cbor = hex::decode( + "84a600d9010281825820010101010101010101010101010101010101010101010101010101010101010100018002070dd901028182582002020202020202020202020202020202020202020202020202020202020202020010a200581d607393621349f3555f70c975392c84879ba400d08d14ab3d572d7ece80011a0016e360111a0007a120a0f4f6", + ) + .expect("invalid dep tx fixture hex"); + let collateral_return_index = 0usize; + + let dep_tx = + MultiEraTx::decode_for_era(Era::Conway, &dep_tx_cbor).expect("failed to decode dep tx"); + assert!( + dep_tx.output_at(collateral_return_index).is_none(), + "regular outputs do not include collateral return index" + ); + let collateral_return_address = dep_tx + .produces_at(collateral_return_index) + .expect("collateral return must be produced at index 0") + .address() + .expect("collateral return must have an address") + .to_string(); + + let (blocks, _, _) = build_synthetic_blocks(SyntheticBlockConfig::default()); + let raw = blocks.first().expect("missing synthetic block"); + + let block = MultiEraBlock::decode(raw).expect("failed to decode block"); + let txs = block.txs(); + let spender = txs.get(1).expect("fixture needs a second tx"); + let consumed = spender.consumes(); + let input = consumed.first().expect("spender must consume"); + assert_eq!(input.index() as usize, collateral_return_index); + + let mut builder = BlockModelBuilder::new(raw).expect("failed to build block model"); + let dep_cbor = EraCbor(Era::Conway.into(), dep_tx_cbor); + builder + .load_dep(*input.hash(), &dep_cbor) + .expect("failed to load dep"); + let addresses: Vec = + builder.into_model().expect("failed to map block addresses"); + + let entry = addresses + .iter() + .find(|entry| entry.address == collateral_return_address) + .expect("collateral-return address missing from response"); + + assert!( + entry + .transactions + .iter() + .any(|tx| tx.tx_hash == spender.hash().to_string()), + "spender tx must be attributed to the consumed collateral-return address" + ); + } + #[tokio::test] async fn blocks_by_hash_or_number_addresses_bad_request() { let app = TestApp::new();