diff --git a/crates/minibf/src/mapping.rs b/crates/minibf/src/mapping.rs index 13c0cdc4..6ee8e148 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.era()); + + let tx = MultiEraTx::decode_for_era(era, cbor.cbor()) + .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.produces_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..8b334799 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,27 @@ 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?; + + // 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)?; + } + } + + 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 +824,190 @@ 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_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(); + 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; + } }