avail_rust_client/
submission_api.rs

1//! Builders for submitting extrinsics and inspecting their on-chain lifecycle.
2
3use crate::{
4	Client, Error, UserError,
5	block_api::{
6		BlockApi, BlockEvents, BlockExtrinsic, BlockRawExtrinsic, BlockTransaction, BlockWithExt, BlockWithRawExt,
7		BlockWithTx, ExtrinsicEvents,
8	},
9	subscription::Sub,
10	subxt_signer::sr25519::Keypair,
11	transaction_options::{Options, RefinedMortality, RefinedOptions},
12};
13use avail_rust_core::{
14	AccountId, BlockInfo, EncodeSelector, H256, HasHeader, RpcError,
15	ext::codec::Encode,
16	substrate::extrinsic::{ExtrinsicAdditional, ExtrinsicCall, GenericExtrinsic},
17	types::{
18		metadata::{HashString, TransactionRef},
19		substrate::{FeeDetails, RuntimeDispatchInfo},
20	},
21};
22use codec::Decode;
23#[cfg(feature = "tracing")]
24use tracing::info;
25
26/// Builder that keeps an encoded call together with the client connection and exposes helpers for
27/// signing, submitting, and querying execution costs.
28#[derive(Clone)]
29pub struct SubmittableTransaction {
30	client: Client,
31	pub call: ExtrinsicCall,
32	retry_on_error: Option<bool>,
33}
34
35impl SubmittableTransaction {
36	/// Creates a transaction builder from an encoded call.
37	///
38	/// The builder is inert until one of the async helpers is invoked. By default it inherits the
39	/// client's retry policy, but this can be customised via [`set_retry_on_error`](Self::set_retry_on_error).
40	pub fn new(client: Client, call: ExtrinsicCall) -> Self {
41		Self { client, call, retry_on_error: None }
42	}
43
44	/// Signs the call with the provided keypair and submits it to the chain in a single RPC round-trip.
45	///
46	/// # Returns
47	/// - `Ok(SubmittedTransaction)` when the node accepts the extrinsic and returns its hash along with
48	///   metadata inferred from `options`.
49	/// - `Err(Error)` when signing fails, submission is rejected by the node, or any underlying RPC call
50	///   (potentially retried according to the configured policy) returns an error.
51	///
52	/// The submission uses `options` (nonce, tip, mortality) exactly as provided; no additional mutation
53	/// happens inside this helper.
54	pub async fn sign_and_submit(&self, signer: &Keypair, options: Options) -> Result<SubmittedTransaction, Error> {
55		self.client
56			.chain()
57			.retry_on(self.retry_on_error, None)
58			.sign_and_submit_call(signer, &self.call, options)
59			.await
60	}
61
62	/// Signs the call without submitting it, returning the encoded extrinsic bytes that would be sent
63	/// to the network.
64	///
65	/// # Returns
66	/// - `Ok(GenericExtrinsic<'_>)` containing the SCALE-encoded payload ready for submission.
67	/// - `Err(Error)` when the signing operation fails (for example, due to a bad signer, stale
68	///   account information, or RPC issues while fetching metadata).
69	pub async fn sign(&self, signer: &Keypair, options: Options) -> Result<GenericExtrinsic<'_>, Error> {
70		self.client
71			.chain()
72			.retry_on(self.retry_on_error, None)
73			.sign_call(signer, &self.call, options)
74			.await
75	}
76
77	/// Estimates fee details for the underlying call using runtime information at `at` without signing
78	/// or submitting anything.
79	///
80	/// # Returns
81	/// - `Ok(FeeDetails)` containing the partial fee breakdown the runtime reports for the call.
82	/// - `Err(RpcError)` if the node rejects the dry-run query (e.g. bad call data, missing runtime
83	///   exposes) or if transport errors occur.
84	pub async fn estimate_call_fees(&self, at: Option<H256>) -> Result<FeeDetails, RpcError> {
85		let call = self.call.encode();
86		self.client
87			.chain()
88			.retry_on(self.retry_on_error, None)
89			.transaction_payment_query_call_fee_details(call, at)
90			.await
91	}
92
93	/// Signs the call with the provided options and queries the chain for the cost of submitting that
94	/// exact extrinsic.
95	///
96	/// # Returns
97	/// - `Ok(FeeDetails)` containing the fee components returned by the runtime.
98	/// - `Err(Error)` if signing the call fails or if the fee query returns an error (in which case the
99	///   underlying [`RpcError`] is wrapped in the returned [`Error`]).
100	pub async fn estimate_extrinsic_fees(
101		&self,
102		signer: &Keypair,
103		options: Options,
104		at: Option<H256>,
105	) -> Result<FeeDetails, Error> {
106		let transaction = self.sign(signer, options).await?;
107		let transaction = transaction.encode();
108		Ok(self
109			.client
110			.chain()
111			.retry_on(self.retry_on_error, None)
112			.transaction_payment_query_fee_details(transaction, at)
113			.await?)
114	}
115
116	/// Returns runtime dispatch information for the call, including weight, class, and partial fee
117	/// estimation based on the provided block context.
118	///
119	/// # Returns
120	/// - `Ok(RuntimeDispatchInfo)` with weight and class metadata.
121	/// - `Err(RpcError)` if the node cannot evaluate the call (bad parameters, runtime error, or RPC
122	///   transport failure).
123	pub async fn call_info(&self, at: Option<H256>) -> Result<RuntimeDispatchInfo, RpcError> {
124		let call = self.call.encode();
125		self.client
126			.chain()
127			.retry_on(self.retry_on_error, None)
128			.transaction_payment_query_call_info(call, at)
129			.await
130	}
131
132	/// Resolves whether RPC calls performed through this builder should be retried on transient
133	/// failures.
134	///
135	/// The method returns the explicit override set by [`set_retry_on_error`](Self::set_retry_on_error),
136	/// falling back to the client's global retry configuration when no override is present.
137	pub fn should_retry_on_error(&self) -> bool {
138		should_retry(&self.client, self.retry_on_error)
139	}
140
141	/// Controls retry behaviour for RPC calls sent via this builder.
142	///
143	/// # Parameters
144	/// - `Some(true)`: force retries regardless of the client's global setting.
145	/// - `Some(false)`: disable retries for requests issued through this builder.
146	/// - `None`: fall back to the client's global retry configuration.
147	pub fn set_retry_on_error(&mut self, value: Option<bool>) {
148		self.retry_on_error = value;
149	}
150
151	/// Converts any encodable call into a `SubmittableTransaction` based on its pallet and call indices.
152	/// The provided value is SCALE-encoded immediately; failures propagate as panics originating from
153	/// the underlying encoding implementation.
154	pub fn from_encodable<T: HasHeader + Encode>(client: Client, value: T) -> SubmittableTransaction {
155		let call = ExtrinsicCall::new(T::HEADER_INDEX.0, T::HEADER_INDEX.1, value.encode());
156		SubmittableTransaction::new(client, call)
157	}
158
159	/// Hashes the call payload as it would appear in an extrinsic, returning the blake2 hash used by
160	/// the runtime for call identification.
161	pub fn call_hash(&self) -> [u8; 32] {
162		self.call.hash()
163	}
164}
165
166impl From<SubmittableTransaction> for ExtrinsicCall {
167	fn from(value: SubmittableTransaction) -> Self {
168		value.call
169	}
170}
171
172impl From<&SubmittableTransaction> for ExtrinsicCall {
173	fn from(value: &SubmittableTransaction) -> Self {
174		value.call.clone()
175	}
176}
177
178/// Handle to a transaction that has already been submitted to the network along with the contextual
179/// information required to query its lifecycle.
180#[derive(Clone)]
181pub struct SubmittedTransaction {
182	client: Client,
183	pub tx_hash: H256,
184	pub account_id: AccountId,
185	pub options: RefinedOptions,
186	pub additional: ExtrinsicAdditional,
187}
188
189impl SubmittedTransaction {
190	/// Creates a new submitted transaction handle using previously gathered metadata.
191	///
192	/// This does not perform any network calls; it simply stores the information needed to later
193	/// resolve receipts or query status.
194	pub fn new(
195		client: Client,
196		tx_hash: H256,
197		account_id: AccountId,
198		options: RefinedOptions,
199		additional: ExtrinsicAdditional,
200	) -> Self {
201		Self { client, tx_hash, account_id, options, additional }
202	}
203
204	/// Produces a receipt describing how the transaction landed on chain, if it did at all.
205	///
206	/// # Returns
207	/// - `Ok(Some(TransactionReceipt))` when the transaction is found in the searched block range.
208	/// - `Ok(None)` when the transaction cannot be located within the mortality window implied by
209	///   `options`.
210	/// - `Err(Error)` when the underlying RPC or subscription queries fail.
211	///
212	/// Set `use_best_block` to `true` to follow the node's best chain (potentially including
213	/// non-finalized blocks) or `false` to restrict the search to finalized blocks.
214	pub async fn receipt(&self, use_best_block: bool) -> Result<Option<TransactionReceipt>, Error> {
215		Utils::transaction_receipt(
216			self.client.clone(),
217			self.tx_hash,
218			self.options.nonce,
219			&self.account_id,
220			&self.options.mortality,
221			use_best_block,
222		)
223		.await
224	}
225}
226
227/// Indicates what happened to a transaction after it was submitted.
228///
229/// The variants correspond to the states returned by the chain RPC when querying transaction status.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231#[repr(u8)]
232pub enum BlockState {
233	/// The transaction was included in a block but the block may still be re-orged out.
234	Included = 0,
235	/// The block containing the transaction is finalized and immutable under normal circumstances.
236	Finalized = 1,
237	/// The transaction was seen but ended up discarded (e.g. due to invalidation).
238	Discarded = 2,
239	/// The transaction could not be found on chain.
240	DoesNotExist = 3,
241}
242
243/// Detailed information about where a transaction was found on chain.
244#[derive(Clone)]
245pub struct TransactionReceipt {
246	client: Client,
247	pub block_ref: BlockInfo,
248	pub tx_ref: TransactionRef,
249}
250
251impl TransactionReceipt {
252	/// Wraps the provided block and transaction references without performing network IO.
253	pub fn new(client: Client, block: BlockInfo, tx: TransactionRef) -> Self {
254		Self { client, block_ref: block, tx_ref: tx }
255	}
256
257	/// Returns the current lifecycle state of the containing block.
258	///
259	/// # Returns
260	/// - `Ok(BlockState)` on success.
261	/// - `Err(Error)` if the RPC request fails or the node cannot provide the block state.
262	pub async fn block_state(&self) -> Result<BlockState, Error> {
263		self.client.chain().block_state(self.block_ref.hash).await
264	}
265
266	/// Fetches and decodes the transaction at the recorded index within the block.
267	///
268	/// # Returns
269	/// - `Ok(BlockTransaction<T>)` when the transaction exists and can be decoded as `T`.
270	/// - `Err(Error)` if the transaction is missing, cannot be decoded, or any RPC call fails.
271	pub async fn tx<T: HasHeader + Decode>(&self) -> Result<BlockTransaction<T>, Error> {
272		let block = BlockWithTx::new(self.client.clone(), self.block_ref.height);
273		let tx = block.get(self.tx_ref.index).await?;
274		let Some(tx) = tx else {
275			return Err(RpcError::ExpectedData("No transaction found at the requested index.".into()).into());
276		};
277
278		Ok(tx)
279	}
280
281	/// Fetches and decodes the extrinsic at the recorded index within the block.
282	///
283	/// # Returns
284	/// - `Ok(BlockExtrinsic<T>)` when the extrinsic exists and decodes as `T`.
285	/// - `Err(Error)` when the extrinsic is missing, cannot be decoded as `T`, or RPC access fails.
286	pub async fn ext<T: HasHeader + Decode>(&self) -> Result<BlockExtrinsic<T>, Error> {
287		let block = BlockWithExt::new(self.client.clone(), self.block_ref.height);
288		let ext: Option<BlockExtrinsic<T>> = block.get(self.tx_ref.index).await?;
289		let Some(ext) = ext else {
290			return Err(RpcError::ExpectedData("No extrinsic found at the requested index.".into()).into());
291		};
292
293		Ok(ext)
294	}
295
296	/// Fetches just the call payload for the extrinsic at the recorded index.
297	///
298	/// # Returns
299	/// - `Ok(T)` when the extrinsic exists and its call decodes as `T`.
300	/// - `Err(Error)` otherwise (missing extrinsic, decode failure, or RPC error).
301	pub async fn call<T: HasHeader + Decode>(&self) -> Result<T, Error> {
302		let block = BlockWithExt::new(self.client.clone(), self.block_ref.height);
303		let tx = block.get(self.tx_ref.index).await?;
304		let Some(tx) = tx else {
305			return Err(RpcError::ExpectedData("No extrinsic found at the requested index.".into()).into());
306		};
307
308		Ok(tx.call)
309	}
310
311	/// Returns the raw extrinsic bytes or a different encoding if requested.
312	///
313	/// # Returns
314	/// - `Ok(BlockRawExtrinsic)` with the requested encoding.
315	/// - `Err(Error)` when the extrinsic cannot be found or an RPC failure occurs.
316	pub async fn raw_ext(&self, encode_as: EncodeSelector) -> Result<BlockRawExtrinsic, Error> {
317		let block = BlockWithRawExt::new(self.client.clone(), self.block_ref.height);
318		let ext = block.get(self.tx_ref.index, encode_as).await?;
319		let Some(ext) = ext else {
320			return Err(RpcError::ExpectedData("No extrinsic found at the requested index.".into()).into());
321		};
322
323		Ok(ext)
324	}
325
326	/// Fetches the events emitted as part of the transaction execution.
327	///
328	/// # Returns
329	/// - `Ok(ExtrinsicEvents)` when the extrinsic exists and events are available.
330	/// - `Err(Error)` when the events cannot be located or fetched.
331	pub async fn events(&self) -> Result<ExtrinsicEvents, Error> {
332		let block = BlockEvents::new(self.client.clone(), self.block_ref.hash);
333		let events = block.ext(self.tx_ref.index).await?;
334		let Some(events) = events else {
335			return Err(RpcError::ExpectedData("No events found for the requested extrinsic.".into()).into());
336		};
337		Ok(events)
338	}
339
340	/// Iterates block-by-block from `block_start` through `block_end` (inclusive) looking for an
341	/// extrinsic whose hash matches `tx_hash`.
342	///
343	/// Returns `Ok(Some(TransactionReceipt))` as soon as a match is found, `Ok(None)` when the
344	/// entire range has been exhausted without a match, and bubbles up any RPC or subscription
345	/// errors encountered along the way.
346	///
347	/// Fails fast with a validation error when `block_start > block_end`. When `use_best_block`
348	/// is `true`, the search follows the node's best chain; otherwise it restricts the iteration to
349	/// finalized blocks only.
350	pub async fn from_range(
351		client: Client,
352		tx_hash: impl Into<HashString>,
353		block_start: u32,
354		block_end: u32,
355		use_best_block: bool,
356	) -> Result<Option<TransactionReceipt>, Error> {
357		if block_start > block_end {
358			return Err(UserError::ValidationFailed("Block Start cannot start after Block End".into()).into());
359		}
360		let tx_hash: HashString = tx_hash.into();
361		let mut sub = Sub::new(client.clone());
362		sub.use_best_block(use_best_block);
363		sub.set_block_height(block_start);
364
365		loop {
366			let block_ref = sub.next().await?;
367
368			let block = BlockWithRawExt::new(client.clone(), block_ref.height);
369			let ext = block.get(tx_hash.clone(), EncodeSelector::None).await?;
370			if let Some(ext) = ext {
371				let tr = TransactionReceipt::new(client.clone(), block_ref, (ext.ext_hash(), ext.ext_index()).into());
372				return Ok(Some(tr));
373			}
374
375			if block_ref.height >= block_end {
376				return Ok(None);
377			}
378		}
379	}
380}
381
382/// Convenience helpers for locating transactions on chain.
383pub struct Utils;
384impl Utils {
385	/// Resolves the canonical receipt for a transaction if it landed on chain within its mortality window.
386	///
387	/// # Returns
388	/// - `Ok(Some(TransactionReceipt))` when a matching inclusion is located.
389	/// - `Ok(None)` when no matching transaction exists in the searched range.
390	/// - `Err(Error)` when RPC queries fail or input validation detects an inconsistency.
391	pub async fn transaction_receipt(
392		client: Client,
393		tx_hash: H256,
394		nonce: u32,
395		account_id: &AccountId,
396		mortality: &RefinedMortality,
397		use_best_block: bool,
398	) -> Result<Option<TransactionReceipt>, Error> {
399		let Some(block_ref) =
400			Self::find_correct_block_info(&client, nonce, tx_hash, account_id, mortality, use_best_block).await?
401		else {
402			return Ok(None);
403		};
404
405		let block = BlockApi::new(client.clone(), block_ref.hash);
406		let ext = block.raw_ext().get(tx_hash, EncodeSelector::None).await?;
407
408		let Some(ext) = ext else {
409			return Ok(None);
410		};
411
412		let tx_ref = TransactionRef::from((ext.ext_hash(), ext.ext_index()));
413		Ok(Some(TransactionReceipt::new(client, block_ref, tx_ref)))
414	}
415
416	/// Inspects blocks following the transaction's mortality and returns the first matching inclusion.
417	///
418	/// The search starts at `mortality.block_height` and proceeds one block at a time until the
419	/// mortality period expires, optionally following the node's best chain when `use_best_block` is
420	/// `true`.
421	///
422	/// # Returns
423	/// - `Ok(Some(BlockInfo))` once an inclusion is confirmed or a higher nonce proves execution.
424	/// - `Ok(None)` when the mortality period elapses without finding a match.
425	/// - `Err(Error)` if block streaming or nonce queries fail.
426	pub async fn find_correct_block_info(
427		client: &Client,
428		nonce: u32,
429		tx_hash: H256,
430		account_id: &AccountId,
431		mortality: &RefinedMortality,
432		use_best_block: bool,
433	) -> Result<Option<BlockInfo>, Error> {
434		let mortality_ends_height = mortality.block_height + mortality.period as u32;
435
436		let mut sub = Sub::new(client.clone());
437		sub.set_block_height(mortality.block_height);
438		sub.use_best_block(use_best_block);
439
440		let mut current_block_height = mortality.block_height;
441
442		#[cfg(feature = "tracing")]
443		{
444			match use_best_block {
445				true => {
446					let info = client.best().block_info().await?;
447					info!(target: "lib", "Nonce: {} Account address: {} Current Best Height: {} Mortality End Height: {}", nonce, account_id, info.height, mortality_ends_height);
448				},
449				false => {
450					let info = client.finalized().block_info().await?;
451					info!(target: "lib", "Nonce: {} Account address: {} Current Finalized Height: {} Mortality End Height: {}", nonce, account_id, info.height, mortality_ends_height);
452				},
453			};
454		}
455
456		while mortality_ends_height >= current_block_height {
457			let info = sub.next().await?;
458			current_block_height = info.height;
459
460			let state_nonce = client.chain().block_nonce(account_id.clone(), info.hash).await?;
461			if state_nonce > nonce {
462				trace_new_block(nonce, state_nonce, account_id, info, true);
463				return Ok(Some(info));
464			}
465			if state_nonce == 0 {
466				let block = BlockApi::new(client.clone(), info.hash);
467				let ext = block.raw_ext().get(tx_hash, EncodeSelector::None).await?;
468				if ext.is_some() {
469					trace_new_block(nonce, state_nonce, account_id, info, true);
470					return Ok(Some(info));
471				}
472			}
473
474			trace_new_block(nonce, state_nonce, account_id, info, false);
475		}
476
477		Ok(None)
478	}
479}
480
481/// Emits optional tracing output detailing nonce progression while searching for a transaction.
482///
483/// When the `tracing` feature is disabled this function does nothing; otherwise it records each
484/// inspected block along with whether the search completed.
485fn trace_new_block(nonce: u32, state_nonce: u32, account_id: &AccountId, block_info: BlockInfo, search_done: bool) {
486	#[cfg(feature = "tracing")]
487	{
488		if search_done {
489			info!(target: "lib", "Account ({}, {}). At block ({}, {:?}) found nonce: {}. Search is done", nonce, account_id, block_info.height, block_info.hash, state_nonce);
490		} else {
491			info!(target: "lib", "Account ({}, {}). At block ({}, {:?}) found nonce: {}.", nonce, account_id, block_info.height, block_info.hash, state_nonce);
492		}
493	}
494
495	#[cfg(not(feature = "tracing"))]
496	{
497		let _ = (nonce, state_nonce, account_id, block_info, search_done);
498	}
499}
500
501/// Applies either the provided retry preference or the client's global retry configuration.
502///
503/// Returns `true` when retries should be attempted, `false` otherwise.
504fn should_retry(client: &Client, value: Option<bool>) -> bool {
505	value.unwrap_or(client.is_global_retries_enabled())
506}