Skip to content

Commit 074bd92

Browse files
authored
Merge pull request #373 from input-output-hk/whankinsiv/address-asset-utxos-endpoint
feat: address UTxOs of a specific asset endpoint
2 parents 5f1092c + 7a04fad commit 074bd92

File tree

3 files changed

+186
-83
lines changed

3 files changed

+186
-83
lines changed

modules/rest_blockfrost/src/handlers/addresses.rs

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::sync::Arc;
22

33
use crate::types::{AddressInfoExtended, AddressTotalsREST, TransactionInfoREST, UTxOREST};
4+
use crate::utils::split_policy_and_asset;
45
use crate::{handlers_config::HandlersConfig, types::AddressInfoREST};
56
use acropolis_common::queries::assets::{AssetsStateQuery, AssetsStateQueryResponse};
67
use acropolis_common::queries::blocks::{BlocksStateQuery, BlocksStateQueryResponse};
@@ -379,11 +380,116 @@ pub async fn handle_address_utxos_blockfrost(
379380

380381
/// Handle `/addresses/{address}/utxos/{asset}` Blockfrost-compatible endpoint
381382
pub async fn handle_address_asset_utxos_blockfrost(
382-
_context: Arc<Context<Message>>,
383-
_params: Vec<String>,
384-
_handlers_config: Arc<HandlersConfig>,
383+
context: Arc<Context<Message>>,
384+
params: Vec<String>,
385+
handlers_config: Arc<HandlersConfig>,
385386
) -> Result<RESTResponse, RESTError> {
386-
Err(RESTError::not_implemented("Address asset UTxOs endpoint"))
387+
let address = parse_address(&params)?;
388+
let address_str = address.to_string()?;
389+
let (target_policy, target_name) = split_policy_and_asset(&params[1])?;
390+
391+
// Get utxos from address state
392+
let msg = Arc::new(Message::StateQuery(StateQuery::Addresses(
393+
AddressStateQuery::GetAddressUTxOs { address },
394+
)));
395+
let utxo_identifiers = query_state(
396+
&context,
397+
&handlers_config.addresses_query_topic,
398+
msg,
399+
|message| match message {
400+
Message::StateQueryResponse(StateQueryResponse::Addresses(
401+
AddressStateQueryResponse::AddressUTxOs(utxos),
402+
)) => Ok(utxos),
403+
Message::StateQueryResponse(StateQueryResponse::Addresses(
404+
AddressStateQueryResponse::Error(e),
405+
)) => Err(e),
406+
_ => Err(QueryError::internal_error(
407+
"Unexpected message type while retrieving address UTxOs",
408+
)),
409+
},
410+
)
411+
.await?;
412+
413+
// Get UTxO balances from utxo state
414+
let msg = Arc::new(Message::StateQuery(StateQuery::UTxOs(
415+
UTxOStateQuery::GetUTxOs {
416+
utxo_identifiers: utxo_identifiers.clone(),
417+
},
418+
)));
419+
let entries = query_state(
420+
&context,
421+
&handlers_config.utxos_query_topic,
422+
msg,
423+
|message| match message {
424+
Message::StateQueryResponse(StateQueryResponse::UTxOs(
425+
UTxOStateQueryResponse::UTxOs(utxos),
426+
)) => Ok(utxos),
427+
Message::StateQueryResponse(StateQueryResponse::UTxOs(
428+
UTxOStateQueryResponse::Error(e),
429+
)) => Err(e),
430+
_ => Err(QueryError::internal_error(
431+
"Unexpected message type while retrieving UTxO entries",
432+
)),
433+
},
434+
)
435+
.await?;
436+
437+
// Filter for UTxOs which contain the asset
438+
let mut filtered_identifiers = Vec::new();
439+
let mut filtered_entries = Vec::new();
440+
441+
for (i, entry) in entries.iter().enumerate() {
442+
let matches = entry.value.assets.iter().any(|(policy, assets)| {
443+
policy == &target_policy && assets.iter().any(|asset| asset.name == target_name)
444+
});
445+
446+
if matches {
447+
filtered_identifiers.push(utxo_identifiers[i]);
448+
filtered_entries.push(entry);
449+
}
450+
}
451+
452+
if filtered_identifiers.is_empty() {
453+
return Ok(RESTResponse::with_json(200, "[]"));
454+
}
455+
456+
// Get TxHashes and BlockHashes from subset of UTxOIdentifiers with specific asset balances
457+
let msg = Arc::new(Message::StateQuery(StateQuery::Blocks(
458+
BlocksStateQuery::GetUTxOHashes {
459+
utxo_ids: filtered_identifiers.clone(),
460+
},
461+
)));
462+
let hashes = query_state(
463+
&context,
464+
&handlers_config.blocks_query_topic,
465+
msg,
466+
|message| match message {
467+
Message::StateQueryResponse(StateQueryResponse::Blocks(
468+
BlocksStateQueryResponse::UTxOHashes(hashes),
469+
)) => Ok(hashes),
470+
Message::StateQueryResponse(StateQueryResponse::Blocks(
471+
BlocksStateQueryResponse::Error(e),
472+
)) => Err(e),
473+
_ => Err(QueryError::internal_error(
474+
"Unexpected message type while retrieving UTxO hashes",
475+
)),
476+
},
477+
)
478+
.await?;
479+
480+
let mut rest_response = Vec::with_capacity(filtered_entries.len());
481+
for (i, entry) in filtered_entries.into_iter().enumerate() {
482+
rest_response.push(UTxOREST::new(
483+
address_str.clone(),
484+
&filtered_identifiers[i],
485+
entry,
486+
hashes.tx_hashes[i].as_ref(),
487+
hashes.block_hashes[i].as_ref(),
488+
))
489+
}
490+
491+
let json = serde_json::to_string_pretty(&rest_response)?;
492+
Ok(RESTResponse::with_json(200, &json))
387493
}
388494

389495
/// Handle `/addresses/{address}/transactions` Blockfrost-compatible endpoint

modules/rest_blockfrost/src/handlers/assets.rs

Lines changed: 2 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::{
44
AssetAddressRest, AssetInfoRest, AssetMetadataREST, AssetMintRecordRest,
55
AssetTransactionRest, PolicyAssetRest,
66
},
7+
utils::split_policy_and_asset,
78
};
89
use acropolis_common::queries::errors::QueryError;
910
use acropolis_common::rest_error::RESTError;
@@ -14,7 +15,7 @@ use acropolis_common::{
1415
utils::query_state,
1516
},
1617
serialization::Bech32WithHrp,
17-
AssetName, PolicyId,
18+
PolicyId,
1819
};
1920
use blake2::{digest::consts::U20, Blake2b, Digest};
2021
use caryatid_sdk::Context;
@@ -298,28 +299,6 @@ pub async fn handle_policy_assets_blockfrost(
298299
Ok(RESTResponse::with_json(200, &json))
299300
}
300301

