1use std::future::Future;
7
8use jsonrpsee::core::client::ClientT;
9use thruput_json_rpc_client::native::{
10 NativeClient, NativeClientError,
11 receipt::{ReceiptPollConfig, ReceiptPollError, poll_receipt},
12};
13use thruput_json_rpc_types::{
14 NetworkHash,
15 hex::HexBytes,
16 requests::{EmptyRequest, SendTransactionRequest},
17 responses::{self, DecodedResponse},
18 transaction::TxHash,
19};
20use thruput_xdr::types::NetworkHash as SigningNetworkHash;
21
22use crate::SignedCall;
23
24#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum NativeRpcError {
29 #[error("{method} failed: {source}")]
30 Call {
31 method: &'static str,
32 #[source]
33 source: Box<NativeClientError>,
34 },
35 #[error("unsupported {method} result")]
36 Unsupported {
37 method: &'static str,
38 raw: serde_json::Value,
39 },
40 #[error("unsupported {method} response representation")]
41 ResponseRepresentation { method: &'static str },
42 #[error("endpoint does not provide valid Thruput discovery: {source}")]
45 InvalidDiscovery {
46 #[source]
47 source: Box<NativeClientError>,
48 },
49 #[error("incompatible native RPC wire format: {actual}; expected major 14")]
50 WireFormat { actual: String },
51 #[error("native RPC endpoint does not advertise required methods: {methods:?}")]
52 MissingMethods { methods: Vec<String> },
53 #[error("network identity mismatch: expected {expected:?}, received {actual:?}")]
54 NetworkMismatch {
55 expected: SigningNetworkHash,
56 actual: SigningNetworkHash,
57 },
58 #[error("invalid transaction expiry: tip {tip}, offset {offset}")]
59 InvalidExpiry { tip: i64, offset: i64 },
60}
61
62fn known<T>(
63 result: Result<DecodedResponse<T>, NativeClientError>,
64 method: &'static str,
65) -> Result<T, NativeRpcError> {
66 match result {
67 Ok(DecodedResponse::Known(value)) => Ok(value),
68 Ok(DecodedResponse::Unsupported { raw }) => {
69 Err(NativeRpcError::Unsupported { method, raw })
70 }
71 Ok(_) => Err(NativeRpcError::ResponseRepresentation { method }),
72 Err(source) => Err(NativeRpcError::Call {
73 method,
74 source: Box::new(source),
75 }),
76 }
77}
78
79pub async fn check_endpoint<C: ClientT + Sync>(
84 client: &NativeClient<C>,
85 required_methods: &[&str],
86) -> Result<responses::VersionInfo, NativeRpcError> {
87 let version = known(
88 client.get_version_info(EmptyRequest {}).await,
89 "getVersionInfo",
90 )
91 .map_err(|error| match error {
92 NativeRpcError::Call { source, .. } if invalid_discovery(&source) => {
93 NativeRpcError::InvalidDiscovery { source }
94 }
95 other => other,
96 })?;
97 if !compatible_wire_format(&version.wire_format) {
98 return Err(NativeRpcError::WireFormat {
99 actual: version.wire_format,
100 });
101 }
102 let mut missing: Vec<_> = required_methods
103 .iter()
104 .filter(|required| {
105 !version
106 .supported_methods
107 .iter()
108 .any(|name| name == **required)
109 })
110 .map(|name| (*name).to_owned())
111 .collect();
112 missing.sort_unstable();
113 missing.dedup();
114 if !missing.is_empty() {
115 return Err(NativeRpcError::MissingMethods { methods: missing });
116 }
117 Ok(version)
118}
119
120fn invalid_discovery(error: &NativeClientError) -> bool {
121 match error {
122 NativeClientError::Transport(jsonrpsee::core::client::Error::ParseError(_))
123 | NativeClientError::InvalidResponse { .. } => true,
124 NativeClientError::Application { source, .. } => {
125 source.code() == jsonrpsee::types::error::METHOD_NOT_FOUND_CODE
126 }
127 _ => false,
128 }
129}
130
131fn compatible_wire_format(value: &str) -> bool {
132 let mut parts = value.split('.');
133 let mut version = [0_u64; 3];
134 for component in &mut version {
135 let Some(text) = parts.next() else {
136 return false;
137 };
138 if text.is_empty()
139 || (text.len() > 1 && text.starts_with('0'))
140 || !text.bytes().all(|b| b.is_ascii_digit())
141 {
142 return false;
143 }
144 let Ok(number) = text.parse() else {
145 return false;
146 };
147 *component = number;
148 }
149 parts.next().is_none() && version[0] == 14
150}
151
152pub async fn validate_network<C: ClientT + Sync>(
155 client: &NativeClient<C>,
156 expected: &SigningNetworkHash,
157) -> Result<responses::NetworkInfo, NativeRpcError> {
158 let info = known(
159 client.get_network_info(EmptyRequest {}).await,
160 "getNetworkInfo",
161 )?;
162 let actual = SigningNetworkHash::from(info.network_hash.0);
163 if &actual != expected {
164 return Err(NativeRpcError::NetworkMismatch {
165 expected: expected.clone(),
166 actual,
167 });
168 }
169 Ok(info)
170}
171
172pub async fn resolve_block_expiry_height<C: ClientT + Sync>(
175 client: &NativeClient<C>,
176 offset: i64,
177) -> Result<i64, NativeRpcError> {
178 let tip = known(client.get_chain_tip(EmptyRequest {}).await, "getChainTip")?;
179 let height = *tip.block_height;
180 if height < 0 || offset <= 0 {
181 return Err(NativeRpcError::InvalidExpiry {
182 tip: height,
183 offset,
184 });
185 }
186 height
187 .checked_add(offset)
188 .ok_or(NativeRpcError::InvalidExpiry {
189 tip: height,
190 offset,
191 })
192}
193
194#[derive(Debug, thiserror::Error)]
197#[non_exhaustive]
198pub enum SubmitCallError {
199 #[error("sendTransaction failed for {tx_hash:?}: {source}")]
200 Rpc {
201 tx_hash: TxHash,
202 #[source]
203 source: Box<NativeClientError>,
204 },
205 #[error("unsupported sendTransaction result for {tx_hash:?}")]
206 Unsupported {
207 tx_hash: TxHash,
208 raw: serde_json::Value,
209 },
210 #[error("unsupported sendTransaction response representation for {tx_hash:?}")]
211 ResponseRepresentation { tx_hash: TxHash },
212 #[error("submitted transaction {tx_hash:?}, but endpoint returned {returned:?}")]
213 HashMismatch { tx_hash: TxHash, returned: TxHash },
214 #[error("invalid locally signed transaction encoding for {tx_hash:?}: {source}")]
215 Encoding {
216 tx_hash: TxHash,
217 #[source]
218 source: hex::FromHexError,
219 },
220}
221
222pub fn first_submission_not_admitted(error: &NativeClientError, expected_hash: TxHash) -> bool {
227 use thruput_json_rpc_types::error::native::{AttemptAdmission, DecodedError, ErrorData};
228 let NativeClientError::Application { data, .. } = error else {
229 return false;
230 };
231 let DecodedError::Known { data, .. } = data.as_ref() else {
232 return false;
233 };
234 let (ErrorData::TransactionInvalid {
235 admission,
236 transaction_hash: hash,
237 ..
238 }
239 | ErrorData::TransactionRejected {
240 admission,
241 transaction_hash: hash,
242 ..
243 }
244 | ErrorData::BackendUnavailable {
245 admission: Some(admission),
246 transaction_hash: hash,
247 ..
248 }
249 | ErrorData::BackendTimeout {
250 admission: Some(admission),
251 transaction_hash: hash,
252 ..
253 }
254 | ErrorData::Overloaded {
255 admission: Some(admission),
256 transaction_hash: hash,
257 ..
258 }
259 | ErrorData::BackendDataInvalid {
260 admission: Some(admission),
261 transaction_hash: hash,
262 ..
263 }) = data.as_ref()
264 else {
265 return false;
266 };
267 matches!(
268 admission,
269 AttemptAdmission::NotAttempted | AttemptAdmission::Rejected
270 ) && hash.is_none_or(|hash| hash == expected_hash)
271}
272
273impl SubmitCallError {
274 pub fn proves_first_send_not_admitted(&self) -> bool {
277 match self {
278 Self::Rpc { tx_hash, source } => first_submission_not_admitted(source, *tx_hash),
279 Self::Encoding { .. } => true,
280 _ => false,
281 }
282 }
283}
284
285impl SignedCall {
286 pub async fn submit<C: ClientT + Sync>(
289 &self,
290 client: &NativeClient<C>,
291 ) -> Result<SubmittedCall, SubmitCallError> {
292 submit_signed_transaction(client, self.tx_hash(), self.tx_hex()).await
293 }
294}
295
296pub async fn submit_signed_transaction<C: ClientT + Sync>(
298 client: &NativeClient<C>,
299 tx_hash: TxHash,
300 tx_hex: &str,
301) -> Result<SubmittedCall, SubmitCallError> {
302 let bytes = hex::decode(tx_hex.strip_prefix("0x").unwrap_or(tx_hex))
303 .map_err(|source| SubmitCallError::Encoding { tx_hash, source })?;
304 let result = client
305 .send_transaction(SendTransactionRequest {
306 signed_transaction: HexBytes(bytes),
307 })
308 .await
309 .map_err(|source| SubmitCallError::Rpc {
310 tx_hash,
311 source: Box::new(source),
312 })?;
313 let acknowledgement = match result {
314 DecodedResponse::Known(value) => value,
315 DecodedResponse::Unsupported { raw } => {
316 return Err(SubmitCallError::Unsupported { tx_hash, raw });
317 }
318 _ => return Err(SubmitCallError::ResponseRepresentation { tx_hash }),
319 };
320 if acknowledgement.transaction_hash != tx_hash {
321 return Err(SubmitCallError::HashMismatch {
322 tx_hash,
323 returned: acknowledgement.transaction_hash,
324 });
325 }
326 Ok(SubmittedCall { tx_hash })
327}
328
329#[derive(Debug, Clone, Copy)]
331#[non_exhaustive]
332pub struct SubmittedCall {
333 pub tx_hash: TxHash,
334}
335
336impl SubmittedCall {
337 #[must_use]
340 pub const fn new(tx_hash: TxHash) -> Self {
341 Self { tx_hash }
342 }
343
344 pub async fn wait<C, F>(
348 &self,
349 client: &NativeClient<C>,
350 network_hash: NetworkHash,
351 config: ReceiptPollConfig,
352 cancelled: F,
353 ) -> Result<responses::TransactionReceipt, ReceiptPollError>
354 where
355 C: ClientT + Sync,
356 F: Future<Output = ()>,
357 {
358 poll_receipt(client, network_hash, self.tx_hash, None, config, cancelled).await
359 }
360}
361
362#[cfg(test)]
363mod tests;