diff --git a/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs b/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs index 4b75c9d8ef..642d4485eb 100644 --- a/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs +++ b/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs @@ -1,4 +1,5 @@ -/// A response containing the last tx hash given some bridge address, +/// A response to the `bridge/account_last_tx_hash` ABCI query +/// containing the last tx hash given some bridge address, /// if it exists. #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -15,3 +16,32 @@ impl ::prost::Name for BridgeAccountLastTxHashResponse { ::prost::alloc::format!("astria.protocol.bridge.v1alpha1.{}", Self::NAME) } } +/// A response to the `bridge/account_info` ABCI query +/// containing the account information given some bridge address, +/// if it exists. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct BridgeAccountInfoResponse { + #[prost(uint64, tag = "2")] + pub height: u64, + /// if the account is not a bridge account, the following fields will be empty. + #[prost(message, optional, tag = "3")] + pub rollup_id: ::core::option::Option, + #[prost(string, optional, tag = "4")] + pub asset: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag = "5")] + pub sudo_address: ::core::option::Option< + super::super::super::primitive::v1::Address, + >, + #[prost(message, optional, tag = "6")] + pub withdrawer_address: ::core::option::Option< + super::super::super::primitive::v1::Address, + >, +} +impl ::prost::Name for BridgeAccountInfoResponse { + const NAME: &'static str = "BridgeAccountInfoResponse"; + const PACKAGE: &'static str = "astria.protocol.bridge.v1alpha1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("astria.protocol.bridge.v1alpha1.{}", Self::NAME) + } +} diff --git a/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs b/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs index 6e09384c54..8fbcd562c0 100644 --- a/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs +++ b/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs @@ -1,4 +1,12 @@ use super::raw; +use crate::primitive::v1::{ + asset, + asset::denom::ParseDenomError, + Address, + AddressError, + IncorrectRollupIdLength, + RollupId, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgeAccountLastTxHashResponse { @@ -76,3 +84,149 @@ enum BridgeAccountLastTxHashResponseErrorKind { #[error("invalid tx hash; must be 32 bytes, got {0} bytes")] InvalidTxHash(usize), } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeAccountInfoResponse { + pub height: u64, + pub info: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeAccountInfo { + pub rollup_id: RollupId, + pub asset: asset::Denom, + pub sudo_address: Address, + pub withdrawer_address: Address, +} + +impl BridgeAccountInfoResponse { + /// Converts a protobuf [`raw::BridgeAccountInfoResponse`] to a native + /// [`BridgeAccountInfoResponse`]. + /// + /// # Errors + /// + /// - if the `rollup_id` field is set but the `sudo_address` field is not + /// - if the `rollup_id` field is set but the `withdrawer_address` field is not + /// - if the `rollup_id` field is set but the `asset_id` field is not + /// - if the `asset` field does not contain a valid asset denom + /// - if the `rollup_id` field is set but invalid + /// - if the `sudo_address` field is set but invalid + /// - if the `withdrawer_address` field is set but invalid + pub fn try_from_raw( + raw: raw::BridgeAccountInfoResponse, + ) -> Result { + let raw::BridgeAccountInfoResponse { + height, + rollup_id, + asset, + sudo_address, + withdrawer_address, + } = raw; + + let Some(rollup_id) = rollup_id else { + return Ok(Self { + height, + info: None, + }); + }; + + let Some(sudo_address) = sudo_address else { + return Err(BridgeAccountInfoResponseError::field_not_set( + "sudo_address", + )); + }; + + let Some(withdrawer_address) = withdrawer_address else { + return Err(BridgeAccountInfoResponseError::field_not_set( + "withdrawer_address", + )); + }; + + let Some(asset) = asset else { + return Err(BridgeAccountInfoResponseError::field_not_set("asset")); + }; + + let asset = asset + .parse() + .map_err(BridgeAccountInfoResponseError::invalid_denom)?; + + Ok(Self { + height, + info: Some(BridgeAccountInfo { + rollup_id: RollupId::try_from_raw(&rollup_id) + .map_err(BridgeAccountInfoResponseError::invalid_rollup_id)?, + asset, + sudo_address: Address::try_from_raw(&sudo_address) + .map_err(BridgeAccountInfoResponseError::invalid_sudo_address)?, + withdrawer_address: Address::try_from_raw(&withdrawer_address) + .map_err(BridgeAccountInfoResponseError::invalid_withdrawer_address)?, + }), + }) + } + + #[must_use] + pub fn into_raw(self) -> raw::BridgeAccountInfoResponse { + let Some(info) = self.info else { + return raw::BridgeAccountInfoResponse { + height: self.height, + rollup_id: None, + asset: None, + sudo_address: None, + withdrawer_address: None, + }; + }; + + raw::BridgeAccountInfoResponse { + height: self.height, + rollup_id: Some(info.rollup_id.into_raw()), + asset: Some(info.asset.to_string()), + sudo_address: Some(info.sudo_address.into_raw()), + withdrawer_address: Some(info.withdrawer_address.into_raw()), + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct BridgeAccountInfoResponseError(BridgeAccountInfoResponseErrorKind); + +#[derive(Debug, thiserror::Error)] +enum BridgeAccountInfoResponseErrorKind { + #[error("the expected field in the raw source type was not set: `{0}`")] + FieldNotSet(&'static str), + #[error("the `denom` field was invalid")] + InvalidDenom(#[source] ParseDenomError), + #[error("the `rollup_id` field was invalid")] + InvalidRollupId(#[source] IncorrectRollupIdLength), + #[error("the `sudo_address` field was invalid")] + InvalidSudoAddress(#[source] AddressError), + #[error("the `withdrawer_address` field was invalid")] + InvalidWithdrawerAddress(#[source] AddressError), +} + +impl BridgeAccountInfoResponseError { + #[must_use] + pub fn field_not_set(field: &'static str) -> Self { + Self(BridgeAccountInfoResponseErrorKind::FieldNotSet(field)) + } + + #[must_use] + pub fn invalid_rollup_id(err: IncorrectRollupIdLength) -> Self { + Self(BridgeAccountInfoResponseErrorKind::InvalidRollupId(err)) + } + + #[must_use] + pub fn invalid_sudo_address(err: AddressError) -> Self { + Self(BridgeAccountInfoResponseErrorKind::InvalidSudoAddress(err)) + } + + #[must_use] + pub fn invalid_withdrawer_address(err: AddressError) -> Self { + Self(BridgeAccountInfoResponseErrorKind::InvalidWithdrawerAddress(err)) + } + + #[must_use] + pub fn invalid_denom(err: ParseDenomError) -> Self { + Self(BridgeAccountInfoResponseErrorKind::InvalidDenom(err)) + } +} diff --git a/crates/astria-sequencer-client/src/extension_trait.rs b/crates/astria-sequencer-client/src/extension_trait.rs index d5b06bec6a..a03a8c50a3 100644 --- a/crates/astria-sequencer-client/src/extension_trait.rs +++ b/crates/astria-sequencer-client/src/extension_trait.rs @@ -35,7 +35,10 @@ use std::{ use astria_core::protocol::{ asset::v1alpha1::AllowedFeeAssetsResponse, - bridge::v1alpha1::BridgeAccountLastTxHashResponse, + bridge::v1alpha1::{ + BridgeAccountInfoResponse, + BridgeAccountLastTxHashResponse, + }, }; pub use astria_core::{ primitive::v1::Address, @@ -543,6 +546,38 @@ pub trait SequencerClientExt: Client { self.get_nonce(address, 0u32).await } + async fn get_bridge_account_info( + &self, + address: Address, + ) -> Result { + const PREFIX: &str = "bridge/account_info"; + let path = format!("{PREFIX}/{address}"); + + let response = self + .abci_query(Some(path), vec![], None, false) + .await + .map_err(|e| Error::tendermint_rpc("abci_query", e))?; + + let proto_response = + astria_core::generated::protocol::bridge::v1alpha1::BridgeAccountInfoResponse::decode( + &*response.value, + ) + .map_err(|e| { + Error::abci_query_deserialization( + "astria.protocol.bridge.v1alpha1.BridgeAccountInfoResponse", + response, + e, + ) + })?; + let native = BridgeAccountInfoResponse::try_from_raw(proto_response).map_err(|e| { + Error::native_conversion( + "astria.protocol.bridge.v1alpha1.BridgeAccountInfoResponse", + Arc::new(e), + ) + })?; + Ok(native) + } + async fn get_bridge_account_last_transaction_hash( &self, address: Address, diff --git a/crates/astria-sequencer-client/src/tests/http.rs b/crates/astria-sequencer-client/src/tests/http.rs index 2f48e22211..30a9458f05 100644 --- a/crates/astria-sequencer-client/src/tests/http.rs +++ b/crates/astria-sequencer-client/src/tests/http.rs @@ -251,6 +251,39 @@ async fn get_allowed_fee_assets() { assert_eq!(expected_response, actual_response); } +#[tokio::test] +async fn get_bridge_account_info() { + use astria_core::{ + generated::protocol::bridge::v1alpha1::BridgeAccountInfoResponse, + primitive::v1::RollupId, + }; + + let MockSequencer { + server, + client, + } = MockSequencer::start().await; + + let expected_response = BridgeAccountInfoResponse { + height: 10, + rollup_id: Some(RollupId::from_unhashed_bytes(b"rollup_0").into_raw()), + asset: Some("asset_0".parse().unwrap()), + sudo_address: Some(alice_address().into_raw()), + withdrawer_address: Some(alice_address().into_raw()), + }; + + let _guard = + register_abci_query_response(&server, "bridge/account_info", expected_response.clone()) + .await; + + let actual_response = client + .get_bridge_account_info(alice_address()) + .await + .unwrap() + .into_raw(); + + assert_eq!(expected_response, actual_response); +} + #[tokio::test] async fn get_bridge_account_last_transaction_hash() { use astria_core::generated::protocol::bridge::v1alpha1::BridgeAccountLastTxHashResponse; diff --git a/crates/astria-sequencer/src/bridge/query.rs b/crates/astria-sequencer/src/bridge/query.rs index b93268f4c3..e7a0a2c3c8 100644 --- a/crates/astria-sequencer/src/bridge/query.rs +++ b/crates/astria-sequencer/src/bridge/query.rs @@ -1,7 +1,10 @@ use anyhow::Context as _; use astria_core::{ primitive::v1::Address, - protocol::abci::AbciErrorCode, + protocol::{ + abci::AbciErrorCode, + bridge::v1alpha1::BridgeAccountInfo, + }, }; use cnidarium::Storage; use prost::Message as _; @@ -11,10 +14,170 @@ use tendermint::abci::{ }; use crate::{ + asset::state_ext::StateReadExt as _, bridge::state_ext::StateReadExt as _, state_ext::StateReadExt as _, }; +fn error_query_response( + err: Option, + code: AbciErrorCode, + msg: &str, +) -> response::Query { + let log = match err { + Some(err) => format!("{msg}: {err:?}"), + None => msg.into(), + }; + response::Query { + code: code.into(), + info: code.to_string(), + log, + ..response::Query::default() + } +} + +async fn get_bridge_account_info( + snapshot: cnidarium::Snapshot, + address: Address, +) -> anyhow::Result, response::Query> { + let rollup_id = match snapshot.get_bridge_account_rollup_id(&address).await { + Ok(Some(rollup_id)) => rollup_id, + Ok(None) => { + return Ok(None); + } + Err(err) => { + return Err(error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed to get rollup id", + )); + } + }; + + let ibc_asset = match snapshot.get_bridge_account_ibc_asset(&address).await { + Ok(asset) => asset, + Err(err) => { + return Err(error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed to get bridge asset", + )); + } + }; + + let trace_asset = match snapshot.map_ibc_to_trace_prefixed_asset(ibc_asset).await { + Ok(Some(trace_asset)) => trace_asset, + Ok(None) => { + return Err(error_query_response( + None, + AbciErrorCode::INTERNAL_ERROR, + "failed to map ibc asset to trace asset; asset does not exist in state", + )); + } + Err(err) => { + return Err(error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed to map ibc asset to trace asset", + )); + } + }; + + let sudo_address = match snapshot.get_bridge_account_sudo_address(&address).await { + Ok(Some(sudo_address)) => sudo_address, + Ok(None) => { + return Err(error_query_response( + None, + AbciErrorCode::INTERNAL_ERROR, + "sudo address not set", + )); + } + Err(err) => { + return Err(error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed to get sudo address", + )); + } + }; + + let withdrawer_address = match snapshot + .get_bridge_account_withdrawer_address(&address) + .await + { + Ok(Some(withdrawer_address)) => withdrawer_address, + Ok(None) => { + return Err(error_query_response( + None, + AbciErrorCode::INTERNAL_ERROR, + "withdrawer address not set", + )); + } + Err(err) => { + return Err(error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed to get withdrawer address", + )); + } + }; + + Ok(Some(BridgeAccountInfo { + rollup_id, + asset: trace_asset.into(), + sudo_address, + withdrawer_address, + })) +} + +pub(crate) async fn bridge_account_info_request( + storage: Storage, + request: request::Query, + params: Vec<(String, String)>, +) -> response::Query { + use astria_core::protocol::bridge::v1alpha1::BridgeAccountInfoResponse; + + let address = match preprocess_request(¶ms) { + Ok(tup) => tup, + Err(err_rsp) => return err_rsp, + }; + + let snapshot = storage.latest_snapshot(); + let height = match snapshot.get_block_height().await { + Ok(height) => height, + Err(err) => { + return error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed to get block height", + ); + } + }; + + let info = match get_bridge_account_info(snapshot, address).await { + Ok(info) => info, + Err(err) => { + return err; + } + }; + + let resp = BridgeAccountInfoResponse { + height, + info, + }; + + let payload = resp.into_raw().encode_to_vec().into(); + + let height = tendermint::block::Height::try_from(height).expect("height must fit into an i64"); + response::Query { + code: 0.into(), + key: request.path.clone().into_bytes().into(), + value: payload, + height, + ..response::Query::default() + } +} + pub(crate) async fn bridge_account_last_tx_hash_request( storage: Storage, request: request::Query, @@ -32,12 +195,11 @@ pub(crate) async fn bridge_account_last_tx_hash_request( 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() - }; + return error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed to get block height", + ); } }; @@ -54,12 +216,11 @@ pub(crate) async fn bridge_account_last_tx_hash_request( tx_hash: None, }, Err(err) => { - return response::Query { - code: AbciErrorCode::INTERNAL_ERROR.into(), - info: AbciErrorCode::INTERNAL_ERROR.to_string(), - log: format!("failed getting balance for provided address: {err:?}"), - ..response::Query::default() - }; + return error_query_response( + Some(err), + AbciErrorCode::INTERNAL_ERROR, + "failed getting balance for provided address", + ); } }; let payload = resp.into_raw().encode_to_vec().into(); @@ -79,12 +240,11 @@ fn preprocess_request(params: &[(String, String)]) -> anyhow::Result anyhow::Result