301-
fn split_policy_and_asset(hex_str: &str) -> Result<(PolicyId, AssetName), RESTError> {
302-
let decoded = hex::decode(hex_str)?;
303-
304-
if decoded.len() < 28 {
305-
return Err(RESTError::BadRequest(
306-
"Asset identifier must be at least 28 bytes".to_string(),
307-
));
308-
}
309-
310-
let (policy_part, asset_part) = decoded.split_at(28);
311-
312-
let policy_id: PolicyId = policy_part
313-
.try_into()
314-
.map_err(|_| RESTError::BadRequest("Policy id must be 28 bytes".to_string()))?;
315-
316-
let asset_name = AssetName::new(asset_part).ok_or_else(|| {
317-
RESTError::BadRequest("Asset name must be less than 32 bytes".to_string())
318-
})?;
319-
320-
Ok((policy_id, asset_name))
321-
}
322-
323302
pub async fn fetch_asset_metadata(
324303
asset: &str,
325304
offchain_registry_url: &str,
@@ -448,59 +427,3 @@ fn cbor_to_json(val: CborValue) -> Value {
448427
_ => Value::Null,
449428
}
450429
}
451-
452-
#[cfg(test)]
453-
mod tests {
454-
use crate::handlers::assets::split_policy_and_asset;
455-
use hex;
456-
457-
fn policy_bytes() -> [u8; 28] {
458-
[0u8; 28]
459-
}
460-
461-
#[test]
462-
fn invalid_hex_string() {
463-
let result = split_policy_and_asset("zzzz");
464-
assert!(result.is_err());
465-
let err = result.unwrap_err();
466-
assert_eq!(err.status_code(), 400);
467-
assert_eq!(
468-
err.message(),
469-
"Invalid hex string: Invalid character 'z' at position 0"
470-
);
471-
}
472-
473-
#[test]
474-
fn too_short_input() {
475-
let hex_str = hex::encode([1u8, 2, 3]);
476-
let result = split_policy_and_asset(&hex_str);
477-
assert!(result.is_err());
478-
let err = result.unwrap_err();
479-
assert_eq!(err.status_code(), 400);
480-
assert_eq!(err.message(), "Asset identifier must be at least 28 bytes");
481-
}
482-
483-
#[test]
484-
fn invalid_asset_name_too_long() {
485-
let mut bytes = policy_bytes().to_vec();
486-
bytes.extend(vec![0u8; 33]);
487-
let hex_str = hex::encode(bytes);
488-
let result = split_policy_and_asset(&hex_str);
489-
assert!(result.is_err());
490-
let err = result.unwrap_err();
491-
assert_eq!(err.status_code(), 400);
492-
assert_eq!(err.message(), "Asset name must be less than 32 bytes");
493-
}
494-
495-
#[test]
496-
fn valid_policy_and_asset() {
497-
let mut bytes = policy_bytes().to_vec();
498-
bytes.extend_from_slice(b"MyToken");
499-
let hex_str = hex::encode(bytes);
500-
let result = split_policy_and_asset(&hex_str);
501-
assert!(result.is_ok());
502-
let (policy, name) = result.unwrap();
503-
assert_eq!(policy, policy_bytes());
504-
assert_eq!(name.as_slice(), b"MyToken");
505-
}
506-
}

