Skip to main content

thruput_client/
native.rs

1//! Native JSON-RPC invocation helpers, separate from wallet account selection.
2//!
3//! Callers verify endpoint compatibility and a trusted network pin before signing.
4//! These helpers never discover configuration, select a wire protocol, or retry writes.
5
6use 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/// A native read or endpoint preparation failure. Original application errors
25/// and unfamiliar results remain available to the embedding application.
26#[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    /// Missing bootstrap method or malformed discovery, including responses
43    /// without Thruput's required `wireFormat` field.
44    #[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
79/// Require native wire major 14 and every caller-selected method before use.
80/// Discovery is queried once; there is no legacy-name fallback. Missing or
81/// malformed discovery fields are incompatible discovery errors retaining the
82/// original cause; transport outages remain ordinary call failures.
83pub 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
152/// Verify signing identity against an independently configured network hash.
153/// A matching network hash does not distinguish resets of the same chain.
154pub 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
172/// Add a positive expiry offset to a committed tip, rejecting overflow rather
173/// than silently changing the caller's requested validity window.
174pub 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/// Native submission failure retaining the canonical identity prepared locally.
195/// Neither an RPC failure nor an unfamiliar acknowledgement proves non-admission.
196#[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
222/// Whether the first dispatch received authoritative proof of non-admission.
223/// This says nothing about earlier submissions of the same transaction and never
224/// grants permission to retry. Missing admission, unfamiliar data or a conflicting
225/// supplied hash is false; request-scoped refusal may omit the optional hash.
226pub 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    /// The first dispatch was refused; this does not prove that an earlier
275    /// submission of the same bytes was refused or permit automatic retries.
276    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    /// Submit these signed bytes exactly once through the native RPC contract.
287    /// The caller must verify compatibility and network identity before signing.
288    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
296/// Submit retained signed bytes once and verify the acknowledgement's transaction hash.
297pub 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/// A transaction hash that can be observed without resubmitting its envelope.
330#[derive(Debug, Clone, Copy)]
331#[non_exhaustive]
332pub struct SubmittedCall {
333    pub tx_hash: TxHash,
334}
335
336impl SubmittedCall {
337    /// Resume observation of a retained hash after an uncertain submission or restart.
338    /// Construction does not claim that the transaction was admitted or executed.
339    #[must_use]
340    pub const fn new(tx_hash: TxHash) -> Self {
341        Self { tx_hash }
342    }
343
344    /// Wait read-only for this hash on the same caller-verified chain instance.
345    /// Failed execution receipts are returned intact. Cancellation and timeout
346    /// retain the native waiter's commitment, status and original error evidence.
347    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;