Skip to content
Merged
144 changes: 105 additions & 39 deletions api/src/common/send.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::fmt;
use std::sync::Arc;

use near_openapi_client::types::{
Expand All @@ -11,7 +12,7 @@ use near_api_types::{
transaction::{
PrepopulateTransaction, SignedTransaction,
delegate_action::{SignedDelegateAction, SignedDelegateActionAsBase64},
result::ExecutionFinalResult,
result::{ExecutionFinalResult, TransactionResult},
},
};
use reqwest::Response;
Expand All @@ -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<RpcTransactionResponse>),
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<PrepopulateTransaction, ArgumentValidationError>;
Expand Down Expand Up @@ -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<ExecutionFinalResult, ExecuteTransactionError> {
) -> Result<TransactionResult, ExecuteTransactionError> {
let (signed, transactionable) = match &mut self.transaction {
TransactionableOrSigned::Transactionable(transaction) => {
debug!(target: TX_EXECUTOR_TARGET, "Preparing unsigned transaction");
Expand Down Expand Up @@ -243,15 +277,15 @@ 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<ExecutionFinalResult, ExecuteTransactionError> {
pub async fn send_to_mainnet(self) -> Result<TransactionResult, ExecuteTransactionError> {
let network = NetworkConfig::mainnet();
self.send_to(&network).await
}

/// 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<ExecutionFinalResult, ExecuteTransactionError> {
pub async fn send_to_testnet(self) -> Result<TransactionResult, ExecuteTransactionError> {
let network = NetworkConfig::testnet();
self.send_to(&network).await
}
Expand All @@ -260,7 +294,7 @@ impl ExecuteSignedTransaction {
network: &NetworkConfig,
signed_tr: SignedTransaction,
wait_until: TxExecutionStatus,
) -> Result<ExecutionFinalResult, ExecuteTransactionError> {
) -> Result<TransactionResult, ExecuteTransactionError> {
let hash = signed_tr.get_hash();
let signed_tx_base64: near_openapi_client::types::SignedTransaction = signed_tr.into();
let result = retry(network.clone(), |client| {
Expand All @@ -285,7 +319,7 @@ impl ExecuteSignedTransaction {
result,
..
},
) => RetryResponse::Ok(result),
) => RetryResponse::Ok(SendImplResponse::Full(Box::new(result))),
Ok(
JsonRpcResponseForRpcTransactionResponseAndRpcTransactionError::Variant1 {
error,
Expand All @@ -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::<MinimalTransactionResponse>(bytes)
{
return RetryResponse::Ok(SendImplResponse::Pending(
minimal.result.final_execution_status,
));
}
}
}
to_retry_error(err, is_critical_transaction_error)
}
};

tracing::debug!(
Expand All @@ -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)?,
)))
}
}
}
}

Expand Down
160 changes: 159 additions & 1 deletion types/src/transaction/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<ExecutionFinalResult>),
}

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<ExecutionSuccess, TransactionResultError> {
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<ExecutionFinalResult> {
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<TxExecutionStatus> {
match self {
Self::Pending { status } => Some(status),
Self::Full(_) => None,
}
}
Comment on lines +421 to +427
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Returns the pending status, if the transaction is still pending.
pub const fn pending_status(&self) -> Option<&TxExecutionStatus> {
match self {
Self::Pending { status } => Some(status),
Self::Final(_) => None,
}
}
/// Returns the pending status, if the transaction is still pending.
pub const fn pending_status(self) -> Option<TxExecutionStatus> {
match self {
Self::Pending { status } => Some(status),
Self::Final(_) => None,
}
}

I think this function should work without ref to TxExecutionStatus? Or maybe there is some other reason to have ref here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Fixed it: 2b6b76a (this PR)


/// Unwraps the execution failure, panicking if the transaction is pending or succeeded.
#[track_caller]
pub fn assert_failure(self) -> ExecutionResult<TxExecutionError> {
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<ExecutionFailure>),
/// 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
Expand Down