modules/rest_blockfrost/src/utils.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::time::Duration;
22

3+
use acropolis_common::{rest_error::RESTError, AssetName, PolicyId};
34
use anyhow::Result;
45
use blake2::digest::{Update, VariableOutput};
56
use reqwest::Client;
@@ -83,6 +84,29 @@ pub fn verify_pool_metadata_hash(
8384
fn invalid_size_desc<T: std::fmt::Display>(e: T) -> String {
8485
format!("Invalid size for hashing pool metadata json {e}")
8586
}
87+
88+
pub fn split_policy_and_asset(hex_str: &str) -> Result<(PolicyId, AssetName), RESTError> {
89+
let decoded = hex::decode(hex_str)?;
90+
91+
if decoded.len() < 28 {
92+
return Err(RESTError::BadRequest(
93+
"Asset identifier must be at least 28 bytes".to_string(),
94+
));
95+
}
96+
97+
let (policy_part, asset_part) = decoded.split_at(28);
98+
99+
let policy_id: PolicyId = policy_part
100+
.try_into()
101+
.map_err(|_| RESTError::BadRequest("Policy id must be 28 bytes".to_string()))?;
102+
103+
let asset_name = AssetName::new(asset_part).ok_or_else(|| {
104+
RESTError::BadRequest("Asset name must be less than 32 bytes".to_string())
105+
})?;
106+
107+
Ok((policy_id, asset_name))
108+
}
109+
86110
#[cfg(test)]
87111
mod tests {
88112
use super::*;
@@ -142,4 +166,54 @@ mod tests {
142166
Ok(())
143167
);
144168
}
169+
170+
fn policy_bytes() -> [u8; 28] {
171+
[0u8; 28]
172+
}
173+
174+
#[test]
175+
fn invalid_hex_string() {
176+
let result = split_policy_and_asset("zzzz");
177+
assert!(result.is_err());
178+
let err = result.unwrap_err();
179+
assert_eq!(err.status_code(), 400);
180+
assert_eq!(
181+
err.message(),
182+
"Invalid hex string: Invalid character 'z' at position 0"
183+
);
184+
}
185+
186+
#[test]
187+
fn too_short_input() {
188+
let hex_str = hex::encode([1u8, 2, 3]);
189+
let result = split_policy_and_asset(&hex_str);
190+
assert!(result.is_err());
191+
let err = result.unwrap_err();
192+
assert_eq!(err.status_code(), 400);
193+
assert_eq!(err.message(), "Asset identifier must be at least 28 bytes");
194+
}
195+
196+
#[test]
197+
fn invalid_asset_name_too_long() {
198+
let mut bytes = policy_bytes().to_vec();
199+
bytes.extend(vec![0u8; 33]);
200+
let hex_str = hex::encode(bytes);
201+
let result = split_policy_and_asset(&hex_str);
202+
assert!(result.is_err());
203+
let err = result.unwrap_err();
204+
assert_eq!(err.status_code(), 400);
205+
assert_eq!(err.message(), "Asset name must be less than 32 bytes");
206+
}
207+
208+
#[test]
209+
fn valid_policy_and_asset() {
210+
let mut bytes = policy_bytes().to_vec();
211+
bytes.extend_from_slice(b"MyToken");
212+
let hex_str = hex::encode(bytes);
213+
let result = split_policy_and_asset(&hex_str);
214+
assert!(result.is_ok());
215+
let (policy, name) = result.unwrap();
216+
assert_eq!(policy, policy_bytes());
217+
assert_eq!(name.as_slice(), b"MyToken");
218+
}
145219
}

0 commit comments

Comments
 (0)