diff --git a/api/src/common/send.rs b/api/src/common/send.rs index 296cb53b..e03c6afb 100644 --- a/api/src/common/send.rs +++ b/api/src/common/send.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::sync::Arc; use near_openapi_client::types::{ @@ -11,7 +12,7 @@ use near_api_types::{ transaction::{ PrepopulateTransaction, SignedTransaction, delegate_action::{SignedDelegateAction, SignedDelegateActionAsBase64}, - result::ExecutionFinalResult, + result::{ExecutionFinalResult, TransactionResult}, }, }; use reqwest::Response; @@ -32,6 +33,35 @@ use super::META_TRANSACTION_VALID_FOR_DEFAULT; const TX_EXECUTOR_TARGET: &str = "near_api::tx::executor"; const META_EXECUTOR_TARGET: &str = "near_api::meta::executor"; +/// Internal enum to distinguish between a full RPC response and a minimal pending response. +enum SendImplResponse { + Full(Box), + Pending(TxExecutionStatus), +} + +impl fmt::Debug for SendImplResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Full(_) => write!(f, "Full(...)"), + Self::Pending(status) => write!(f, "Pending({status:?})"), + } + } +} + +/// Minimal JSON-RPC response returned when `wait_until` is `NONE` or `INCLUDED`. +/// +/// The RPC returns only `{"jsonrpc":"2.0","result":{"final_execution_status":"..."},"id":"0"}` +/// which doesn't match the full `RpcTransactionResponse` schema. +#[derive(serde::Deserialize)] +struct MinimalTransactionResponse { + result: MinimalTransactionResult, +} + +#[derive(serde::Deserialize)] +struct MinimalTransactionResult { + final_execution_status: TxExecutionStatus, +} + #[async_trait::async_trait] pub trait Transactionable: Send + Sync { fn prepopulated(&self) -> Result; @@ -191,10 +221,14 @@ impl ExecuteSignedTransaction { /// /// This is useful if you want to send the transaction to a non-default network configuration (e.g, custom RPC URL, sandbox). /// Please note that if the transaction is not presigned, it will be signed with the network's nonce and block hash. + /// + /// Returns a [`TransactionResult`] which is either: + /// - [`TransactionResult::Pending`] if `wait_until` is `None` or `Included` (no execution data available yet) + /// - [`TransactionResult::Full`] for higher finality levels with full execution results pub async fn send_to( mut self, network: &NetworkConfig, - ) -> Result { + ) -> Result { let (signed, transactionable) = match &mut self.transaction { TransactionableOrSigned::Transactionable(transaction) => { debug!(target: TX_EXECUTOR_TARGET, "Preparing unsigned transaction"); @@ -243,7 +277,7 @@ impl ExecuteSignedTransaction { /// Sends the transaction to the default mainnet configuration. /// /// Please note that this will sign the transaction with the mainnet's nonce and block hash if it's not presigned yet. - pub async fn send_to_mainnet(self) -> Result { + pub async fn send_to_mainnet(self) -> Result { let network = NetworkConfig::mainnet(); self.send_to(&network).await } @@ -251,7 +285,7 @@ impl ExecuteSignedTransaction { /// Sends the transaction to the default testnet configuration. /// /// Please note that this will sign the transaction with the testnet's nonce and block hash if it's not presigned yet. - pub async fn send_to_testnet(self) -> Result { + pub async fn send_to_testnet(self) -> Result { let network = NetworkConfig::testnet(); self.send_to(&network).await } @@ -260,7 +294,7 @@ impl ExecuteSignedTransaction { network: &NetworkConfig, signed_tr: SignedTransaction, wait_until: TxExecutionStatus, - ) -> Result { + ) -> Result { let hash = signed_tr.get_hash(); let signed_tx_base64: near_openapi_client::types::SignedTransaction = signed_tr.into(); let result = retry(network.clone(), |client| { @@ -285,7 +319,7 @@ impl ExecuteSignedTransaction { result, .. }, - ) => RetryResponse::Ok(result), + ) => RetryResponse::Ok(SendImplResponse::Full(Box::new(result))), Ok( JsonRpcResponseForRpcTransactionResponseAndRpcTransactionError::Variant1 { error, @@ -296,7 +330,35 @@ impl ExecuteSignedTransaction { SendRequestError::from(error); to_retry_error(error, is_critical_transaction_error) } - Err(err) => to_retry_error(err, is_critical_transaction_error), + Err(err) => { + // When wait_until is NONE or INCLUDED, the RPC returns a minimal + // response with only `final_execution_status`. The openapi client + // fails to deserialize this into RpcTransactionResponse (which + // expects full execution data) and returns InvalidResponsePayload. + // We intercept this case and parse the minimal response ourselves. + // + // We only attempt this fallback when we explicitly requested a + // minimal response, so unexpected/buggy RPC responses for higher + // finality levels don't get silently treated as Pending. + if matches!( + wait_until, + TxExecutionStatus::None | TxExecutionStatus::Included + ) { + if let SendRequestError::TransportError( + near_openapi_client::Error::InvalidResponsePayload(ref bytes, _), + ) = err + { + if let Ok(minimal) = + serde_json::from_slice::(bytes) + { + return RetryResponse::Ok(SendImplResponse::Pending( + minimal.result.final_execution_status, + )); + } + } + } + to_retry_error(err, is_critical_transaction_error) + } }; tracing::debug!( @@ -312,39 +374,43 @@ impl ExecuteSignedTransaction { .await .map_err(ExecuteTransactionError::TransactionError)?; - // TODO: check if we need to add support for that final_execution_status - let final_execution_outcome_view = match result { - // We don't use `experimental_tx`, so we can ignore that, but just to be safe - RpcTransactionResponse::Variant0 { - final_execution_status: _, - receipts: _, - receipts_outcome, - status, - transaction, - transaction_outcome, - } => FinalExecutionOutcomeView { - receipts_outcome, - status, - transaction, - transaction_outcome, - }, - RpcTransactionResponse::Variant1 { - final_execution_status: _, - receipts_outcome, - status, - transaction, - transaction_outcome, - } => FinalExecutionOutcomeView { - receipts_outcome, - status, - transaction, - transaction_outcome, - }, - }; + match result { + SendImplResponse::Pending(status) => Ok(TransactionResult::Pending { status }), + SendImplResponse::Full(rpc_response) => { + let final_execution_outcome_view = match *rpc_response { + // We don't use `experimental_tx`, so we can ignore that, but just to be safe + RpcTransactionResponse::Variant0 { + final_execution_status: _, + receipts: _, + receipts_outcome, + status, + transaction, + transaction_outcome, + } => FinalExecutionOutcomeView { + receipts_outcome, + status, + transaction, + transaction_outcome, + }, + RpcTransactionResponse::Variant1 { + final_execution_status: _, + receipts_outcome, + status, + transaction, + transaction_outcome, + } => FinalExecutionOutcomeView { + receipts_outcome, + status, + transaction, + transaction_outcome, + }, + }; - Ok(ExecutionFinalResult::try_from( - final_execution_outcome_view, - )?) + Ok(TransactionResult::Full(Box::new( + ExecutionFinalResult::try_from(final_execution_outcome_view)?, + ))) + } + } } } diff --git a/types/src/transaction/result.rs b/types/src/transaction/result.rs index 7205117a..e83edac4 100644 --- a/types/src/transaction/result.rs +++ b/types/src/transaction/result.rs @@ -6,7 +6,7 @@ use base64::{Engine as _, engine::general_purpose}; use borsh; use near_openapi_types::{ CallResult, ExecutionStatusView, FinalExecutionOutcomeView, FinalExecutionStatus, - TxExecutionError, + TxExecutionError, TxExecutionStatus, }; use crate::{ @@ -355,6 +355,164 @@ impl ExecutionFinalResult { } } +/// The result of sending a transaction to the network. +/// +/// Depending on the [`TxExecutionStatus`] used with `wait_until`, the RPC may return +/// either a full execution result or just a confirmation that the transaction was received. +/// +/// - `wait_until(TxExecutionStatus::None)` or `wait_until(TxExecutionStatus::Included)` will +/// return [`TransactionResult::Pending`] since the transaction hasn't been executed yet. +/// - Higher finality levels (`ExecutedOptimistic`, `Final`, etc.) will return +/// [`TransactionResult::Full`] with the full execution outcome. +#[derive(Clone, Debug)] +#[must_use = "use `into_result()` to handle potential execution errors and cases when transaction is pending"] +pub enum TransactionResult { + /// Transaction was submitted but execution results are not yet available. + /// + /// This is returned when `wait_until` is set to `None` or `Included`. + /// The `status` field indicates how far the transaction has progressed. + Pending { status: TxExecutionStatus }, + /// Full execution result is available. + Full(Box), +} + +impl TransactionResult { + /// Returns the full execution result if available, or an error if the transaction is still pending. + #[allow(clippy::result_large_err)] + pub fn into_result(self) -> Result { + match self { + Self::Full(result) => result + .into_result() + .map_err(|e| TransactionResultError::Failure(Box::new(e))), + Self::Pending { status } => Err(TransactionResultError::Pending(status)), + } + } + + /// Unwraps the full execution result, panicking if the transaction is pending or failed. + #[track_caller] + pub fn assert_success(self) -> ExecutionSuccess { + match self { + Self::Full(result) => result.assert_success(), + Self::Pending { status } => panic!( + "called `assert_success()` on a pending transaction (status: {status:?}). \ + Use wait_until(TxExecutionStatus::Final) or handle the pending case." + ), + } + } + + /// Returns `true` if the transaction has a full execution result. + pub const fn is_full(&self) -> bool { + matches!(self, Self::Full(_)) + } + + /// Returns `true` if the transaction is still pending. + pub const fn is_pending(&self) -> bool { + matches!(self, Self::Pending { .. }) + } + + /// Returns the full execution result, if available. + pub fn into_full(self) -> Option { + match self { + Self::Full(result) => Some(*result), + Self::Pending { .. } => None, + } + } + + /// Returns the pending status, if the transaction is still pending. + pub fn pending_status(self) -> Option { + match self { + Self::Pending { status } => Some(status), + Self::Full(_) => None, + } + } + + /// Unwraps the execution failure, panicking if the transaction is pending or succeeded. + #[track_caller] + pub fn assert_failure(self) -> ExecutionResult { + match self { + Self::Full(result) => result.assert_failure(), + Self::Pending { status } => panic!( + "called `assert_failure()` on a pending transaction (status: {status:?}). \ + Use wait_until(TxExecutionStatus::Final) or handle the pending case." + ), + } + } + + /// Checks whether the transaction has failed. Returns `false` if the transaction + /// is still pending or succeeded. + pub const fn is_failure(&self) -> bool { + match self { + Self::Full(result) => result.is_failure(), + Self::Pending { .. } => false, + } + } + + /// Checks whether the transaction was successful. Returns `false` if the transaction + /// is still pending or failed. + pub const fn is_success(&self) -> bool { + match self { + Self::Full(result) => result.is_success(), + Self::Pending { .. } => false, + } + } + + /// Returns the transaction that was executed. + /// + /// # Panics + /// + /// Panics if the transaction is still pending. + #[track_caller] + pub fn transaction(&self) -> &Transaction { + match self { + Self::Full(result) => result.transaction(), + Self::Pending { status } => panic!( + "called `transaction()` on a pending transaction (status: {status:?}). \ + Use wait_until(TxExecutionStatus::Final) or handle the pending case." + ), + } + } + + /// Grab all logs from both the transaction and receipt outcomes. + /// + /// # Panics + /// + /// Panics if the transaction is still pending. + #[track_caller] + pub fn logs(&self) -> Vec<&str> { + match self { + Self::Full(result) => result.logs(), + Self::Pending { status } => panic!( + "called `logs()` on a pending transaction (status: {status:?}). \ + Use wait_until(TxExecutionStatus::Final) or handle the pending case." + ), + } + } +} + +/// Error type for [`TransactionResult::into_result`]. +#[derive(Debug)] +pub enum TransactionResultError { + /// The transaction failed execution. + Failure(Box), + /// The transaction is still pending (was sent with `wait_until` set to `None` or `Included`). + Pending(TxExecutionStatus), +} + +impl fmt::Display for TransactionResultError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failure(err) => write!(f, "Transaction failed: {err}"), + Self::Pending(status) => write!( + f, + "Transaction is pending (status: {status:?}). \ + Execution results are not yet available." + ), + } + } +} + +impl std::error::Error for TransactionResultError {} + impl ExecutionSuccess { /// Deserialize an instance of type `T` from bytes of JSON text sourced from the /// execution result of this call. This conversion can fail if the structure of