diff --git a/crates/astria-core/src/generated/astria.protocol.asset.v1alpha1.rs b/crates/astria-core/src/generated/astria.protocol.asset.v1alpha1.rs new file mode 100644 index 000000000..852bf222e --- /dev/null +++ b/crates/astria-core/src/generated/astria.protocol.asset.v1alpha1.rs @@ -0,0 +1,16 @@ +/// A response containing the denomination given an asset ID. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DenomResponse { + #[prost(uint64, tag = "2")] + pub height: u64, + #[prost(string, tag = "3")] + pub denom: ::prost::alloc::string::String, +} +impl ::prost::Name for DenomResponse { + const NAME: &'static str = "DenomResponse"; + const PACKAGE: &'static str = "astria.protocol.asset.v1alpha1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("astria.protocol.asset.v1alpha1.{}", Self::NAME) + } +} diff --git a/crates/astria-core/src/generated/mod.rs b/crates/astria-core/src/generated/mod.rs index a11e1e8c7..8d5f7cd9c 100644 --- a/crates/astria-core/src/generated/mod.rs +++ b/crates/astria-core/src/generated/mod.rs @@ -47,6 +47,11 @@ pub mod protocol { pub mod v1alpha1; } #[path = ""] + pub mod asset { + #[path = "astria.protocol.asset.v1alpha1.rs"] + pub mod v1alpha1; + } + #[path = ""] pub mod transaction { #[path = "astria.protocol.transactions.v1alpha1.rs"] pub mod v1alpha1; diff --git a/crates/astria-core/src/protocol/abci.rs b/crates/astria-core/src/protocol/abci.rs index ab96c9624..76c7bc8c8 100644 --- a/crates/astria-core/src/protocol/abci.rs +++ b/crates/astria-core/src/protocol/abci.rs @@ -17,6 +17,7 @@ impl AbciErrorCode { pub const TRANSACTION_TOO_LARGE: Self = Self(5); pub const INSUFFICIENT_FUNDS: Self = Self(6); pub const INVALID_CHAIN_ID: Self = Self(7); + pub const VALUE_NOT_FOUND: Self = Self(8); } impl AbciErrorCode { @@ -31,6 +32,7 @@ impl AbciErrorCode { 5 => "the provided transaction was too large".into(), 6 => "insufficient funds".into(), 7 => "the provided chain id was invalid".into(), + 8 => "the requested value was not found".into(), other => format!("unknown non-zero abci error code: {other}").into(), } } @@ -58,6 +60,7 @@ impl From for AbciErrorCode { 5 => Self::TRANSACTION_TOO_LARGE, 6 => Self::INSUFFICIENT_FUNDS, 7 => Self::INVALID_CHAIN_ID, + 8 => Self::VALUE_NOT_FOUND, other => Self(other), } } diff --git a/crates/astria-core/src/protocol/asset/mod.rs b/crates/astria-core/src/protocol/asset/mod.rs new file mode 100644 index 000000000..7fe01b872 --- /dev/null +++ b/crates/astria-core/src/protocol/asset/mod.rs @@ -0,0 +1,3 @@ +pub mod v1alpha1; + +use crate::generated::protocol::asset::v1alpha1 as raw; diff --git a/crates/astria-core/src/protocol/asset/v1alpha1/mod.rs b/crates/astria-core/src/protocol/asset/v1alpha1/mod.rs new file mode 100644 index 000000000..9c0fb48c5 --- /dev/null +++ b/crates/astria-core/src/protocol/asset/v1alpha1/mod.rs @@ -0,0 +1,62 @@ +use super::raw; +use crate::primitive::v1::asset::Denom; + +/// The sequencer response to a denomination request for a given asset ID. +#[derive(Clone, Debug, PartialEq)] +pub struct DenomResponse { + pub height: u64, + pub denom: Denom, +} + +impl DenomResponse { + /// Converts a protobuf [`raw::DenomResponse`] to an astria + /// native [`DenomResponse`]. + #[must_use] + pub fn from_raw(proto: &raw::DenomResponse) -> Self { + let raw::DenomResponse { + height, + denom, + } = proto; + Self { + height: *height, + denom: denom.clone().into(), + } + } + + /// Converts an astria native [`DenomResponse`] to a + /// protobuf [`raw::DenomResponse`]. + #[must_use] + pub fn into_raw(self) -> raw::DenomResponse { + raw::DenomResponse::from_native(self) + } +} + +impl raw::DenomResponse { + /// Converts an astria native [`DenomResponse`] to a + /// protobuf [`raw::DenomResponse`]. + #[must_use] + pub fn from_native(native: DenomResponse) -> Self { + let DenomResponse { + height, + denom, + } = native; + Self { + height, + denom: denom.to_string(), + } + } + + /// Converts a protobuf [`raw::DenomResponse`] to an astria + /// native [`DenomResponse`]. + #[must_use] + pub fn into_native(self) -> DenomResponse { + DenomResponse::from_raw(&self) + } + + /// Converts a protobuf [`raw::DenomResponse`] to an astria + /// native [`DenomResponse`] by allocating a new [`v1alpha1::DenomResponse`]. + #[must_use] + pub fn to_native(&self) -> DenomResponse { + self.clone().into_native() + } +} diff --git a/crates/astria-core/src/protocol/mod.rs b/crates/astria-core/src/protocol/mod.rs index b275a5db6..bf486bb99 100644 --- a/crates/astria-core/src/protocol/mod.rs +++ b/crates/astria-core/src/protocol/mod.rs @@ -5,6 +5,7 @@ use crate::primitive::v1::RollupId; pub mod abci; pub mod account; +pub mod asset; pub mod transaction; #[cfg(any(feature = "test-utils", test))] diff --git a/crates/astria-sequencer/src/accounts/state_ext.rs b/crates/astria-sequencer/src/accounts/state_ext.rs index f285125ea..b1053ea60 100644 --- a/crates/astria-sequencer/src/accounts/state_ext.rs +++ b/crates/astria-sequencer/src/accounts/state_ext.rs @@ -96,7 +96,11 @@ pub(crate) trait StateReadExt: StateRead { continue; } - let denom = self.get_ibc_asset(asset_id).await?; + let denom = self + .get_ibc_asset(asset_id) + .await + .context("failed to get ibc asset denom")? + .context("asset denom not found when user has balance of it; this is a bug")?; balances.push(AssetBalance { denom, balance, diff --git a/crates/astria-sequencer/src/asset/mod.rs b/crates/astria-sequencer/src/asset/mod.rs index e45db1125..f4f5023d4 100644 --- a/crates/astria-sequencer/src/asset/mod.rs +++ b/crates/astria-sequencer/src/asset/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod query; pub(crate) mod state_ext; use std::sync::OnceLock; diff --git a/crates/astria-sequencer/src/asset/query.rs b/crates/astria-sequencer/src/asset/query.rs new file mode 100644 index 000000000..7b938704c --- /dev/null +++ b/crates/astria-sequencer/src/asset/query.rs @@ -0,0 +1,108 @@ +use anyhow::Context as _; +use astria_core::{ + primitive::v1::asset, + protocol::abci::AbciErrorCode, +}; +use cnidarium::Storage; +use prost::Message as _; +use tendermint::abci::{ + request, + response, +}; + +use crate::{ + asset::state_ext::StateReadExt as _, + state_ext::StateReadExt, +}; + +// Retrieve the full asset denomination given the asset ID. +// +// Example: +// `abci-cli query --path=asset/denom/` +pub(crate) async fn denom_request( + storage: Storage, + request: request::Query, + params: Vec<(String, String)>, +) -> response::Query { + use astria_core::protocol::asset::v1alpha1::DenomResponse; + + // use the latest snapshot, as this is a lookup of id->denom + let snapshot = storage.latest_snapshot(); + let asset_id = match preprocess_request(¶ms) { + Ok(asset_id) => asset_id, + Err(err_rsp) => return err_rsp, + }; + + let height = match snapshot.get_block_height().await { + Ok(height) => height, + Err(err) => { + return response::Query { + code: AbciErrorCode::INTERNAL_ERROR.into(), + info: AbciErrorCode::INTERNAL_ERROR.to_string(), + log: format!("failed getting block height: {err:#}"), + ..response::Query::default() + }; + } + }; + + let maybe_denom = match snapshot.get_ibc_asset(asset_id).await { + Ok(maybe_denom) => maybe_denom, + Err(err) => { + return response::Query { + code: AbciErrorCode::INTERNAL_ERROR.into(), + info: AbciErrorCode::INTERNAL_ERROR.to_string(), + log: format!("failed to retrieve denomination `{asset_id}`: {err:#}"), + ..response::Query::default() + }; + } + }; + + let Some(denom) = maybe_denom else { + return response::Query { + code: AbciErrorCode::VALUE_NOT_FOUND.into(), + info: AbciErrorCode::VALUE_NOT_FOUND.to_string(), + log: format!("failed to retrieve value for denomination ID`{asset_id}`"), + ..response::Query::default() + }; + }; + + let payload = DenomResponse { + height, + denom, + } + .into_raw() + .encode_to_vec() + .into(); + + let height = tendermint::block::Height::try_from(height).expect("height must fit into an i64"); + response::Query { + code: tendermint::abci::Code::Ok, + key: request.path.into_bytes().into(), + value: payload, + height, + ..response::Query::default() + } +} + +fn preprocess_request(params: &[(String, String)]) -> anyhow::Result { + let Some(asset_id) = params.iter().find_map(|(k, v)| (k == "id").then_some(v)) else { + return Err(response::Query { + code: AbciErrorCode::INVALID_PARAMETER.into(), + info: AbciErrorCode::INVALID_PARAMETER.to_string(), + log: "path did not contain asset ID parameter".into(), + ..response::Query::default() + }); + }; + let asset_id = hex::decode(asset_id) + .context("failed decoding hex encoded bytes") + .and_then(|addr| { + asset::Id::try_from_slice(&addr).context("failed constructing asset ID from bytes") + }) + .map_err(|err| response::Query { + code: AbciErrorCode::INVALID_PARAMETER.into(), + info: AbciErrorCode::INVALID_PARAMETER.to_string(), + log: format!("asset ID could not be constructed from provided parameter: {err:#}"), + ..response::Query::default() + })?; + Ok(asset_id) +} diff --git a/crates/astria-sequencer/src/asset/state_ext.rs b/crates/astria-sequencer/src/asset/state_ext.rs index f271be115..34161c2cf 100644 --- a/crates/astria-sequencer/src/asset/state_ext.rs +++ b/crates/astria-sequencer/src/asset/state_ext.rs @@ -1,5 +1,4 @@ use anyhow::{ - bail, Context as _, Result, }; @@ -42,19 +41,19 @@ pub(crate) trait StateReadExt: StateRead { } #[instrument(skip(self))] - async fn get_ibc_asset(&self, id: asset::Id) -> Result { + async fn get_ibc_asset(&self, id: asset::Id) -> Result> { let Some(bytes) = self .get_raw(&asset_storage_key(id)) .await .context("failed reading raw asset from state")? else { - bail!("asset not found"); + return Ok(None); }; let DenominationTrace(denom_str) = DenominationTrace::try_from_slice(&bytes).context("invalid asset bytes")?; let denom: Denom = denom_str.into(); - Ok(denom) + Ok(Some(denom)) } } @@ -94,11 +93,14 @@ mod test { let asset = Id::from_denom("asset"); - // gets for non existing assets fail - state - .get_ibc_asset(asset) - .await - .expect_err("gets for non existing ibc assets should fail"); + // gets for non existing assets should return none + assert_eq!( + state + .get_ibc_asset(asset) + .await + .expect("getting non existing asset should not fail"), + None + ); } #[tokio::test] @@ -147,6 +149,7 @@ mod test { state .get_ibc_asset(denom.id()) .await + .unwrap() .expect("an ibc asset was written and must exist inside the database"), denom, "stored ibc asset was not what was expected" @@ -168,6 +171,7 @@ mod test { state .get_ibc_asset(denom.id()) .await + .unwrap() .expect("an ibc asset was written and must exist inside the database"), denom, "stored ibc asset was not what was expected" @@ -182,6 +186,7 @@ mod test { state .get_ibc_asset(denom_1.id()) .await + .unwrap() .expect("an additional ibc asset was written and must exist inside the database"), denom_1, "additional ibc asset was not what was expected" @@ -190,6 +195,7 @@ mod test { state .get_ibc_asset(denom.id()) .await + .unwrap() .expect("an ibc asset was written and must exist inside the database"), denom, "original ibc asset was not what was expected" @@ -214,6 +220,7 @@ mod test { state .get_ibc_asset(id_key) .await + .unwrap() .expect("an ibc asset was written and must exist inside the database") .id(), id_key, diff --git a/crates/astria-sequencer/src/ibc/ics20_transfer.rs b/crates/astria-sequencer/src/ibc/ics20_transfer.rs index 5168a62f2..a477a8e23 100644 --- a/crates/astria-sequencer/src/ibc/ics20_transfer.rs +++ b/crates/astria-sequencer/src/ibc/ics20_transfer.rs @@ -183,7 +183,8 @@ async fn refund_tokens_check( denom = state .get_ibc_asset(denom.id()) .await - .context("failed to get denom trace from asset id")?; + .context("failed to get denom trace from asset id")? + .context("denom for given asset id not found in state")?; } let is_source = !is_prefixed(source_port, source_channel, &denom); @@ -396,6 +397,7 @@ async fn convert_denomination_if_ibc_prefixed( .get_ibc_asset(id_bytes.into()) .await .context("failed to get denom trace from asset id")? + .context("denom for given asset id not found in state")? } else { packet_denom }; diff --git a/crates/astria-sequencer/src/service/info/mod.rs b/crates/astria-sequencer/src/service/info/mod.rs index 60c0dd590..7433c14a0 100644 --- a/crates/astria-sequencer/src/service/info/mod.rs +++ b/crates/astria-sequencer/src/service/info/mod.rs @@ -55,6 +55,9 @@ impl Info { crate::accounts::query::nonce_request, ) .context("invalid path: `accounts/nonce/:account`")?; + query_router + .insert("asset/denom/:id", crate::asset::query::denom_request) + .context("invalid path: `asset/denom/:id`")?; Ok(Self { storage, query_router, @@ -153,6 +156,7 @@ mod test { Address, }; use cnidarium::StateDelta; + use prost::Message as _; use tendermint::v0_38::abci::{ request, InfoRequest, @@ -164,13 +168,19 @@ mod test { accounts::state_ext::StateWriteExt as _, asset::{ get_native_asset, - NATIVE_ASSET, + initialize_native_asset, + state_ext::StateWriteExt, }, state_ext::StateWriteExt as _, }; #[tokio::test] - async fn handle_query() { + async fn handle_balance_query() { + use astria_core::{ + generated::protocol::account::v1alpha1 as raw, + protocol::account::v1alpha1::AssetBalance, + }; + let storage = cnidarium::TempStorage::new() .await .expect("failed to create temp storage backing chain state"); @@ -179,17 +189,18 @@ mod test { let mut state = StateDelta::new(storage.latest_snapshot()); state.put_storage_version_by_height(height, version); - let _ = NATIVE_ASSET.set(Denom::from_base_denom(DEFAULT_NATIVE_ASSET_DENOM)); + initialize_native_asset(DEFAULT_NATIVE_ASSET_DENOM); let address = Address::try_from_slice( &hex::decode("a034c743bed8f26cb8ee7b8db2230fd8347ae131").unwrap(), ) .unwrap(); + + let balance = 1000; state - .put_account_balance(address, get_native_asset().id(), 1000) + .put_account_balance(address, get_native_asset().id(), balance) .unwrap(); state.put_block_height(height); - storage.commit(state).await.unwrap(); let info_request = InfoRequest::Query(request::Query { @@ -212,5 +223,59 @@ mod test { other => panic!("expected InfoResponse::Query, got {other:?}"), }; assert!(query_response.code.is_ok()); + + let expected_balance = AssetBalance { + denom: get_native_asset().clone(), + balance, + }; + + let balance_resp = raw::BalanceResponse::decode(query_response.value) + .unwrap() + .to_native(); + assert_eq!(balance_resp.balances.len(), 1); + assert_eq!(balance_resp.balances[0], expected_balance); + assert_eq!(balance_resp.height, height); + } + + #[tokio::test] + async fn handle_denom_query() { + use astria_core::generated::protocol::asset::v1alpha1 as raw; + + let storage = cnidarium::TempStorage::new().await.unwrap(); + let mut state = StateDelta::new(storage.latest_snapshot()); + + let denom: Denom = "some/ibc/asset".to_string().into(); + let id = denom.id(); + let height = 99; + state.put_block_height(height); + state.put_ibc_asset(id, &denom).unwrap(); + storage.commit(state).await.unwrap(); + + let info_request = InfoRequest::Query(request::Query { + path: format!("asset/denom/{}", hex::encode(id)), + data: vec![].into(), + height: u32::try_from(height).unwrap().into(), + prove: false, + }); + + let response = { + let storage = (*storage).clone(); + let info_service = Info::new(storage).unwrap(); + info_service + .handle_info_request(info_request) + .await + .unwrap() + }; + let query_response = match response { + InfoResponse::Query(query) => query, + other => panic!("expected InfoResponse::Query, got {other:?}"), + }; + assert!(query_response.code.is_ok()); + + let denom_resp = raw::DenomResponse::decode(query_response.value) + .unwrap() + .to_native(); + assert_eq!(denom_resp.height, height); + assert_eq!(denom_resp.denom, denom); } } diff --git a/proto/protocolapis/astria/protocol/asset/v1alpha1/types.proto b/proto/protocolapis/astria/protocol/asset/v1alpha1/types.proto new file mode 100644 index 000000000..a3e4474d4 --- /dev/null +++ b/proto/protocolapis/astria/protocol/asset/v1alpha1/types.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package astria.protocol.asset.v1alpha1; + +// A response containing the denomination given an asset ID. +message DenomResponse { + uint64 height = 2; + string denom = 3; +}