sui_rpc/client/transaction_execution.rs
1use super::Client;
2use crate::field::FieldMaskUtil;
3use crate::proto::TryFromProtoError;
4use crate::proto::sui::rpc::v2::ExecuteTransactionRequest;
5use crate::proto::sui::rpc::v2::ExecuteTransactionResponse;
6use crate::proto::sui::rpc::v2::ExecutionError;
7use crate::proto::sui::rpc::v2::GetEpochRequest;
8use crate::proto::sui::rpc::v2::GetTransactionRequest;
9use crate::proto::sui::rpc::v2::GetTransactionResponse;
10use crate::proto::sui::rpc::v2::SubscribeCheckpointsRequest;
11use futures::TryStreamExt;
12use prost_types::FieldMask;
13use std::fmt;
14use std::time::Duration;
15use tonic::Response;
16
17/// Error types that can occur when executing a transaction and waiting for checkpoint
18#[derive(Debug)]
19#[non_exhaustive]
20pub enum ExecuteAndWaitError {
21 /// RPC Error (actual tonic::Status from the client/server)
22 RpcError(tonic::Status),
23 /// Request is missing the required transaction field
24 MissingTransaction,
25 /// Failed to parse/convert the transaction for digest calculation
26 ProtoConversionError(TryFromProtoError),
27 /// Transaction executed but checkpoint wait timed out
28 CheckpointTimeout(Response<ExecuteTransactionResponse>),
29 /// Transaction executed but checkpoint stream had an error
30 CheckpointStreamError {
31 response: Response<ExecuteTransactionResponse>,
32 error: tonic::Status,
33 },
34}
35
36impl std::fmt::Display for ExecuteAndWaitError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::RpcError(status) => write!(f, "RPC error: {status}"),
40 Self::MissingTransaction => {
41 write!(f, "Request is missing the required transaction field")
42 }
43 Self::ProtoConversionError(e) => write!(f, "Failed to convert transaction: {e}"),
44 Self::CheckpointTimeout(_) => {
45 write!(f, "Transaction executed but checkpoint wait timed out")
46 }
47 Self::CheckpointStreamError { error, .. } => {
48 write!(
49 f,
50 "Transaction executed but checkpoint stream had an error: {error}"
51 )
52 }
53 }
54 }
55}
56
57impl std::error::Error for ExecuteAndWaitError {
58 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
59 match self {
60 Self::RpcError(status) => Some(status),
61 Self::ProtoConversionError(e) => Some(e),
62 Self::CheckpointStreamError { error, .. } => Some(error),
63 Self::MissingTransaction => None,
64 Self::CheckpointTimeout(_) => None,
65 }
66 }
67}
68
69impl Client {
70 /// Executes a transaction and waits for it to be included in a checkpoint.
71 ///
72 /// This method provides "read your writes" consistency by executing the transaction
73 /// and waiting for it to appear in a checkpoint, which gauruntees indexes have been updated on
74 /// this node.
75 ///
76 /// # Arguments
77 /// * `request` - The transaction execution request (ExecuteTransactionRequest)
78 /// * `timeout` - Maximum time to wait for indexing confirmation
79 ///
80 /// # Returns
81 /// A `Result` containing the response if the transaction was executed and checkpoint confirmed,
82 /// or an error that may still include the response if execution succeeded but checkpoint
83 /// confirmation failed.
84 ///
85 /// # Duplicate submissions
86 /// Submitting a transaction that has already been executed is handled
87 /// gracefully. While the execution RPC is in flight the ledger is probed
88 /// for the transaction, and a transaction that is already in a checkpoint
89 /// is returned without waiting for execution to finish. Likewise, if
90 /// execution fails but the ledger shows the transaction in a checkpoint
91 /// (for example when a resubmission races the original submission), the
92 /// execution error is discarded and the committed transaction is
93 /// returned. In both cases the response is assembled from
94 /// `GetTransaction` using the request's read mask, so it carries the same
95 /// fields an execution response would, with `digest`, `checkpoint`, and
96 /// `timestamp` always populated.
97 pub async fn execute_transaction_and_wait_for_checkpoint(
98 &mut self,
99 request: impl tonic::IntoRequest<ExecuteTransactionRequest>,
100 timeout: Duration,
101 ) -> Result<Response<ExecuteTransactionResponse>, ExecuteAndWaitError> {
102 // Calculate digest from the input transaction to avoid relying on response read mask
103 let request = request.into_request();
104 let transaction = match request.get_ref().transaction_opt() {
105 Some(tx) => tx,
106 None => return Err(ExecuteAndWaitError::MissingTransaction),
107 };
108
109 let executed_txn_digest = match sui_sdk_types::Transaction::try_from(transaction) {
110 Ok(tx) => tx.digest().to_string(),
111 Err(e) => return Err(ExecuteAndWaitError::ProtoConversionError(e)),
112 };
113
114 // Read mask for answering from GetTransaction when execution cannot
115 // provide the response: a duplicate submission that already
116 // committed, or an execution error after the transaction landed.
117 // Both RPCs' masks select fields of `ExecutedTransaction`, so the
118 // caller's mask passes through unchanged; when the caller didn't set
119 // one, mirror ExecuteTransaction's documented default. `digest`,
120 // `checkpoint`, and `timestamp` are always included since this
121 // method's contract populates them.
122 let lookup_mask = {
123 let caller_paths = match &request.get_ref().read_mask {
124 Some(mask) => mask.paths.clone(),
125 None => vec!["effects.status".to_owned()],
126 };
127 FieldMask::from_paths(caller_paths.iter().map(String::as_str).chain([
128 "digest",
129 "checkpoint",
130 "timestamp",
131 ]))
132 .normalize()
133 };
134
135 // Subscribe to checkpoint stream before execution to avoid missing the transaction.
136 // Uses minimal read mask for efficiency since we only nee digest confirmation.
137 // Once server-side filtering is available, we should filter by transaction digest to
138 // further reduce bandwidth.
139 let mut checkpoint_stream = match self
140 .subscription_client()
141 .subscribe_checkpoints(SubscribeCheckpointsRequest::default().with_read_mask(
142 FieldMask::from_str("transactions.digest,sequence_number,summary.timestamp"),
143 ))
144 .await
145 {
146 Ok(stream) => stream.into_inner(),
147 Err(e) => return Err(ExecuteAndWaitError::RpcError(e)),
148 };
149
150 // Scan the subscription for the transaction's digest. Every RPC on
151 // this client shares one HTTP/2 connection, so this future must be
152 // polled concurrently with the execution phase below: a subscription
153 // parked while another call is awaited pins its flow-control window
154 // (checkpoints keep arriving whether or not anyone reads them) and,
155 // past the idle timeout, gets reset by the client's body watchdog.
156 //
157 // Both this future and the execution future below are boxed: their
158 // combined state (two full tonic call chains alive at once) would
159 // otherwise be inlined into this method's future, making it large
160 // enough to threaten a stack overflow in callers that hold it in
161 // deeply nested or spawned futures.
162 let mut scan = Box::pin(async {
163 while let Some(response) = checkpoint_stream.try_next().await? {
164 let checkpoint = response.checkpoint();
165
166 for tx in checkpoint.transactions() {
167 if tx.digest() == executed_txn_digest {
168 return Ok((checkpoint.sequence_number(), checkpoint.summary().timestamp));
169 }
170 }
171 }
172 Err(tonic::Status::aborted(
173 "checkpoint stream ended unexpectedly",
174 ))
175 });
176
177 // The concurrent futures below each need a service client, and a
178 // single `&mut self` cannot back all of them at once, so give each
179 // its own client over the shared channel.
180 let mut execution_client = self.execution_client();
181 let mut post_exec_lookup_client = self.ledger_client();
182 let mut probe_client = self.ledger_client();
183
184 // Execute, then query the fullnode directly to see if it already has
185 // the txn in a checkpoint. This is to handle the case where an
186 // already executed transaction is sent multiple times.
187 let mut exec_and_check = Box::pin(async {
188 let response = execution_client.execute_transaction(request).await?;
189
190 let already_checkpointed = match post_exec_lookup_client
191 .get_transaction(
192 GetTransactionRequest::default()
193 .with_digest(&executed_txn_digest)
194 .with_read_mask(FieldMask::from_str("digest,checkpoint,timestamp")),
195 )
196 .await
197 {
198 Ok(resp) if resp.get_ref().transaction().checkpoint_opt().is_some() => Some((
199 resp.get_ref().transaction().checkpoint(),
200 resp.get_ref().transaction().timestamp,
201 )),
202 _ => None,
203 };
204
205 Ok::<_, tonic::Status>((response, already_checkpointed))
206 });
207
208 // Probe the ledger while execution is in flight: a resubmission of a
209 // transaction that already committed can be answered from the ledger
210 // without waiting for, or succeeding at, execution.
211 let mut probe = Box::pin(async {
212 probe_client
213 .get_transaction(
214 GetTransactionRequest::default()
215 .with_digest(&executed_txn_digest)
216 .with_read_mask(lookup_mask.clone()),
217 )
218 .await
219 });
220
221 // Drive execution, the scan, and the probe together. The scan can
222 // complete first (for example, when a duplicate of an already
223 // executed transaction lands in a checkpoint mid-execution), so
224 // remember its outcome; the guards keep completed futures from being
225 // polled again. A probe that finds the transaction in a checkpoint
226 // resolves the call on the spot; any other probe outcome (not found,
227 // not yet checkpointed, or an RPC error) means execution has to
228 // provide the answer.
229 let mut scan_result = None;
230 let mut probe_done = false;
231 let exec_result = loop {
232 tokio::select! {
233 exec = &mut exec_and_check => break exec,
234 result = &mut scan, if scan_result.is_none() => {
235 scan_result = Some(result);
236 }
237 result = &mut probe, if !probe_done => {
238 probe_done = true;
239 if let Ok(lookup) = result
240 && lookup.get_ref().transaction().checkpoint_opt().is_some()
241 {
242 return Ok(lookup_into_execute_response(lookup));
243 }
244 }
245 }
246 };
247
248 let (mut response, already_checkpointed) = match exec_result {
249 Ok(ok) => ok,
250 Err(error) => {
251 // Execution can fail for a transaction that nonetheless
252 // committed, for example when a resubmission races the
253 // original submission. Consult the ledger before surfacing
254 // the error.
255 drop(probe);
256 if let Ok(lookup) = probe_client
257 .get_transaction(
258 GetTransactionRequest::default()
259 .with_digest(&executed_txn_digest)
260 .with_read_mask(lookup_mask),
261 )
262 .await
263 && lookup.get_ref().transaction().checkpoint_opt().is_some()
264 {
265 return Ok(lookup_into_execute_response(lookup));
266 }
267 return Err(ExecuteAndWaitError::RpcError(error));
268 }
269 };
270
271 // Wait for the transaction to appear in a checkpoint, at which point
272 // indexes will have been updated. The direct lookup takes precedence:
273 // when it already places the transaction in a checkpoint there is
274 // nothing to wait for, even if the scan failed in the meantime.
275 let (checkpoint, timestamp) = if let Some(found) = already_checkpointed {
276 found
277 } else {
278 let result = match scan_result {
279 Some(result) => result,
280 None => {
281 tokio::select! {
282 result = &mut scan => result,
283 _ = tokio::time::sleep(timeout) => {
284 return Err(ExecuteAndWaitError::CheckpointTimeout(response));
285 }
286 }
287 }
288 };
289 match result {
290 Ok(found) => found,
291 Err(e) => {
292 return Err(ExecuteAndWaitError::CheckpointStreamError { response, error: e });
293 }
294 }
295 };
296
297 response
298 .get_mut()
299 .transaction_mut()
300 .set_checkpoint(checkpoint);
301 response.get_mut().transaction_mut().timestamp = timestamp;
302 Ok(response)
303 }
304
305 /// Retrieves the current reference gas price from the latest epoch information.
306 ///
307 /// # Returns
308 /// The reference gas price as a `u64`
309 ///
310 /// # Errors
311 /// Returns an error if there is an RPC error when fetching the epoch information
312 pub async fn get_reference_gas_price(&mut self) -> Result<u64, tonic::Status> {
313 let request = GetEpochRequest::latest()
314 .with_read_mask(FieldMask::from_paths(["reference_gas_price"]));
315 let response = self.ledger_client().get_epoch(request).await?.into_inner();
316 Ok(response.epoch().reference_gas_price())
317 }
318}
319
320/// Builds the response for a transaction answered from the ledger instead of
321/// from execution.
322fn lookup_into_execute_response(
323 response: Response<GetTransactionResponse>,
324) -> Response<ExecuteTransactionResponse> {
325 Response::new(ExecuteTransactionResponse {
326 transaction: response.into_inner().transaction,
327 })
328}
329
330impl fmt::Display for ExecutionError {
331 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332 let description = self.description.as_deref().unwrap_or("No description");
333 write!(
334 f,
335 "ExecutionError: Kind: {}, Description: {}",
336 self.kind().as_str_name(),
337 description
338 )
339 }
340}