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
4 changes: 4 additions & 0 deletions crates/minibf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@ where
"/assets/{subject}/transactions",
get(routes::assets::by_subject_transactions::<D>),
)
.route(
"/assets/{subject}/txs",
get(routes::assets::by_subject_txs::<D>),
)
.route(
"/metadata/txs/labels/{label}",
get(routes::metadata::by_label_json::<D>),
Expand Down
233 changes: 233 additions & 0 deletions crates/minibf/src/routes/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,12 +681,19 @@ pub async fn by_subject_transactions<D>(
) -> Result<Json<Vec<AssetTransactionsInner>>, Error>
where
D: Domain + Clone + Send + Sync + 'static,
Option<AssetState>: From<D::Entity>,
{
let pagination = Pagination::try_from(params)?;
pagination.enforce_max_scan_limit(domain.config.max_scan_items())?;

let subject = hex::decode(&subject).map_err(|_| Error::InvalidAsset)?;

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.

🟡 Blockfrost validates the asset length before the lookup. validateAsset (ryo) and validate_asset_name (mimicry, src/asset.rs:32) require 56–120 hex chars and return 400 otherwise. Dolos only runs hex::decode. With the new 404 gate, GET /assets/abcd/transactions now returns 404 where Blockfrost returns 400 'Invalid or malformed asset format.' (verified live against this branch). Add a length check that maps to 400. The same gap exists in by_subject and by_subject_addresses; a shared helper would fix all of them.


// Blockfrost returns 404 for a valid but unknown asset, same as `/addresses`.

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 comment cites the wrong precedent. /addresses/{address}/transactions checks existence lazily, only when the page is empty (addresses.rs:550-556), and the /addresses/{address}/txs alias has no check at all. The matching precedent is /assets/{subject}/addresses in this file. Suggest: name that endpoint, or state the Blockfrost rule without a cross-reference.

let entity_key = pallas::crypto::hash::Hasher::<256>::hash(subject.as_slice());
if !domain.cardano_entity_exists::<AssetState>(entity_key.as_slice())? {

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.

🟡 This is the third inline copy of the decode → Hasher::<256>cardano_entity_exists gate in this file (by_subject line 484, by_subject_addresses line 525). The copies already drift: by_subject maps bad hex to StatusCode::BAD_REQUEST and uses read_cardano_entity. Extract one helper that decodes the subject, validates it, checks existence, and returns the subject bytes. Call it from all three handlers. That also gives the 56–120 length check a single home.

return Err(StatusCode::NOT_FOUND.into());
}

let (start_slot, end_slot) = pagination.start_and_end_slots(&domain).await?;
let stream = domain.query().blocks_by_asset_stream(
&subject,
Expand Down Expand Up @@ -729,6 +736,24 @@ where
Ok(Json(transactions))
}

/// Alias of `/assets/{subject}/transactions` that returns only the tx hashes.
/// Same scan, same pagination, thinner payload.
pub async fn by_subject_txs<D>(
path: Path<String>,
params: Query<PaginationParameters>,
state: State<Facade<D>>,
) -> Result<Json<Vec<String>>, Error>
where
D: Domain + Clone + Send + Sync + 'static,
Option<AssetState>: From<D::Entity>,
{
let Json(transactions) = by_subject_transactions(path, params, state).await?;

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 alias inherits from/to through PaginationParameters, but Blockfrost's /assets/{asset}/txs does not define them. Ryo's handler reads only order/count/page (no getAdditionalParametersFromRequest). Mimicry parses from/to but its assets_asset_txs.sql binds only order, count, page, and asset — the values never reach the query. So both references return the full list for GET /assets/{unit}/txs?from=X&to=Y, while this alias filters by the range. Suggest clearing from/to from the params before delegating.


let hashes = transactions.into_iter().map(|x| x.tx_hash).collect();

Ok(Json(hashes))
}

fn collect_minted_subjects(
block: &[u8],
policy: &[u8],
Expand Down Expand Up @@ -928,6 +953,214 @@ mod tests {
assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await;
}

#[tokio::test]
async fn assets_by_subject_transactions_happy_path() {
let app = TestApp::new();
let asset = app.vectors().asset_unit.as_str();
let path = format!("/assets/{asset}/transactions");
let (status, bytes) = app.get_bytes(&path).await;

assert_eq!(
status,
StatusCode::OK,
"unexpected status {status} with body: {}",
String::from_utf8_lossy(&bytes)
);
let items: Vec<AssetTransactionsInner> =
serde_json::from_slice(&bytes).expect("failed to parse asset transactions");
assert!(!items.is_empty());

// every tx must sit exactly where the synthetic chain placed it
for item in items {
let (block_number, tx_index) = app.vectors().tx_position(&item.tx_hash);
assert_eq!(item.block_height as u64, block_number);
assert_eq!(item.tx_index as usize, tx_index);
}
}

#[tokio::test]
async fn assets_by_subject_transactions_slot_constrained() {
let app = TestApp::new();
let asset = app.vectors().asset_unit.as_str();
let block = app.vectors().blocks.first().expect("missing block vectors");
let path = format!(
"/assets/{asset}/transactions?from={}&to={}",
block.block_number, block.block_number
);
let (status, bytes) = app.get_bytes(&path).await;
assert_eq!(status, StatusCode::OK);

let items: Vec<AssetTransactionsInner> =
serde_json::from_slice(&bytes).expect("failed to parse asset transactions");
assert!(!items.is_empty());
for item in items {
assert!(block.tx_hashes.contains(&item.tx_hash));
}
}

#[tokio::test]
async fn assets_by_subject_transactions_paginated() {
let app = TestApp::new();
let asset = app.vectors().asset_unit.as_str();
let path_page_1 = format!("/assets/{asset}/transactions?page=1&count=2");
let path_page_2 = format!("/assets/{asset}/transactions?page=2&count=2");

let (status_1, bytes_1) = app.get_bytes(&path_page_1).await;
let (status_2, bytes_2) = app.get_bytes(&path_page_2).await;

assert_eq!(status_1, StatusCode::OK);
assert_eq!(status_2, StatusCode::OK);

let page_1: Vec<AssetTransactionsInner> =
serde_json::from_slice(&bytes_1).expect("failed to parse transactions page 1");
let page_2: Vec<AssetTransactionsInner> =
serde_json::from_slice(&bytes_2).expect("failed to parse transactions page 2");

assert_eq!(page_1.len(), 2);
assert_eq!(page_2.len(), 2);

let page_1_hashes: std::collections::HashSet<_> =
page_1.into_iter().map(|x| x.tx_hash).collect();
let page_2_hashes: std::collections::HashSet<_> =
page_2.into_iter().map(|x| x.tx_hash).collect();
assert!(page_1_hashes.is_disjoint(&page_2_hashes));
}

#[tokio::test]
async fn assets_by_subject_transactions_order_asc() {
let app = TestApp::new();
let asset = app.vectors().asset_unit.as_str();
let path = format!("/assets/{asset}/transactions?order=asc&count=5");
let (status, bytes) = app.get_bytes(&path).await;
assert_eq!(status, StatusCode::OK);

let asc: Vec<AssetTransactionsInner> =
serde_json::from_slice(&bytes).expect("failed to parse transactions asc");
assert!(!asc.is_empty());
let asc_pos: Vec<_> = asc.iter().map(|x| (x.block_height, x.tx_index)).collect();
assert!(asc_pos.windows(2).all(|w| w[0] < w[1]));
}

#[tokio::test]
async fn assets_by_subject_transactions_order_desc() {
let app = TestApp::new();
let asset = app.vectors().asset_unit.as_str();
let path = format!("/assets/{asset}/transactions?order=desc&count=5");
let (status, bytes) = app.get_bytes(&path).await;
assert_eq!(status, StatusCode::OK);

let desc: Vec<AssetTransactionsInner> =
serde_json::from_slice(&bytes).expect("failed to parse transactions desc");
assert!(!desc.is_empty());
let desc_pos: Vec<_> = desc.iter().map(|x| (x.block_height, x.tx_index)).collect();
assert!(desc_pos.windows(2).all(|w| w[0] > w[1]));
}

#[tokio::test]
async fn assets_by_subject_transactions_bad_request() {
let app = TestApp::new();
let path = format!("/assets/{}/transactions", invalid_asset());
assert_status(&app, &path, StatusCode::BAD_REQUEST).await;
}

#[tokio::test]
async fn assets_by_subject_transactions_not_found() {
let app = TestApp::new();
let path = format!("/assets/{}/transactions", missing_asset());
assert_status(&app, &path, StatusCode::NOT_FOUND).await;
}

#[tokio::test]
async fn assets_by_subject_transactions_internal_error() {
let app = TestApp::new_with_fault(Some(TestFault::IndexStoreError));
let asset = app.vectors().asset_unit.as_str();
let path = format!("/assets/{asset}/transactions");
assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await;
}

#[tokio::test]
async fn assets_by_subject_txs_happy_path() {
let app = TestApp::new();
let asset = app.vectors().asset_unit.as_str();
let path = format!("/assets/{asset}/txs");
let (status, bytes) = app.get_bytes(&path).await;

assert_eq!(
status,
StatusCode::OK,
"unexpected status {status} with body: {}",
String::from_utf8_lossy(&bytes)
);
let hashes: Vec<String> =
serde_json::from_slice(&bytes).expect("failed to parse asset txs");
assert!(!hashes.is_empty());

let known: std::collections::HashSet<&String> = app
.vectors()
.blocks
.iter()
.flat_map(|block| block.tx_hashes.iter())
.collect();
for hash in &hashes {
assert!(known.contains(hash), "unknown tx hash {hash}");
}
}

#[tokio::test]
async fn assets_by_subject_txs_matches_transactions() {
let app = TestApp::new();
let asset = app.vectors().asset_unit.as_str();

for query in [
"",
"?order=desc",
"?page=2&count=2",
"?order=desc&page=1&count=3",
] {
let (status, bytes) = app
.get_bytes(&format!("/assets/{asset}/transactions{query}"))
.await;
assert_eq!(status, StatusCode::OK);
let expected: Vec<String> =
serde_json::from_slice::<Vec<AssetTransactionsInner>>(&bytes)
.expect("failed to parse asset transactions")
.into_iter()
.map(|x| x.tx_hash)
.collect();

assert!(!expected.is_empty(), "no transactions for query {query:?}");

let (status, bytes) = app.get_bytes(&format!("/assets/{asset}/txs{query}")).await;
assert_eq!(status, StatusCode::OK);
let actual: Vec<String> =
serde_json::from_slice(&bytes).expect("failed to parse asset txs");

assert_eq!(actual, expected, "mismatch for query {query:?}");
}
}

#[tokio::test]
async fn assets_by_subject_txs_bad_request() {
let app = TestApp::new();
let path = format!("/assets/{}/txs", invalid_asset());
assert_status(&app, &path, StatusCode::BAD_REQUEST).await;
}

#[tokio::test]
async fn assets_by_subject_txs_not_found() {
let app = TestApp::new();
let path = format!("/assets/{}/txs", missing_asset());
assert_status(&app, &path, StatusCode::NOT_FOUND).await;
}

#[tokio::test]
async fn assets_by_subject_txs_internal_error() {
let app = TestApp::new_with_fault(Some(TestFault::IndexStoreError));
let asset = app.vectors().asset_unit.as_str();
let path = format!("/assets/{asset}/txs");
assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await;
}

fn unminted_policy() -> &'static str {
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
}
Expand Down
10 changes: 10 additions & 0 deletions docs/content/apis/minibf.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list
| `/accounts/{stake_address}/delegations` | Get delegations for a stake address |
| `/accounts/{stake_address}/registrations` | Get registrations for a stake address |
| `/accounts/{stake_address}/rewards` | Get rewards for a stake address |
| `/accounts/{stake_address}/transactions` | Get transactions for a stake address |
| `/accounts/{stake_address}/utxos` | Get UTXOs for a stake address |
| `/accounts/{stake_address}/withdrawals` | Get withdrawals for a stake address |
| `/addresses/{address}` | Get general information for a specific address |
Expand All @@ -117,9 +118,11 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list
| `/addresses/{address}/txs` | Alias of `/addresses/{address}/transactions` |
| `/addresses/{address}/utxos` | Get UTXOs for a specific address |
| `/addresses/{address}/utxos/{asset}` | Get UTXOs for a specific address and asset |
| `/assets/policy/{policy_id}` | Get assets minted under a specific policy |
| `/assets/{subject}` | Get asset information |
| `/assets/{subject}/addresses` | Get addresses holding a specific asset |
| `/assets/{subject}/transactions` | Get transactions involving a specific asset |
| `/assets/{subject}/txs` | Alias of `/assets/{subject}/transactions` (hashes only) |
| `/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 |
Expand All @@ -130,9 +133,13 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list
| `/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` | Get latest epoch information |
| `/epochs/latest/parameters` | Get latest epoch parameters |
| `/epochs/{epoch}` | Get information for a specific epoch |
| `/epochs/{epoch}/blocks` | Get blocks in a specific epoch |
| `/epochs/{epoch}/next` | Get epochs following a specific epoch |
| `/epochs/{epoch}/parameters` | Get epoch parameters |
| `/epochs/{epoch}/previous` | Get epochs preceding a specific epoch |
| `/epochs/{epoch}/stakes` | Get epoch stake distribution |
| `/epochs/{epoch}/stakes/{pool_id}` | Get epoch stake distribution for a specific pool |
| `/genesis` | Get genesis information |
Expand All @@ -141,11 +148,14 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list
| `/metadata/txs/labels/{label}/cbor` | Get CBOR metadata for transactions with a specific label |
| `/network` | Get network information |
| `/network/eras` | Get network eras information |
| `/pools` | Get list of registered stake pools |
| `/pools/extended` | Get extended pool information |
| `/pools/retiring` | Get stake pools retiring in upcoming epochs |
| `/pools/{id}` | Get information for a specific pool |
| `/pools/{id}/delegators` | Get delegators for a specific pool |
| `/pools/{id}/history` | Get history for a specific pool |
| `/pools/{id}/metadata` | Get metadata for a specific pool |
| `/pools/{id}/relays` | Get relays for a specific pool |
| `/scripts/{script_hash}` | Get script information |
| `/scripts/{script_hash}/json` | Get script JSON |
| `/scripts/{script_hash}/cbor` | Get script CBOR |
Expand Down
Loading