Skip to content

Commit 95b6b3a

Browse files
authored
feat: handle minimal tx response for NONE/INCLUDED wait_until (#128)
1 parent 17f9c3c commit 95b6b3a

File tree

2 files changed

+264
-40
lines changed

2 files changed

+264
-40
lines changed

api/src/common/send.rs

Lines changed: 105 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::fmt;
12
use std::sync::Arc;
23

34
use near_openapi_client::types::{
@@ -11,7 +12,7 @@ use near_api_types::{
1112
transaction::{
1213
PrepopulateTransaction, SignedTransaction,
1314
delegate_action::{SignedDelegateAction, SignedDelegateActionAsBase64},
14-
result::ExecutionFinalResult,
15+
result::{ExecutionFinalResult, TransactionResult},
1516
},
1617
};
1718
use reqwest::Response;
@@ -32,6 +33,35 @@ use super::META_TRANSACTION_VALID_FOR_DEFAULT;
3233
const TX_EXECUTOR_TARGET: &str = "near_api::tx::executor";
3334
const META_EXECUTOR_TARGET: &str = "near_api::meta::executor";
3435

36+
/// Internal enum to distinguish between a full RPC response and a minimal pending response.
37+
enum SendImplResponse {
38+
Full(Box<RpcTransactionResponse>),
39+
Pending(TxExecutionStatus),
40+
}
41+
42+
impl fmt::Debug for SendImplResponse {
43+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44+
match self {
45+
Self::Full(_) => write!(f, "Full(...)"),
46+
Self::Pending(status) => write!(f, "Pending({status:?})"),
47+
}
48+
}
49+
}
50+
51+
/// Minimal JSON-RPC response returned when `wait_until` is `NONE` or `INCLUDED`.
52+
///
53+
/// The RPC returns only `{"jsonrpc":"2.0","result":{"final_execution_status":"..."},"id":"0"}`
54+
/// which doesn't match the full `RpcTransactionResponse` schema.
55+
#[derive(serde::Deserialize)]
56+
struct MinimalTransactionResponse {
57+
result: MinimalTransactionResult,
58+
}
59+
60+
#[derive(serde::Deserialize)]
61+
struct MinimalTransactionResult {
62+
final_execution_status: TxExecutionStatus,
63+
}
64+
3565
#[async_trait::async_trait]
3666
pub trait Transactionable: Send + Sync {
3767
fn prepopulated(&self) -> Result<PrepopulateTransaction, ArgumentValidationError>;
@@ -191,10 +221,14 @@ impl ExecuteSignedTransaction {
191221
///
192222
/// This is useful if you want to send the transaction to a non-default network configuration (e.g, custom RPC URL, sandbox).
193223
/// Please note that if the transaction is not presigned, it will be signed with the network's nonce and block hash.
224+
///
225+
/// Returns a [`TransactionResult`] which is either:
226+
/// - [`TransactionResult::Pending`] if `wait_until` is `None` or `Included` (no execution data available yet)
227+
/// - [`TransactionResult::Full`] for higher finality levels with full execution results
194228
pub async fn send_to(
195229
mut self,
196230
network: &NetworkConfig,
197-
) -> Result<ExecutionFinalResult, ExecuteTransactionError> {
231+
) -> Result<TransactionResult, ExecuteTransactionError> {
198232
let (signed, transactionable) = match &mut self.transaction {
199233
TransactionableOrSigned::Transactionable(transaction) => {
200234
debug!(target: TX_EXECUTOR_TARGET, "Preparing unsigned transaction");
@@ -243,15 +277,15 @@ impl ExecuteSignedTransaction {
243277
/// Sends the transaction to the default mainnet configuration.
244278
///
245279
/// Please note that this will sign the transaction with the mainnet's nonce and block hash if it's not presigned yet.
246-
pub async fn send_to_mainnet(self) -> Result<ExecutionFinalResult, ExecuteTransactionError> {
280+
pub async fn send_to_mainnet(self) -> Result<TransactionResult, ExecuteTransactionError> {
247281
let network = NetworkConfig::mainnet();
248282
self.send_to(&network).await
249283
}
250284

251285
/// Sends the transaction to the default testnet configuration.
252286
///
253287
/// Please note that this will sign the transaction with the testnet's nonce and block hash if it's not presigned yet.
254-
pub async fn send_to_testnet(self) -> Result<ExecutionFinalResult, ExecuteTransactionError> {
288+
pub async fn send_to_testnet(self) -> Result<TransactionResult, ExecuteTransactionError> {
255289
let network = NetworkConfig::testnet();
256290
self.send_to(&network).await
257291
}
@@ -260,7 +294,7 @@ impl ExecuteSignedTransaction {
260294
network: &NetworkConfig,
261295
signed_tr: SignedTransaction,
262296
wait_until: TxExecutionStatus,
263-
) -> Result<ExecutionFinalResult, ExecuteTransactionError> {
297+
) -> Result<TransactionResult, ExecuteTransactionError> {
264298
let hash = signed_tr.get_hash();
265299
let signed_tx_base64: near_openapi_client::types::SignedTransaction = signed_tr.into();
266300
let result = retry(network.clone(), |client| {
@@ -285,7 +319,7 @@ impl ExecuteSignedTransaction {
285319
result,
286320
..
287321
},
288-
) => RetryResponse::Ok(result),
322+
) => RetryResponse::Ok(SendImplResponse::Full(Box::new(result))),
289323
Ok(
290324
JsonRpcResponseForRpcTransactionResponseAndRpcTransactionError::Variant1 {
291325
error,
@@ -296,7 +330,35 @@ impl ExecuteSignedTransaction {
296330
SendRequestError::from(error);
297331
to_retry_error(error, is_critical_transaction_error)
298332
}
299-
Err(err) => to_retry_error(err, is_critical_transaction_error),
333+
Err(err) => {
334+
// When wait_until is NONE or INCLUDED, the RPC returns a minimal
335+
// response with only `final_execution_status`. The openapi client
336+
// fails to deserialize this into RpcTransactionResponse (which
337+
// expects full execution data) and returns InvalidResponsePayload.
338+
// We intercept this case and parse the minimal response ourselves.
339+
//
340+
// We only attempt this fallback when we explicitly requested a
341+
// minimal response, so unexpected/buggy RPC responses for higher
342+
// finality levels don't get silently treated as Pending.
343+
if matches!(
344+
wait_until,
345+
TxExecutionStatus::None | TxExecutionStatus::Included
346+
) {
347+
if let SendRequestError::TransportError(
348+
near_openapi_client::Error::InvalidResponsePayload(ref bytes, _),
349+
) = err
350+
{
351+
if let Ok(minimal) =
352+
serde_json::from_slice::<MinimalTransactionResponse>(bytes)
353+
{
354+
return RetryResponse::Ok(SendImplResponse::Pending(
355+
minimal.result.final_execution_status,
356+
));
357+
}
358+
}
359+
}
360+
to_retry_error(err, is_critical_transaction_error)
361+
}
300362
};
301363

302364
tracing::debug!(
@@ -312,39 +374,43 @@ impl ExecuteSignedTransaction {
312374
.await
313375
.map_err(ExecuteTransactionError::TransactionError)?;
314376

315-
// TODO: check if we need to add support for that final_execution_status
316-
let final_execution_outcome_view = match result {
317-
// We don't use `experimental_tx`, so we can ignore that, but just to be safe
318-
RpcTransactionResponse::Variant0 {
319-
final_execution_status: _,
320-
receipts: _,
321-
receipts_outcome,
322-
status,
323-
transaction,
324-
transaction_outcome,
325-
} => FinalExecutionOutcomeView {
326-
receipts_outcome,
327-
status,
328-
transaction,
329-
transaction_outcome,
330-
},
331-
RpcTransactionResponse::Variant1 {
332-
final_execution_status: _,
333-
receipts_outcome,
334-
status,
335-
transaction,
336-
transaction_outcome,
337-
} => FinalExecutionOutcomeView {
338-
receipts_outcome,
339-
status,
340-
transaction,
341-
transaction_outcome,
342-
},
343-
};
377+
match result {
378+
SendImplResponse::Pending(status) => Ok(TransactionResult::Pending { status }),
379+
SendImplResponse::Full(rpc_response) => {
380+
let final_execution_outcome_view = match *rpc_response {
381+
// We don't use `experimental_tx`, so we can ignore that, but just to be safe
382+
RpcTransactionResponse::Variant0 {
383+
final_execution_status: _,
384+
receipts: _,
385+
receipts_outcome,
386+
status,
387+
transaction,
388+
transaction_outcome,
389+
} => FinalExecutionOutcomeView {
390+
receipts_outcome,
391+
status,
392+
transaction,
393+
transaction_outcome,
394+
},
395+
RpcTransactionResponse::Variant1 {
396+
final_execution_status: _,
397+
receipts_outcome,
398+
status,
399+
transaction,
400+
transaction_outcome,
401+
} => FinalExecutionOutcomeView {
402+
receipts_outcome,
403+
status,
404+
transaction,
405+
transaction_outcome,
406+
},
407+
};
344408

345-
Ok(ExecutionFinalResult::try_from(
346-
final_execution_outcome_view,
347-
)?)
409+
Ok(TransactionResult::Full(Box::new(
410+
ExecutionFinalResult::try_from(final_execution_outcome_view)?,
411+
)))
412+
}
413+
}
348414
}
349415
}
350416

types/src/transaction/result.rs

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use base64::{Engine as _, engine::general_purpose};
66
use borsh;
77
use near_openapi_types::{
88
CallResult, ExecutionStatusView, FinalExecutionOutcomeView, FinalExecutionStatus,
9-
TxExecutionError,
9+
TxExecutionError, TxExecutionStatus,
1010
};
1111

1212
use crate::{
@@ -355,6 +355,164 @@ impl ExecutionFinalResult {
355355
}
356356
}
357357

358+
/// The result of sending a transaction to the network.
359+
///
360+
/// Depending on the [`TxExecutionStatus`] used with `wait_until`, the RPC may return
361+
/// either a full execution result or just a confirmation that the transaction was received.
362+
///
363+
/// - `wait_until(TxExecutionStatus::None)` or `wait_until(TxExecutionStatus::Included)` will
364+
/// return [`TransactionResult::Pending`] since the transaction hasn't been executed yet.
365+
/// - Higher finality levels (`ExecutedOptimistic`, `Final`, etc.) will return
366+
/// [`TransactionResult::Full`] with the full execution outcome.
367+
#[derive(Clone, Debug)]
368+
#[must_use = "use `into_result()` to handle potential execution errors and cases when transaction is pending"]
369+
pub enum TransactionResult {
370+
/// Transaction was submitted but execution results are not yet available.
371+
///
372+
/// This is returned when `wait_until` is set to `None` or `Included`.
373+
/// The `status` field indicates how far the transaction has progressed.
374+
Pending { status: TxExecutionStatus },
375+
/// Full execution result is available.
376+
Full(Box<ExecutionFinalResult>),
377+
}
378+
379+
impl TransactionResult {
380+
/// Returns the full execution result if available, or an error if the transaction is still pending.
381+
#[allow(clippy::result_large_err)]
382+
pub fn into_result(self) -> Result<ExecutionSuccess, TransactionResultError> {
383+
match self {
384+
Self::Full(result) => result
385+
.into_result()
386+
.map_err(|e| TransactionResultError::Failure(Box::new(e))),
387+
Self::Pending { status } => Err(TransactionResultError::Pending(status)),
388+
}
389+
}
390+
391+
/// Unwraps the full execution result, panicking if the transaction is pending or failed.
392+
#[track_caller]
393+
pub fn assert_success(self) -> ExecutionSuccess {
394+
match self {
395+
Self::Full(result) => result.assert_success(),
396+
Self::Pending { status } => panic!(
397+
"called `assert_success()` on a pending transaction (status: {status:?}). \
398+
Use wait_until(TxExecutionStatus::Final) or handle the pending case."
399+
),
400+
}
401+
}
402+
403+
/// Returns `true` if the transaction has a full execution result.
404+
pub const fn is_full(&self) -> bool {
405+
matches!(self, Self::Full(_))
406+
}
407+
408+
/// Returns `true` if the transaction is still pending.
409+
pub const fn is_pending(&self) -> bool {
410+
matches!(self, Self::Pending { .. })
411+
}
412+
413+
/// Returns the full execution result, if available.
414+
pub fn into_full(self) -> Option<ExecutionFinalResult> {
415+
match self {
416+
Self::Full(result) => Some(*result),
417+
Self::Pending { .. } => None,
418+
}
419+
}
420+
421+
/// Returns the pending status, if the transaction is still pending.
422+
pub fn pending_status(self) -> Option<TxExecutionStatus> {
423+
match self {
424+
Self::Pending { status } => Some(status),
425+
Self::Full(_) => None,
426+
}
427+
}
428+
429+
/// Unwraps the execution failure, panicking if the transaction is pending or succeeded.
430+
#[track_caller]
431+
pub fn assert_failure(self) -> ExecutionResult<TxExecutionError> {
432+
match self {
433+
Self::Full(result) => result.assert_failure(),
434+
Self::Pending { status } => panic!(
435+
"called `assert_failure()` on a pending transaction (status: {status:?}). \
436+
Use wait_until(TxExecutionStatus::Final) or handle the pending case."
437+
),
438+
}
439+
}
440+
441+
/// Checks whether the transaction has failed. Returns `false` if the transaction
442+
/// is still pending or succeeded.
443+
pub const fn is_failure(&self) -> bool {
444+
match self {
445+
Self::Full(result) => result.is_failure(),
446+
Self::Pending { .. } => false,
447+
}
448+
}
449+
450+
/// Checks whether the transaction was successful. Returns `false` if the transaction
451+
/// is still pending or failed.
452+
pub const fn is_success(&self) -> bool {
453+
match self {
454+
Self::Full(result) => result.is_success(),
455+
Self::Pending { .. } => false,
456+
}
457+
}
458+
459+
/// Returns the transaction that was executed.
460+
///
461+
/// # Panics
462+
///
463+
/// Panics if the transaction is still pending.
464+
#[track_caller]
465+
pub fn transaction(&self) -> &Transaction {
466+
match self {
467+
Self::Full(result) => result.transaction(),
468+
Self::Pending { status } => panic!(
469+
"called `transaction()` on a pending transaction (status: {status:?}). \
470+
Use wait_until(TxExecutionStatus::Final) or handle the pending case."
471+
),
472+
}
473+
}
474+
475+
/// Grab all logs from both the transaction and receipt outcomes.
476+
///
477+
/// # Panics
478+
///
479+
/// Panics if the transaction is still pending.
480+
#[track_caller]
481+
pub fn logs(&self) -> Vec<&str> {
482+
match self {
483+
Self::Full(result) => result.logs(),
484+
Self::Pending { status } => panic!(
485+
"called `logs()` on a pending transaction (status: {status:?}). \
486+
Use wait_until(TxExecutionStatus::Final) or handle the pending case."
487+
),
488+
}
489+
}
490+
}
491+
492+
/// Error type for [`TransactionResult::into_result`].
493+
#[derive(Debug)]
494+
pub enum TransactionResultError {
495+
/// The transaction failed execution.
496+
Failure(Box<ExecutionFailure>),
497+
/// The transaction is still pending (was sent with `wait_until` set to `None` or `Included`).
498+
Pending(TxExecutionStatus),
499+
}
500+
501+
impl fmt::Display for TransactionResultError {
502+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503+
match self {
504+
Self::Failure(err) => write!(f, "Transaction failed: {err}"),
505+
Self::Pending(status) => write!(
506+
f,
507+
"Transaction is pending (status: {status:?}). \
508+
Execution results are not yet available."
509+
),
510+
}
511+
}
512+
}
513+
514+
impl std::error::Error for TransactionResultError {}
515+
358516
impl ExecutionSuccess {
359517
/// Deserialize an instance of type `T` from bytes of JSON text sourced from the
360518
/// execution result of this call. This conversion can fail if the structure of

0 commit comments

Comments
 (0)