diff --git a/crates/astria-core/src/generated/astria.protocol.transactions.v1alpha1.rs b/crates/astria-core/src/generated/astria.protocol.transactions.v1alpha1.rs index de203206ed..e6f250ebec 100644 --- a/crates/astria-core/src/generated/astria.protocol.transactions.v1alpha1.rs +++ b/crates/astria-core/src/generated/astria.protocol.transactions.v1alpha1.rs @@ -472,3 +472,34 @@ impl ::prost::Name for FeeChangeAction { ::prost::alloc::format!("astria.protocol.transactions.v1alpha1.{}", Self::NAME) } } +/// Response to a transaction fee ABCI query. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TransactionFeeResponse { + #[prost(uint64, tag = "2")] + pub height: u64, + #[prost(message, repeated, tag = "3")] + pub fees: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for TransactionFeeResponse { + const NAME: &'static str = "TransactionFeeResponse"; + const PACKAGE: &'static str = "astria.protocol.transactions.v1alpha1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("astria.protocol.transactions.v1alpha1.{}", Self::NAME) + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TransactionFee { + #[prost(string, tag = "1")] + pub asset: ::prost::alloc::string::String, + #[prost(message, optional, tag = "2")] + pub fee: ::core::option::Option, +} +impl ::prost::Name for TransactionFee { + const NAME: &'static str = "TransactionFee"; + const PACKAGE: &'static str = "astria.protocol.transactions.v1alpha1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("astria.protocol.transactions.v1alpha1.{}", Self::NAME) + } +} diff --git a/crates/astria-core/src/protocol/abci.rs b/crates/astria-core/src/protocol/abci.rs index 22339e4dec..05d724539c 100644 --- a/crates/astria-core/src/protocol/abci.rs +++ b/crates/astria-core/src/protocol/abci.rs @@ -20,6 +20,7 @@ impl AbciErrorCode { pub const VALUE_NOT_FOUND: Self = Self(8); pub const TRANSACTION_EXPIRED: Self = Self(9); pub const TRANSACTION_FAILED: Self = Self(10); + pub const BAD_REQUEST: Self = Self(11); } impl AbciErrorCode { @@ -37,6 +38,7 @@ impl AbciErrorCode { 8 => "the requested value was not found".into(), 9 => "the transaction expired in the app's mempool".into(), 10 => "the transaction failed to execute in prepare_proposal()".into(), + 11 => "the request payload was malformed".into(), other => format!("unknown non-zero abci error code: {other}").into(), } } @@ -67,6 +69,7 @@ impl From for AbciErrorCode { 8 => Self::VALUE_NOT_FOUND, 9 => Self::TRANSACTION_EXPIRED, 10 => Self::TRANSACTION_FAILED, + 11 => Self::BAD_REQUEST, other => Self(other), } } diff --git a/crates/astria-core/src/protocol/transaction/v1alpha1/mod.rs b/crates/astria-core/src/protocol/transaction/v1alpha1/mod.rs index e4da3bb09e..77032792cd 100644 --- a/crates/astria-core/src/protocol/transaction/v1alpha1/mod.rs +++ b/crates/astria-core/src/protocol/transaction/v1alpha1/mod.rs @@ -11,7 +11,10 @@ use crate::{ SigningKey, VerificationKey, }, - primitive::v1::ADDRESS_LEN, + primitive::v1::{ + asset, + ADDRESS_LEN, + }, }; pub mod action; @@ -470,6 +473,83 @@ impl TransactionParams { } } +#[derive(Debug, Clone)] +pub struct TransactionFeeResponse { + pub height: u64, + pub fees: Vec<(asset::Denom, u128)>, +} + +impl TransactionFeeResponse { + #[must_use] + pub fn into_raw(self) -> raw::TransactionFeeResponse { + raw::TransactionFeeResponse { + height: self.height, + fees: self + .fees + .into_iter() + .map(|(asset, fee)| raw::TransactionFee { + asset: asset.to_string(), + fee: Some(fee.into()), + }) + .collect(), + } + } + + /// Attempt to convert from a raw protobuf [`raw::TransactionFeeResponse`]. + /// + /// # Errors + /// + /// - if the asset ID could not be converted from bytes + /// - if the fee was unset + pub fn try_from_raw( + proto: raw::TransactionFeeResponse, + ) -> Result { + let raw::TransactionFeeResponse { + height, + fees, + } = proto; + let fees = fees + .into_iter() + .map( + |raw::TransactionFee { + asset, + fee, + }| { + let asset = asset.parse().map_err(TransactionFeeResponseError::asset)?; + let fee = fee.ok_or(TransactionFeeResponseError::unset_fee())?; + Ok((asset, fee.into())) + }, + ) + .collect::>()?; + Ok(Self { + height, + fees, + }) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct TransactionFeeResponseError(TransactionFeeResponseErrorKind); + +impl TransactionFeeResponseError { + fn unset_fee() -> Self { + Self(TransactionFeeResponseErrorKind::UnsetFee) + } + + fn asset(inner: asset::ParseDenomError) -> Self { + Self(TransactionFeeResponseErrorKind::Asset(inner)) + } +} + +#[derive(Debug, thiserror::Error)] +enum TransactionFeeResponseErrorKind { + #[error("`fee` field is unset")] + UnsetFee, + #[error("failed to parse asset denom in the `assets` field")] + Asset(#[source] asset::ParseDenomError), +} + #[cfg(test)] mod test { use super::*; diff --git a/crates/astria-sequencer-client/src/extension_trait.rs b/crates/astria-sequencer-client/src/extension_trait.rs index a03a8c50a3..5180f0b13f 100644 --- a/crates/astria-sequencer-client/src/extension_trait.rs +++ b/crates/astria-sequencer-client/src/extension_trait.rs @@ -39,6 +39,10 @@ use astria_core::protocol::{ BridgeAccountInfoResponse, BridgeAccountLastTxHashResponse, }, + transaction::v1alpha1::{ + TransactionFeeResponse, + UnsignedTransaction, + }, }; pub use astria_core::{ primitive::v1::Address, @@ -610,6 +614,38 @@ pub trait SequencerClientExt: Client { Ok(native) } + async fn get_transaction_fee( + &self, + tx: UnsignedTransaction, + ) -> Result { + let path = "transaction/fee".to_string(); + let data = tx.into_raw().encode_to_vec(); + + let response = self + .abci_query(Some(path), data, None, false) + .await + .map_err(|e| Error::tendermint_rpc("abci_query", e))?; + + let proto_response = + astria_core::generated::protocol::transaction::v1alpha1::TransactionFeeResponse::decode( + &*response.value, + ) + .map_err(|e| { + Error::abci_query_deserialization( + "astria.protocol.transaction.v1alpha1.TransactionFeeResponse", + response, + e, + ) + })?; + let native = TransactionFeeResponse::try_from_raw(proto_response).map_err(|e| { + Error::native_conversion( + "astria.protocol.transaction.v1alpha1.TransactionFeeResponse", + Arc::new(e), + ) + })?; + Ok(native) + } + /// Submits the given transaction to the Sequencer node. /// /// This method blocks until the transaction is checked, but not until it's committed. diff --git a/crates/astria-sequencer-client/src/tests/http.rs b/crates/astria-sequencer-client/src/tests/http.rs index 30a9458f05..f3036c87cb 100644 --- a/crates/astria-sequencer-client/src/tests/http.rs +++ b/crates/astria-sequencer-client/src/tests/http.rs @@ -314,6 +314,38 @@ async fn get_bridge_account_last_transaction_hash() { assert_eq!(expected_response, actual_response); } +#[tokio::test] +async fn get_transaction_fee() { + use astria_core::generated::protocol::transaction::v1alpha1::{ + TransactionFee, + TransactionFeeResponse, + }; + + let MockSequencer { + server, + client, + } = MockSequencer::start().await; + + let expected_response = TransactionFeeResponse { + height: 10, + fees: vec![TransactionFee { + asset: "asset_0".to_string(), + fee: Some(100.into()), + }], + }; + + let _guard = + register_abci_query_response(&server, "transaction/fee", expected_response.clone()).await; + + let actual_response = client + .get_transaction_fee(create_signed_transaction().into_unsigned()) + .await + .unwrap() + .into_raw(); + + assert_eq!(expected_response, actual_response); +} + #[tokio::test] async fn submit_tx_sync() { let MockSequencer { diff --git a/crates/astria-sequencer/src/service/info/mod.rs b/crates/astria-sequencer/src/service/info/mod.rs index 2791d99af7..873383e715 100644 --- a/crates/astria-sequencer/src/service/info/mod.rs +++ b/crates/astria-sequencer/src/service/info/mod.rs @@ -70,6 +70,12 @@ impl Info { crate::bridge::query::bridge_account_last_tx_hash_request, ) .context("invalid path: `bridge/account_last_tx_hash/:address`")?; + query_router + .insert( + "transaction/fee", + crate::transaction::query::transaction_fee_request, + ) + .context("invalid path: `transaction/fee`")?; query_router .insert( "bridge/account_info/:address", diff --git a/crates/astria-sequencer/src/transaction/checks.rs b/crates/astria-sequencer/src/transaction/checks.rs index 01d3cbc1fc..6af1de5db7 100644 --- a/crates/astria-sequencer/src/transaction/checks.rs +++ b/crates/astria-sequencer/src/transaction/checks.rs @@ -57,17 +57,16 @@ pub(crate) async fn check_balance_mempool( state: &S, ) -> anyhow::Result<()> { let signer_address = crate::address::base_prefixed(tx.verification_key().address_bytes()); - check_balance_for_total_fees(tx.unsigned_transaction(), signer_address, state).await?; + check_balance_for_total_fees_and_transfers(tx.unsigned_transaction(), signer_address, state) + .await + .context("failed to check balance for total fees and transfers")?; Ok(()) } -// Checks that the account has enough balance to cover the total fees and transferred values -// for all actions in the transaction. -pub(crate) async fn check_balance_for_total_fees( +pub(crate) async fn get_fees_for_transaction( tx: &UnsignedTransaction, - from: Address, state: &S, -) -> anyhow::Result<()> { +) -> anyhow::Result> { let transfer_fee = state .get_transfer_base_fee() .await @@ -92,20 +91,14 @@ pub(crate) async fn check_balance_for_total_fees( let mut fees_by_asset = HashMap::new(); for action in &tx.actions { match action { - Action::Transfer(act) => transfer_update_fees( - &act.asset, - &act.fee_asset, - act.amount, - &mut fees_by_asset, - transfer_fee, - ), + Action::Transfer(act) => { + transfer_update_fees(&act.fee_asset, &mut fees_by_asset, transfer_fee); + } Action::Sequence(act) => { sequence_update_fees(state, &act.fee_asset, &mut fees_by_asset, &act.data).await?; } Action::Ics20Withdrawal(act) => ics20_withdrawal_updates_fees( - &act.denom, &act.fee_asset, - act.amount(), &mut fees_by_asset, ics20_withdrawal_fee, ), @@ -122,15 +115,7 @@ pub(crate) async fn check_balance_for_total_fees( bridge_lock_byte_cost_multiplier, ), Action::BridgeUnlock(act) => { - bridge_unlock_update_fees( - state, - act.bridge_address.unwrap_or(from), - act.amount, - &act.fee_asset, - &mut fees_by_asset, - transfer_fee, - ) - .await?; + bridge_unlock_update_fees(&act.fee_asset, &mut fees_by_asset, transfer_fee); } Action::BridgeSudoChange(act) => { fees_by_asset @@ -148,7 +133,66 @@ pub(crate) async fn check_balance_for_total_fees( } } } - for (asset, total_fee) in fees_by_asset { + Ok(fees_by_asset) +} + +// Checks that the account has enough balance to cover the total fees and transferred values +// for all actions in the transaction. +pub(crate) async fn check_balance_for_total_fees_and_transfers( + tx: &UnsignedTransaction, + from: Address, + state: &S, +) -> anyhow::Result<()> { + let mut cost_by_asset = get_fees_for_transaction(tx, state) + .await + .context("failed to get fees for transaction")?; + + // add values transferred within the tx to the cost + for action in &tx.actions { + match action { + Action::Transfer(act) => { + cost_by_asset + .entry(act.asset.to_ibc_prefixed()) + .and_modify(|amt| *amt = amt.saturating_add(act.amount)) + .or_insert(act.amount); + } + Action::Ics20Withdrawal(act) => { + cost_by_asset + .entry(act.denom.to_ibc_prefixed()) + .and_modify(|amt| *amt = amt.saturating_add(act.amount)) + .or_insert(act.amount); + } + Action::BridgeLock(act) => { + cost_by_asset + .entry(act.asset.to_ibc_prefixed()) + .and_modify(|amt| *amt = amt.saturating_add(act.amount)) + .or_insert(act.amount); + } + Action::BridgeUnlock(act) => { + let asset = state + .get_bridge_account_ibc_asset(&from) + .await + .context("failed to get bridge account asset id")?; + cost_by_asset + .entry(asset) + .and_modify(|amt| *amt = amt.saturating_add(act.amount)) + .or_insert(act.amount); + } + Action::ValidatorUpdate(_) + | Action::SudoAddressChange(_) + | Action::Sequence(_) + | Action::InitBridgeAccount(_) + | Action::BridgeSudoChange(_) + | Action::Ibc(_) + | Action::IbcRelayerChange(_) + | Action::FeeAssetChange(_) + | Action::FeeChange(_) => { + continue; + } + } + } + + for (asset, total_fee) in cost_by_asset { let balance = state .get_account_balance(from, asset) .await @@ -164,16 +208,10 @@ pub(crate) async fn check_balance_for_total_fees( } fn transfer_update_fees( - asset: &asset::Denom, fee_asset: &asset::Denom, - amount: u128, fees_by_asset: &mut HashMap, transfer_fee: u128, ) { - fees_by_asset - .entry(asset.to_ibc_prefixed()) - .and_modify(|amt: &mut u128| *amt = amt.saturating_add(amount)) - .or_insert(amount); fees_by_asset .entry(fee_asset.to_ibc_prefixed()) .and_modify(|amt| *amt = amt.saturating_add(transfer_fee)) @@ -197,16 +235,10 @@ async fn sequence_update_fees( } fn ics20_withdrawal_updates_fees( - asset: &asset::Denom, fee_asset: &asset::Denom, - amount: u128, fees_by_asset: &mut HashMap, ics20_withdrawal_fee: u128, ) { - fees_by_asset - .entry(asset.to_ibc_prefixed()) - .and_modify(|amt| *amt = amt.saturating_add(amount)) - .or_insert(amount); fees_by_asset .entry(fee_asset.to_ibc_prefixed()) .and_modify(|amt| *amt = amt.saturating_add(ics20_withdrawal_fee)) @@ -233,37 +265,21 @@ fn bridge_lock_update_fees( .saturating_mul(bridge_lock_byte_cost_multiplier), ); - fees_by_asset - .entry(act.asset.to_ibc_prefixed()) - .and_modify(|amt: &mut u128| *amt = amt.saturating_add(act.amount)) - .or_insert(act.amount); fees_by_asset .entry(act.asset.to_ibc_prefixed()) .and_modify(|amt| *amt = amt.saturating_add(expected_deposit_fee)) .or_insert(expected_deposit_fee); } -async fn bridge_unlock_update_fees( - state: &S, - bridge_address: Address, - amount: u128, +fn bridge_unlock_update_fees( fee_asset: &asset::Denom, fees_by_asset: &mut HashMap, transfer_fee: u128, -) -> anyhow::Result<()> { - let asset = state - .get_bridge_account_ibc_asset(&bridge_address) - .await - .context("must be a bridge account for BridgeUnlock action")?; - fees_by_asset - .entry(asset) - .and_modify(|amt: &mut u128| *amt = amt.saturating_add(amount)) - .or_insert(amount); +) { fees_by_asset .entry(fee_asset.to_ibc_prefixed()) .and_modify(|amt| *amt = amt.saturating_add(transfer_fee)) .or_insert(transfer_fee); - Ok(()) } #[cfg(test)] @@ -418,9 +434,11 @@ mod tests { let signed_tx = tx.into_signed(&alice_signing_key); let err = check_balance_mempool(&signed_tx, &state_tx) .await - .expect_err("insufficient funds for `other` asset"); + .err() + .unwrap(); assert!( - err.to_string() + err.root_cause() + .to_string() .contains(&other_asset.to_ibc_prefixed().to_string()) ); } diff --git a/crates/astria-sequencer/src/transaction/mod.rs b/crates/astria-sequencer/src/transaction/mod.rs index 07a32a6c1e..d7e8e8e06c 100644 --- a/crates/astria-sequencer/src/transaction/mod.rs +++ b/crates/astria-sequencer/src/transaction/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod action_handler; mod checks; +pub(crate) mod query; use std::fmt; @@ -17,7 +18,7 @@ use astria_core::{ }, }; pub(crate) use checks::{ - check_balance_for_total_fees, + check_balance_for_total_fees_and_transfers, check_balance_mempool, check_chain_id_mempool, check_nonce_mempool, @@ -198,7 +199,9 @@ impl ActionHandler for UnsignedTransaction { ensure!(curr_nonce == self.nonce(), InvalidNonce(self.nonce())); // Should have enough balance to cover all actions. - check_balance_for_total_fees(self, from, state).await?; + check_balance_for_total_fees_and_transfers(self, from, state) + .await + .context("failed to check balance for total fees and transfers")?; for action in &self.actions { match action { diff --git a/crates/astria-sequencer/src/transaction/query.rs b/crates/astria-sequencer/src/transaction/query.rs new file mode 100644 index 0000000000..0bbc1233d2 --- /dev/null +++ b/crates/astria-sequencer/src/transaction/query.rs @@ -0,0 +1,132 @@ +use astria_core::{ + generated::protocol::transaction::v1alpha1::UnsignedTransaction as RawUnsignedTransaction, + protocol::{ + abci::AbciErrorCode, + transaction::v1alpha1::UnsignedTransaction, + }, +}; +use cnidarium::Storage; +use prost::Message as _; +use tendermint::abci::{ + request, + response, +}; + +use crate::{ + asset::state_ext::StateReadExt as _, + state_ext::StateReadExt as _, + transaction::checks::get_fees_for_transaction, +}; + +pub(crate) async fn transaction_fee_request( + storage: Storage, + request: request::Query, + _params: Vec<(String, String)>, +) -> response::Query { + use astria_core::protocol::transaction::v1alpha1::TransactionFeeResponse; + + let tx = match preprocess_request(&request) { + Ok(tx) => tx, + Err(err_rsp) => return err_rsp, + }; + + // use latest snapshot, as this is a query for a transaction fee + let snapshot = storage.latest_snapshot(); + 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 fees_with_ibc_denoms = match get_fees_for_transaction(&tx, &snapshot).await { + Ok(fees) => fees, + Err(err) => { + return response::Query { + code: AbciErrorCode::INTERNAL_ERROR.into(), + info: AbciErrorCode::INTERNAL_ERROR.to_string(), + log: format!("failed calculating fees for provided transaction: {err:#}"), + ..response::Query::default() + }; + } + }; + + let mut fees = Vec::with_capacity(fees_with_ibc_denoms.len()); + for (ibc_denom, value) in fees_with_ibc_denoms { + let trace_denom = match snapshot.map_ibc_to_trace_prefixed_asset(ibc_denom).await { + Ok(Some(trace_denom)) => trace_denom, + Ok(None) => { + return response::Query { + code: AbciErrorCode::INTERNAL_ERROR.into(), + info: AbciErrorCode::INTERNAL_ERROR.to_string(), + log: format!( + "failed mapping ibc denom to trace denom: {ibc_denom}; asset does not \ + exist in state" + ), + ..response::Query::default() + }; + } + Err(err) => { + return response::Query { + code: AbciErrorCode::INTERNAL_ERROR.into(), + info: AbciErrorCode::INTERNAL_ERROR.to_string(), + log: format!("failed mapping ibc denom to trace denom: {err:#}"), + ..response::Query::default() + }; + } + }; + fees.push((trace_denom.into(), value)); + } + + let resp = TransactionFeeResponse { + height, + fees, + }; + + 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.into_bytes().into(), + value: payload, + height, + ..response::Query::default() + } +} + +fn preprocess_request(request: &request::Query) -> Result { + let tx = match RawUnsignedTransaction::decode(&*request.data) { + Ok(tx) => tx, + Err(err) => { + return Err(response::Query { + code: AbciErrorCode::BAD_REQUEST.into(), + info: AbciErrorCode::BAD_REQUEST.to_string(), + log: format!("failed to decode request data to unsigned transaction: {err:#}"), + ..response::Query::default() + }); + } + }; + + let tx = match UnsignedTransaction::try_from_raw(tx) { + Ok(tx) => tx, + Err(err) => { + return Err(response::Query { + code: AbciErrorCode::BAD_REQUEST.into(), + info: AbciErrorCode::BAD_REQUEST.to_string(), + log: format!( + "failed to convert raw proto unsigned transaction to native unsigned \ + transaction: {err:#}" + ), + ..response::Query::default() + }); + } + }; + + Ok(tx) +} diff --git a/proto/protocolapis/astria/protocol/transactions/v1alpha1/types.proto b/proto/protocolapis/astria/protocol/transactions/v1alpha1/types.proto index e5f5142248..406d5424b8 100644 --- a/proto/protocolapis/astria/protocol/transactions/v1alpha1/types.proto +++ b/proto/protocolapis/astria/protocol/transactions/v1alpha1/types.proto @@ -246,3 +246,14 @@ message FeeChangeAction { astria.primitive.v1.Uint128 ics20_withdrawal_base_fee = 40; } } + +// Response to a transaction fee ABCI query. +message TransactionFeeResponse { + uint64 height = 2; + repeated TransactionFee fees = 3; +} + +message TransactionFee { + string asset = 1; + astria.primitive.v1.Uint128 fee = 2; +}