-
Notifications
You must be signed in to change notification settings - Fork 59
feat(minibf): add /assets/{subject}/txs endpoint
#1220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)?; | ||
|
|
||
| // Blockfrost returns 404 for a valid but unknown asset, same as `/addresses`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 The comment cites the wrong precedent. |
||
| let entity_key = pallas::crypto::hash::Hasher::<256>::hash(subject.as_slice()); | ||
| if !domain.cardano_entity_exists::<AssetState>(entity_key.as_slice())? { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 This is the third inline copy of the decode → |
||
| 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, | ||
|
|
@@ -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?; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The alias inherits |
||
|
|
||
| let hashes = transactions.into_iter().map(|x| x.tx_hash).collect(); | ||
|
|
||
| Ok(Json(hashes)) | ||
| } | ||
|
|
||
| fn collect_minted_subjects( | ||
| block: &[u8], | ||
| policy: &[u8], | ||
|
|
@@ -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" | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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) andvalidate_asset_name(mimicry,src/asset.rs:32) require 56–120 hex chars and return 400 otherwise. Dolos only runshex::decode. With the new 404 gate,GET /assets/abcd/transactionsnow 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 inby_subjectandby_subject_addresses; a shared helper would fix all of them.