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
92 changes: 77 additions & 15 deletions crates/minibf/src/mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use pallas::{
};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
ops::Deref,
time::Duration,
};
Expand Down Expand Up @@ -1986,6 +1986,7 @@ pub struct BlockModelBuilder<'a> {
previous: Option<MultiEraBlock<'a>>,
next: Option<MultiEraBlock<'a>>,
tip: Option<MultiEraBlock<'a>>,
deps: HashMap<TxHash, MultiEraTx<'a>>,
}

impl<'a> BlockModelBuilder<'a> {
Expand All @@ -1998,9 +1999,33 @@ impl<'a> BlockModelBuilder<'a> {
next: None,
tip: None,
chain: None,
deps: HashMap::new(),
})
}

pub fn required_input_deps(&self) -> Vec<TxHash> {
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),
Expand Down Expand Up @@ -2311,21 +2336,58 @@ impl<'a> IntoModel<Vec<BlockContentAddressesInner>> for BlockModelBuilder<'a> {

fn into_model(self) -> Result<Vec<BlockContentAddressesInner>, 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::<Vec<_>>()

// 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<String, Vec<String>> = 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)
Expand Down
216 changes: 207 additions & 9 deletions crates/minibf/src/routes/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -435,24 +436,35 @@ pub async fn by_hash_or_number_addresses<D>(
Path(hash_or_number): Path<String>,
Query(params): Query<PaginationParameters>,
State(domain): State<Facade<D>>,
) -> Result<Json<Vec<String>>, Error>
) -> Result<Json<Vec<BlockContentAddressesInner>>, 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 mut builder = BlockModelBuilder::new(&block)?;

let txs: Vec<String> = 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?;

Comment thread
slowbackspace marked this conversation as resolved.
// 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<BlockContentAddressesInner> = 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(),
Expand Down Expand Up @@ -812,4 +824,190 @@ mod tests {
)
.await;
}

async fn get_addresses(app: &TestApp, path: &str) -> Vec<BlockContentAddressesInner> {
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<BlockContentAddressesInner> =
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;
}
}
Loading