Skip to main content

near_kit/client/
rpc.rs

1//! Low-level JSON-RPC client for NEAR.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::Duration;
5
6use base64::{Engine as _, engine::general_purpose::STANDARD};
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8
9use crate::error::RpcError;
10use crate::types::rpc::RawTransactionResponse;
11use crate::types::{
12    AccessKeyListView, AccessKeyView, AccountId, AccountView, BlockReference, BlockView,
13    CryptoHash, EpochValidatorInfo, GasPrice, PublicKey, SignedTransaction, StatusResponse,
14    TxExecutionStatus, ViewFunctionResult,
15};
16
17/// Network configuration presets.
18pub struct NetworkConfig {
19    /// The RPC URL for this network.
20    pub rpc_url: &'static str,
21    /// The network identifier (e.g., "mainnet", "testnet").
22    /// Reserved for future use in transaction signing.
23    #[allow(dead_code)]
24    pub network_id: &'static str,
25}
26
27/// Mainnet configuration.
28pub const MAINNET: NetworkConfig = NetworkConfig {
29    rpc_url: "https://free.rpc.fastnear.com",
30    network_id: "mainnet",
31};
32
33/// Testnet configuration.
34pub const TESTNET: NetworkConfig = NetworkConfig {
35    rpc_url: "https://test.rpc.fastnear.com",
36    network_id: "testnet",
37};
38
39/// Retry configuration for RPC calls.
40#[derive(Clone, Debug)]
41pub struct RetryConfig {
42    /// Maximum number of retries.
43    pub max_retries: u32,
44    /// Initial delay in milliseconds.
45    pub initial_delay_ms: u64,
46    /// Maximum delay in milliseconds.
47    pub max_delay_ms: u64,
48}
49
50impl Default for RetryConfig {
51    fn default() -> Self {
52        Self {
53            max_retries: 3,
54            initial_delay_ms: 500,
55            max_delay_ms: 5000,
56        }
57    }
58}
59
60/// JSON-RPC request structure.
61#[derive(Serialize)]
62struct JsonRpcRequest<'a, P: Serialize> {
63    jsonrpc: &'static str,
64    id: u64,
65    method: &'a str,
66    params: P,
67}
68
69/// JSON-RPC response structure.
70///
71/// The `result` field is deserialized as raw JSON first, then parsed into `T`
72/// only after confirming no error is present. This avoids deserialization
73/// failures when the RPC returns an error with a partial/unexpected `result`.
74#[derive(Deserialize)]
75struct JsonRpcResponse {
76    #[allow(dead_code)]
77    jsonrpc: String,
78    #[allow(dead_code)]
79    id: u64,
80    result: Option<serde_json::Value>,
81    error: Option<JsonRpcError>,
82}
83
84/// JSON-RPC error structure.
85/// NEAR RPC returns structured errors with name/cause/info pattern.
86#[derive(Debug, Deserialize)]
87struct JsonRpcError {
88    code: i64,
89    message: String,
90    #[serde(default)]
91    data: Option<serde_json::Value>,
92    #[serde(default)]
93    cause: Option<ErrorCause>,
94    #[serde(default)]
95    #[allow(dead_code)]
96    name: Option<String>,
97}
98
99/// Structured error cause from NEAR RPC.
100#[derive(Debug, Deserialize)]
101struct ErrorCause {
102    name: String,
103    #[serde(default)]
104    info: Option<serde_json::Value>,
105}
106
107/// Response from EXPERIMENTAL_call_function.
108/// Errors are returned through the JSON-RPC error envelope, so no `error`
109/// field is needed here.
110#[derive(Debug, Deserialize)]
111struct CallFunctionResponse {
112    result: Vec<u8>,
113    #[serde(default)]
114    logs: Vec<String>,
115    block_height: u64,
116    block_hash: CryptoHash,
117}
118
119/// Low-level JSON-RPC client for NEAR.
120pub struct RpcClient {
121    url: String,
122    client: reqwest::Client,
123    retry_config: RetryConfig,
124    request_id: AtomicU64,
125}
126
127impl RpcClient {
128    /// Create a new RPC client with the given URL.
129    pub fn new(url: impl Into<String>) -> Self {
130        Self {
131            url: url.into(),
132            client: reqwest::Client::new(),
133            retry_config: RetryConfig::default(),
134            request_id: AtomicU64::new(0),
135        }
136    }
137
138    /// Create a new RPC client with custom retry configuration.
139    pub fn with_retry_config(url: impl Into<String>, retry_config: RetryConfig) -> Self {
140        Self {
141            url: url.into(),
142            client: reqwest::Client::new(),
143            retry_config,
144            request_id: AtomicU64::new(0),
145        }
146    }
147
148    /// Get the RPC URL.
149    pub fn url(&self) -> &str {
150        &self.url
151    }
152
153    /// Make a raw RPC call with retries.
154    #[tracing::instrument(skip(self, params), fields(rpc.method = method, rpc.url = %sanitize_url(&self.url)))]
155    pub async fn call<P: Serialize, R: DeserializeOwned>(
156        &self,
157        method: &str,
158        params: P,
159    ) -> Result<R, RpcError> {
160        let total_attempts = self.retry_config.max_retries + 1;
161
162        for attempt in 0..total_attempts {
163            let request_id = self.request_id.fetch_add(1, Ordering::Relaxed);
164
165            let request = JsonRpcRequest {
166                jsonrpc: "2.0",
167                id: request_id,
168                method,
169                params: &params,
170            };
171
172            match self.try_call::<R>(&request).await {
173                Ok(result) => return Ok(result),
174                Err(e) if e.is_retryable() && attempt < total_attempts - 1 => {
175                    let delay = std::cmp::min(
176                        self.retry_config.initial_delay_ms * 2u64.pow(attempt),
177                        self.retry_config.max_delay_ms,
178                    );
179                    tracing::warn!(
180                        attempt = attempt + 1,
181                        max_attempts = total_attempts,
182                        delay_ms = delay,
183                        error = %e,
184                        "RPC request failed, retrying"
185                    );
186                    tokio::time::sleep(Duration::from_millis(delay)).await;
187                    continue;
188                }
189                Err(e) => {
190                    tracing::error!(error = %e, "RPC request failed");
191                    return Err(e);
192                }
193            }
194        }
195
196        unreachable!("all loop iterations return")
197    }
198
199    /// Single attempt to make an RPC call.
200    async fn try_call<R: DeserializeOwned>(
201        &self,
202        request: &JsonRpcRequest<'_, impl Serialize>,
203    ) -> Result<R, RpcError> {
204        if tracing::enabled!(tracing::Level::TRACE) {
205            if let Ok(json) = serde_json::to_string(request) {
206                tracing::trace!(payload = %json, "RPC request");
207            }
208        }
209
210        let response = self
211            .client
212            .post(&self.url)
213            .header("Content-Type", "application/json")
214            .json(request)
215            .send()
216            .await?;
217
218        let status = response.status();
219        let body = response.text().await?;
220
221        tracing::trace!(payload = %body, "RPC response");
222
223        if !status.is_success() {
224            let retryable = is_retryable_status(status.as_u16());
225            return Err(RpcError::network(
226                format!("HTTP {}: {}", status, body),
227                Some(status.as_u16()),
228                retryable,
229            ));
230        }
231
232        let rpc_response: JsonRpcResponse = serde_json::from_str(&body).map_err(RpcError::Json)?;
233
234        if let Some(error) = rpc_response.error {
235            return Err(self.parse_rpc_error(&error));
236        }
237
238        let result_value = rpc_response
239            .result
240            .ok_or_else(|| RpcError::InvalidResponse("Missing result in response".to_string()))?;
241
242        // NEAR's `query` endpoint sometimes returns errors inside the result
243        // object (with an "error" field) instead of in the JSON-RPC error
244        // envelope. Only check for this on the `query` method to avoid
245        // misclassifying legitimate results from other methods.
246        if request.method == "query" {
247            if let Some(error_str) = result_value.get("error").and_then(|e| e.as_str()) {
248                let synthetic = JsonRpcError {
249                    // Use -32600 (Invalid Request) rather than -32000 (Server Error)
250                    // so deterministic failures don't get retried.
251                    code: -32600,
252                    message: error_str.to_string(),
253                    data: Some(serde_json::Value::String(error_str.to_string())),
254                    cause: None,
255                    name: None,
256                };
257                return Err(self.parse_rpc_error(&synthetic));
258            }
259        }
260
261        serde_json::from_value(result_value).map_err(RpcError::Json)
262    }
263
264    /// Parse an RPC error into a specific error type.
265    fn parse_rpc_error(&self, error: &JsonRpcError) -> RpcError {
266        // First, check the direct cause field (NEAR RPC structured errors)
267        if let Some(cause) = &error.cause {
268            let cause_name = cause.name.as_str();
269            let info = cause.info.as_ref();
270            let data = &error.data;
271
272            match cause_name {
273                "UNKNOWN_ACCOUNT" => {
274                    if let Some(account_id) = info
275                        .and_then(|i| i.get("requested_account_id"))
276                        .and_then(|a| a.as_str())
277                    {
278                        if let Ok(account_id) = account_id.parse() {
279                            return RpcError::AccountNotFound(account_id);
280                        }
281                    }
282                }
283                "INVALID_ACCOUNT" => {
284                    let account_id = info
285                        .and_then(|i| i.get("requested_account_id"))
286                        .and_then(|a| a.as_str())
287                        .unwrap_or("unknown");
288                    return RpcError::InvalidAccount(account_id.to_string());
289                }
290                "UNKNOWN_ACCESS_KEY" => {
291                    if let Some(public_key) = info
292                        .and_then(|i| i.get("public_key"))
293                        .and_then(|k| k.as_str())
294                        .and_then(|k| k.parse().ok())
295                    {
296                        // Legacy `query` includes requested_account_id;
297                        // EXPERIMENTAL_view_access_key does not (the caller
298                        // already knows the account). Fall back to "unknown".
299                        let account_id = info
300                            .and_then(|i| i.get("requested_account_id"))
301                            .and_then(|a| a.as_str())
302                            .and_then(|a| a.parse().ok())
303                            .unwrap_or_else(|| "unknown".parse().unwrap());
304                        return RpcError::AccessKeyNotFound {
305                            account_id,
306                            public_key,
307                        };
308                    }
309                }
310                "UNKNOWN_BLOCK" => {
311                    let block_ref = data
312                        .as_ref()
313                        .and_then(|d| d.as_str())
314                        .unwrap_or(&error.message);
315                    return RpcError::UnknownBlock(block_ref.to_string());
316                }
317                "UNKNOWN_CHUNK" => {
318                    let chunk_ref = info
319                        .and_then(|i| i.get("chunk_hash"))
320                        .and_then(|c| c.as_str())
321                        .unwrap_or(&error.message);
322                    return RpcError::UnknownChunk(chunk_ref.to_string());
323                }
324                "UNKNOWN_EPOCH" => {
325                    let block_ref = data
326                        .as_ref()
327                        .and_then(|d| d.as_str())
328                        .unwrap_or(&error.message);
329                    return RpcError::UnknownEpoch(block_ref.to_string());
330                }
331                "UNKNOWN_RECEIPT" => {
332                    let receipt_id = info
333                        .and_then(|i| i.get("receipt_id"))
334                        .and_then(|r| r.as_str())
335                        .unwrap_or("unknown");
336                    return RpcError::UnknownReceipt(receipt_id.to_string());
337                }
338                "NO_CONTRACT_CODE" => {
339                    let account_id = info
340                        .and_then(|i| {
341                            i.get("contract_account_id")
342                                .or_else(|| i.get("account_id"))
343                                .or_else(|| i.get("contract_id"))
344                        })
345                        .and_then(|a| a.as_str())
346                        .unwrap_or("unknown");
347                    if let Ok(account_id) = account_id.parse() {
348                        return RpcError::ContractNotDeployed(account_id);
349                    }
350                }
351                "TOO_LARGE_CONTRACT_STATE" => {
352                    let account_id = info
353                        .and_then(|i| i.get("account_id").or_else(|| i.get("contract_id")))
354                        .and_then(|a| a.as_str())
355                        .unwrap_or("unknown");
356                    if let Ok(account_id) = account_id.parse() {
357                        return RpcError::ContractStateTooLarge(account_id);
358                    }
359                }
360                "CONTRACT_EXECUTION_ERROR" => {
361                    // Check for CodeDoesNotExist inside vm_error
362                    // (EXPERIMENTAL_call_function wraps this as CONTRACT_EXECUTION_ERROR)
363                    if let Some(vm_error) = info.and_then(|i| i.get("vm_error")) {
364                        if let Some(compilation_err) = vm_error.get("CompilationError") {
365                            if let Some(code_not_exist) = compilation_err.get("CodeDoesNotExist") {
366                                if let Some(account_id) = code_not_exist
367                                    .get("account_id")
368                                    .and_then(|a| a.as_str())
369                                    .and_then(|a| a.parse().ok())
370                                {
371                                    return RpcError::ContractNotDeployed(account_id);
372                                }
373                            }
374                        }
375                    }
376
377                    // Legacy `query` includes contract_id/method_name;
378                    // EXPERIMENTAL_call_function does not (the caller
379                    // already knows them). Fall back to "unknown".
380                    let contract_id = info
381                        .and_then(|i| i.get("contract_id"))
382                        .and_then(|c| c.as_str())
383                        .unwrap_or("unknown");
384                    let method_name = info
385                        .and_then(|i| i.get("method_name"))
386                        .and_then(|m| m.as_str())
387                        .map(String::from);
388                    let message = data
389                        .as_ref()
390                        .and_then(|d| d.as_str())
391                        .map(|s| s.to_string())
392                        .or_else(|| {
393                            // EXPERIMENTAL endpoint: use vm_error as fallback
394                            // when data isn't a string
395                            info.and_then(|i| i.get("vm_error")).map(|v| v.to_string())
396                        })
397                        .unwrap_or_else(|| error.message.clone());
398                    if let Ok(contract_id) = contract_id.parse() {
399                        return RpcError::ContractExecution {
400                            contract_id,
401                            method_name,
402                            message,
403                        };
404                    }
405                }
406                "UNAVAILABLE_SHARD" => {
407                    return RpcError::ShardUnavailable(error.message.clone());
408                }
409                "NO_SYNCED_BLOCKS" | "NOT_SYNCED_YET" => {
410                    return RpcError::NodeNotSynced(error.message.clone());
411                }
412                "INVALID_SHARD_ID" => {
413                    let shard_id = info
414                        .and_then(|i| i.get("shard_id"))
415                        .map(|s| s.to_string())
416                        .unwrap_or_else(|| "unknown".to_string());
417                    return RpcError::InvalidShardId(shard_id);
418                }
419                "INVALID_TRANSACTION" => {
420                    return RpcError::invalid_transaction(&error.message, data.clone());
421                }
422                "TIMEOUT_ERROR" => {
423                    let tx_hash = info
424                        .and_then(|i| i.get("transaction_hash"))
425                        .and_then(|h| h.as_str())
426                        .map(String::from);
427                    return RpcError::RequestTimeout {
428                        message: error.message.clone(),
429                        transaction_hash: tx_hash,
430                    };
431                }
432                "PARSE_ERROR" => {
433                    return RpcError::ParseError(error.message.clone());
434                }
435                "INTERNAL_ERROR" => {
436                    return RpcError::InternalError(error.message.clone());
437                }
438                _ => {}
439            }
440        }
441
442        // Fallback: check for string error messages in data field
443        if let Some(data) = &error.data {
444            if let Some(error_str) = data.as_str() {
445                if error_str.contains("does not exist") {
446                    // Try to extract account ID from error message
447                    // Format: "account X does not exist while viewing"
448                    if let Some(start) = error_str.strip_prefix("account ") {
449                        if let Some(account_str) = start.split_whitespace().next() {
450                            if let Ok(account_id) = account_str.parse() {
451                                return RpcError::AccountNotFound(account_id);
452                            }
453                        }
454                    }
455                }
456            }
457        }
458
459        RpcError::Rpc {
460            code: error.code,
461            message: error.message.clone(),
462            data: error.data.clone(),
463        }
464    }
465
466    // ========================================================================
467    // High-level RPC methods
468    // ========================================================================
469
470    /// View account information.
471    #[tracing::instrument(skip(self, block), fields(%account_id))]
472    pub async fn view_account(
473        &self,
474        account_id: &AccountId,
475        block: BlockReference,
476    ) -> Result<AccountView, RpcError> {
477        let mut params = serde_json::json!({
478            "account_id": account_id.to_string(),
479        });
480        self.merge_block_reference(&mut params, &block);
481        self.call("EXPERIMENTAL_view_account", params).await
482    }
483
484    /// View access key information.
485    #[tracing::instrument(skip(self, block), fields(%account_id, %public_key))]
486    pub async fn view_access_key(
487        &self,
488        account_id: &AccountId,
489        public_key: &PublicKey,
490        block: BlockReference,
491    ) -> Result<AccessKeyView, RpcError> {
492        let mut params = serde_json::json!({
493            "account_id": account_id.to_string(),
494            "public_key": public_key.to_string(),
495        });
496        self.merge_block_reference(&mut params, &block);
497        self.call("EXPERIMENTAL_view_access_key", params)
498            .await
499            .map_err(|e| match e {
500                // The EXPERIMENTAL endpoint's UNKNOWN_ACCESS_KEY error omits
501                // the account_id from its info payload. Patch it in from the
502                // request params so callers get a complete error.
503                RpcError::AccessKeyNotFound { public_key, .. } => RpcError::AccessKeyNotFound {
504                    account_id: account_id.clone(),
505                    public_key,
506                },
507                other => other,
508            })
509    }
510
511    /// View all access keys for an account.
512    #[tracing::instrument(skip(self, block), fields(%account_id))]
513    pub async fn view_access_key_list(
514        &self,
515        account_id: &AccountId,
516        block: BlockReference,
517    ) -> Result<AccessKeyListView, RpcError> {
518        let mut params = serde_json::json!({
519            "account_id": account_id.to_string(),
520        });
521        self.merge_block_reference(&mut params, &block);
522        self.call("EXPERIMENTAL_view_access_key_list", params).await
523    }
524
525    /// Call a view function on a contract.
526    #[tracing::instrument(skip(self, args, block), fields(contract_id = %account_id, method = method_name))]
527    pub async fn view_function(
528        &self,
529        account_id: &AccountId,
530        method_name: &str,
531        args: &[u8],
532        block: BlockReference,
533    ) -> Result<ViewFunctionResult, RpcError> {
534        let mut params = serde_json::json!({
535            "account_id": account_id.to_string(),
536            "method_name": method_name,
537            "args_base64": STANDARD.encode(args),
538        });
539        self.merge_block_reference(&mut params, &block);
540
541        // EXPERIMENTAL_call_function returns errors through the JSON-RPC
542        // error envelope, so `parse_rpc_error` handles them. Patch in
543        // the caller-known contract_id and method_name when they are
544        // missing from the error info (the experimental endpoint omits them).
545        let response: CallFunctionResponse = self
546            .call("EXPERIMENTAL_call_function", params)
547            .await
548            .map_err(|e| match e {
549                RpcError::ContractExecution { message, .. } => RpcError::ContractExecution {
550                    contract_id: account_id.clone(),
551                    method_name: Some(method_name.to_string()),
552                    message,
553                },
554                other => other,
555            })?;
556
557        Ok(ViewFunctionResult {
558            result: response.result,
559            logs: response.logs,
560            block_height: response.block_height,
561            block_hash: response.block_hash,
562        })
563    }
564
565    /// Get block information.
566    #[tracing::instrument(skip(self, block))]
567    pub async fn block(&self, block: BlockReference) -> Result<BlockView, RpcError> {
568        let params = block.to_rpc_params();
569        self.call("block", params).await
570    }
571
572    /// Get node status.
573    #[tracing::instrument(skip(self))]
574    pub async fn status(&self) -> Result<StatusResponse, RpcError> {
575        self.call("status", serde_json::json!([])).await
576    }
577
578    /// Get current gas price.
579    #[tracing::instrument(skip(self))]
580    pub async fn gas_price(&self, block_hash: Option<&CryptoHash>) -> Result<GasPrice, RpcError> {
581        let params = match block_hash {
582            Some(hash) => serde_json::json!([hash.to_string()]),
583            None => serde_json::json!([serde_json::Value::Null]),
584        };
585        self.call("gas_price", params).await
586    }
587
588    /// Get validator information for an epoch.
589    ///
590    /// Pass `None` for the latest epoch, or a block height/hash to query a
591    /// specific epoch. Finality and sync-checkpoint variants are treated as
592    /// latest (the `validators` RPC accepts only `block_id` or `null`).
593    #[tracing::instrument(skip(self))]
594    pub async fn validators(
595        &self,
596        block: Option<BlockReference>,
597    ) -> Result<EpochValidatorInfo, RpcError> {
598        let params = serde_json::json!([block_id_or_null(block.as_ref())]);
599        self.call("validators", params).await
600    }
601
602    /// Send a signed transaction.
603    #[tracing::instrument(skip(self, signed_tx), fields(
604        tx_hash = tracing::field::Empty,
605        sender = %signed_tx.transaction.signer_id,
606        receiver = %signed_tx.transaction.receiver_id,
607        ?wait_until,
608    ))]
609    pub async fn send_tx(
610        &self,
611        signed_tx: &SignedTransaction,
612        wait_until: TxExecutionStatus,
613    ) -> Result<RawTransactionResponse, RpcError> {
614        let tx_hash = signed_tx.get_hash();
615        tracing::Span::current().record("tx_hash", tracing::field::display(&tx_hash));
616        let params = serde_json::json!({
617            "signed_tx_base64": signed_tx.to_base64(),
618            "wait_until": wait_until.as_str(),
619        });
620        let mut response: RawTransactionResponse = self.call("send_tx", params).await?;
621        response.transaction_hash = tx_hash;
622        Ok(response)
623    }
624
625    /// Get transaction status with full receipt details.
626    ///
627    /// Uses `EXPERIMENTAL_tx_status` which returns complete receipt information.
628    /// When the transaction has been executed, the outcome's `receipts` field
629    /// will be populated (unlike `send_tx` which leaves it empty).
630    #[tracing::instrument(skip(self), fields(%tx_hash, sender = %sender_id, ?wait_until))]
631    pub async fn tx_status(
632        &self,
633        tx_hash: &CryptoHash,
634        sender_id: &AccountId,
635        wait_until: TxExecutionStatus,
636    ) -> Result<RawTransactionResponse, RpcError> {
637        let params = serde_json::json!({
638            "tx_hash": tx_hash.to_string(),
639            "sender_account_id": sender_id.to_string(),
640            "wait_until": wait_until.as_str(),
641        });
642        let mut response: RawTransactionResponse =
643            self.call("EXPERIMENTAL_tx_status", params).await?;
644        response.transaction_hash = response
645            .outcome
646            .as_ref()
647            .map(|o| *o.transaction_hash())
648            .unwrap_or(*tx_hash);
649        Ok(response)
650    }
651
652    /// Merge block reference parameters into a JSON object.
653    fn merge_block_reference(&self, params: &mut serde_json::Value, block: &BlockReference) {
654        if let serde_json::Value::Object(block_params) = block.to_rpc_params() {
655            if let serde_json::Value::Object(map) = params {
656                map.extend(block_params);
657            }
658        }
659    }
660
661    // ========================================================================
662    // Sandbox-only methods
663    // ========================================================================
664
665    /// Patch account state in sandbox.
666    ///
667    /// This is a sandbox-only method that allows modifying account state directly,
668    /// useful for testing scenarios that require specific account configurations
669    /// (e.g., setting a high balance for staking tests).
670    ///
671    /// # Arguments
672    ///
673    /// * `records` - State records to patch (Account, Data, Contract, AccessKey, etc.)
674    ///
675    /// # Example
676    ///
677    /// ```rust,ignore
678    /// // Set account balance to 1M NEAR
679    /// rpc.sandbox_patch_state(serde_json::json!([
680    ///     {
681    ///         "Account": {
682    ///             "account_id": "alice.sandbox",
683    ///             "account": {
684    ///                 "amount": "1000000000000000000000000000000",
685    ///                 "locked": "0",
686    ///                 "code_hash": "11111111111111111111111111111111",
687    ///                 "storage_usage": 182
688    ///             }
689    ///         }
690    ///     }
691    /// ])).await?;
692    /// ```
693    /// Fast-forward the sandbox by `delta_height` blocks.
694    ///
695    /// This is useful for testing time-dependent logic (e.g., lockups, staking
696    /// epoch changes) without waiting for real block production.
697    ///
698    /// **Note:** This can take a while for large deltas — the sandbox node
699    /// internally produces all intermediate blocks. The RPC call will block
700    /// until fast-forwarding completes (up to 1 hour server-side timeout).
701    ///
702    /// # Example
703    ///
704    /// ```rust,ignore
705    /// // Advance the sandbox by 1000 blocks
706    /// rpc.sandbox_fast_forward(1000).await?;
707    /// ```
708    pub async fn sandbox_fast_forward(&self, delta_height: u64) -> Result<(), RpcError> {
709        let params = serde_json::json!({
710            "delta_height": delta_height,
711        });
712
713        let _: serde_json::Value = self.call("sandbox_fast_forward", params).await?;
714        Ok(())
715    }
716
717    pub async fn sandbox_patch_state(&self, records: serde_json::Value) -> Result<(), RpcError> {
718        let params = serde_json::json!({
719            "records": records,
720        });
721
722        // The sandbox_patch_state method returns an empty result on success
723        let _: serde_json::Value = self.call("sandbox_patch_state", params).await?;
724
725        // NOTE: For some reason, patching account-related items sometimes requires
726        // sending the patch twice for it to take effect reliably.
727        // See: https://github.com/near/near-workspaces-rs/commit/2b72b9b8491c3140ff2d30b0c45d09b200cb027b
728        let _: serde_json::Value = self
729            .call(
730                "sandbox_patch_state",
731                serde_json::json!({
732                    "records": records,
733                }),
734            )
735            .await?;
736
737        // Small delay to allow state to propagate - sandbox patch_state has race conditions
738        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
739
740        Ok(())
741    }
742}
743
744impl Clone for RpcClient {
745    fn clone(&self) -> Self {
746        Self {
747            url: self.url.clone(),
748            client: self.client.clone(),
749            retry_config: self.retry_config.clone(),
750            request_id: AtomicU64::new(0),
751        }
752    }
753}
754
755impl std::fmt::Debug for RpcClient {
756    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
757        f.debug_struct("RpcClient")
758            .field("url", &self.url)
759            .field("retry_config", &self.retry_config)
760            .finish()
761    }
762}
763
764// ============================================================================
765// Helper functions
766// ============================================================================
767
768/// Convert a [`BlockReference`] to a `block_id` value for RPC methods that
769/// accept only positional `block_id` or `null` (e.g. `validators`,
770/// `EXPERIMENTAL_validators_ordered`).
771///
772/// Height and hash variants are forwarded as-is; finality and sync-checkpoint
773/// variants become `null` (latest).
774fn block_id_or_null(block: Option<&BlockReference>) -> serde_json::Value {
775    match block {
776        Some(BlockReference::Height(h)) => serde_json::json!(*h),
777        Some(BlockReference::Hash(h)) => serde_json::json!(h.to_string()),
778        _ => serde_json::Value::Null,
779    }
780}
781
782/// Strip query string, fragment, and userinfo from a URL for safe logging.
783///
784/// RPC provider URLs may carry API keys as query parameters or path tokens.
785/// This returns `scheme://host/path` so credentials don't leak into tracing spans.
786fn sanitize_url(url: &str) -> &str {
787    // Strip query and fragment
788    let end = url.find('?').or_else(|| url.find('#')).unwrap_or(url.len());
789    &url[..end]
790}
791
792/// Check if an HTTP status code is retryable.
793fn is_retryable_status(status: u16) -> bool {
794    // 408 Request Timeout - retryable
795    // 429 Too Many Requests - retryable (rate limiting)
796    // 503 Service Unavailable - retryable
797    // 5xx Server Errors - retryable
798    status == 408 || status == 429 || status == 503 || (500..600).contains(&status)
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    // ========================================================================
806    // RetryConfig tests
807    // ========================================================================
808
809    #[test]
810    fn test_retry_config_default() {
811        let config = RetryConfig::default();
812        assert_eq!(config.max_retries, 3);
813        assert_eq!(config.initial_delay_ms, 500);
814        assert_eq!(config.max_delay_ms, 5000);
815    }
816
817    #[test]
818    fn test_retry_config_clone() {
819        let config = RetryConfig {
820            max_retries: 5,
821            initial_delay_ms: 100,
822            max_delay_ms: 1000,
823        };
824        let cloned = config.clone();
825        assert_eq!(cloned.max_retries, 5);
826        assert_eq!(cloned.initial_delay_ms, 100);
827        assert_eq!(cloned.max_delay_ms, 1000);
828    }
829
830    #[test]
831    fn test_retry_config_debug() {
832        let config = RetryConfig::default();
833        let debug = format!("{:?}", config);
834        assert!(debug.contains("RetryConfig"));
835        assert!(debug.contains("max_retries"));
836    }
837
838    // ========================================================================
839    // RpcClient tests
840    // ========================================================================
841
842    #[test]
843    fn test_rpc_client_new() {
844        let client = RpcClient::new("https://rpc.testnet.near.org");
845        assert_eq!(client.url(), "https://rpc.testnet.near.org");
846    }
847
848    #[test]
849    fn test_rpc_client_with_retry_config() {
850        let config = RetryConfig {
851            max_retries: 5,
852            initial_delay_ms: 100,
853            max_delay_ms: 1000,
854        };
855        let client = RpcClient::with_retry_config("https://rpc.example.com", config);
856        assert_eq!(client.url(), "https://rpc.example.com");
857    }
858
859    #[test]
860    fn test_rpc_client_clone() {
861        let client = RpcClient::new("https://rpc.testnet.near.org");
862        let cloned = client.clone();
863        assert_eq!(cloned.url(), client.url());
864    }
865
866    #[test]
867    fn test_rpc_client_debug() {
868        let client = RpcClient::new("https://rpc.testnet.near.org");
869        let debug = format!("{:?}", client);
870        assert!(debug.contains("RpcClient"));
871        assert!(debug.contains("rpc.testnet.near.org"));
872    }
873
874    // ========================================================================
875    // sanitize_url tests
876    // ========================================================================
877
878    #[test]
879    fn test_sanitize_url_plain() {
880        assert_eq!(
881            sanitize_url("https://rpc.mainnet.near.org"),
882            "https://rpc.mainnet.near.org"
883        );
884    }
885
886    #[test]
887    fn test_sanitize_url_strips_query() {
888        assert_eq!(
889            sanitize_url("https://rpc.provider.com/v1?api_key=secret123"),
890            "https://rpc.provider.com/v1"
891        );
892    }
893
894    #[test]
895    fn test_sanitize_url_strips_fragment() {
896        assert_eq!(
897            sanitize_url("https://rpc.provider.com/v1#section"),
898            "https://rpc.provider.com/v1"
899        );
900    }
901
902    #[test]
903    fn test_sanitize_url_strips_query_and_fragment() {
904        assert_eq!(
905            sanitize_url("https://rpc.provider.com/v1?key=val#frag"),
906            "https://rpc.provider.com/v1"
907        );
908    }
909
910    // ========================================================================
911    // is_retryable_status tests
912    // ========================================================================
913
914    #[test]
915    fn test_is_retryable_status() {
916        // Retryable statuses
917        assert!(is_retryable_status(408)); // Request Timeout
918        assert!(is_retryable_status(429)); // Too Many Requests
919        assert!(is_retryable_status(500)); // Internal Server Error
920        assert!(is_retryable_status(502)); // Bad Gateway
921        assert!(is_retryable_status(503)); // Service Unavailable
922        assert!(is_retryable_status(504)); // Gateway Timeout
923        assert!(is_retryable_status(599)); // Edge of 5xx range
924
925        // Non-retryable statuses
926        assert!(!is_retryable_status(200)); // OK
927        assert!(!is_retryable_status(201)); // Created
928        assert!(!is_retryable_status(400)); // Bad Request
929        assert!(!is_retryable_status(401)); // Unauthorized
930        assert!(!is_retryable_status(403)); // Forbidden
931        assert!(!is_retryable_status(404)); // Not Found
932        assert!(!is_retryable_status(422)); // Unprocessable Entity
933    }
934
935    // ========================================================================
936    // InvalidTxError parsing tests
937    // ========================================================================
938
939    #[test]
940    fn test_invalid_transaction_parses_invalid_nonce() {
941        use crate::types::InvalidTxError;
942        let data = serde_json::json!({
943            "TxExecutionError": {
944                "InvalidTxError": {
945                    "InvalidNonce": {
946                        "tx_nonce": 5,
947                        "ak_nonce": 10
948                    }
949                }
950            }
951        });
952        let err = RpcError::invalid_transaction("invalid nonce", Some(data));
953        match err {
954            RpcError::InvalidTx(InvalidTxError::InvalidNonce { tx_nonce, ak_nonce }) => {
955                assert_eq!(tx_nonce, 5);
956                assert_eq!(ak_nonce, 10);
957            }
958            other => panic!("Expected InvalidTx(InvalidNonce), got: {other:?}"),
959        }
960    }
961
962    #[test]
963    fn test_invalid_transaction_parses_top_level_invalid_tx() {
964        use crate::types::InvalidTxError;
965        // Some RPC versions put InvalidTxError at the top level
966        let data = serde_json::json!({
967            "InvalidTxError": {
968                "NotEnoughBalance": {
969                    "signer_id": "alice.near",
970                    "balance": "1000000000000000000000000",
971                    "cost": "9000000000000000000000000"
972                }
973            }
974        });
975        let err = RpcError::invalid_transaction("insufficient balance", Some(data));
976        assert!(
977            matches!(
978                err,
979                RpcError::InvalidTx(InvalidTxError::NotEnoughBalance { .. })
980            ),
981            "Expected InvalidTx(NotEnoughBalance), got: {err:?}"
982        );
983    }
984
985    #[test]
986    fn test_invalid_transaction_falls_back_on_unparseable() {
987        // When data doesn't contain a parseable InvalidTxError, falls back
988        let data = serde_json::json!({ "SomeOtherError": {} });
989        let err = RpcError::invalid_transaction("some error", Some(data));
990        assert!(matches!(err, RpcError::InvalidTransaction { .. }));
991    }
992
993    // ========================================================================
994    // NetworkConfig tests
995    // ========================================================================
996
997    #[test]
998    fn test_mainnet_config() {
999        assert!(MAINNET.rpc_url.contains("fastnear"));
1000        assert_eq!(MAINNET.network_id, "mainnet");
1001    }
1002
1003    #[test]
1004    fn test_testnet_config() {
1005        assert!(TESTNET.rpc_url.contains("fastnear") || TESTNET.rpc_url.contains("test"));
1006        assert_eq!(TESTNET.network_id, "testnet");
1007    }
1008
1009    // ========================================================================
1010    // parse_rpc_error tests (via RpcClient)
1011    // ========================================================================
1012
1013    #[test]
1014    fn test_parse_rpc_error_unknown_account() {
1015        let client = RpcClient::new("https://example.com");
1016        let error = JsonRpcError {
1017            code: -32000,
1018            message: "Server error".to_string(),
1019            data: None,
1020            cause: Some(ErrorCause {
1021                name: "UNKNOWN_ACCOUNT".to_string(),
1022                info: Some(serde_json::json!({
1023                    "requested_account_id": "nonexistent.near"
1024                })),
1025            }),
1026            name: None,
1027        };
1028        let result = client.parse_rpc_error(&error);
1029        assert!(matches!(result, RpcError::AccountNotFound(_)));
1030    }
1031
1032    #[test]
1033    fn test_parse_rpc_error_unknown_access_key_legacy() {
1034        // Legacy `query` endpoint includes requested_account_id in info
1035        let client = RpcClient::new("https://example.com");
1036        let error = JsonRpcError {
1037            code: -32000,
1038            message: "Server error".to_string(),
1039            data: None,
1040            cause: Some(ErrorCause {
1041                name: "UNKNOWN_ACCESS_KEY".to_string(),
1042                info: Some(serde_json::json!({
1043                    "requested_account_id": "alice.near",
1044                    "public_key": "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp"
1045                })),
1046            }),
1047            name: None,
1048        };
1049        let result = client.parse_rpc_error(&error);
1050        match result {
1051            RpcError::AccessKeyNotFound {
1052                account_id,
1053                public_key,
1054            } => {
1055                assert_eq!(account_id.as_str(), "alice.near");
1056                assert!(public_key.to_string().contains("ed25519:"));
1057            }
1058            _ => panic!("Expected AccessKeyNotFound error, got {:?}", result),
1059        }
1060    }
1061
1062    #[test]
1063    fn test_parse_rpc_error_unknown_access_key_experimental() {
1064        // EXPERIMENTAL_view_access_key omits requested_account_id from info
1065        let client = RpcClient::new("https://example.com");
1066        let error = JsonRpcError {
1067            code: -32000,
1068            message: "Server error".to_string(),
1069            data: Some(serde_json::Value::String(
1070                "Access key for public key ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp does not exist while viewing".to_string()
1071            )),
1072            cause: Some(ErrorCause {
1073                name: "UNKNOWN_ACCESS_KEY".to_string(),
1074                info: Some(serde_json::json!({
1075                    "public_key": "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp",
1076                    "block_height": 243789592,
1077                    "block_hash": "EC5A7qc6rixfN8T4T9Gkt78H5pAsvdcjAos8Z7kFLJgi"
1078                })),
1079            }),
1080            name: Some("HANDLER_ERROR".to_string()),
1081        };
1082        let result = client.parse_rpc_error(&error);
1083        match result {
1084            RpcError::AccessKeyNotFound {
1085                account_id,
1086                public_key,
1087            } => {
1088                // account_id falls back to "unknown" — caller enriches it
1089                assert_eq!(account_id.as_str(), "unknown");
1090                assert!(public_key.to_string().contains("ed25519:"));
1091            }
1092            _ => panic!("Expected AccessKeyNotFound error, got {:?}", result),
1093        }
1094    }
1095
1096    #[test]
1097    fn test_parse_rpc_error_invalid_account() {
1098        let client = RpcClient::new("https://example.com");
1099        let error = JsonRpcError {
1100            code: -32000,
1101            message: "Server error".to_string(),
1102            data: None,
1103            cause: Some(ErrorCause {
1104                name: "INVALID_ACCOUNT".to_string(),
1105                info: Some(serde_json::json!({
1106                    "requested_account_id": "invalid@account"
1107                })),
1108            }),
1109            name: None,
1110        };
1111        let result = client.parse_rpc_error(&error);
1112        assert!(matches!(result, RpcError::InvalidAccount(_)));
1113    }
1114
1115    #[test]
1116    fn test_parse_rpc_error_unknown_block() {
1117        let client = RpcClient::new("https://example.com");
1118        let error = JsonRpcError {
1119            code: -32000,
1120            message: "Block not found".to_string(),
1121            data: Some(serde_json::json!("12345")),
1122            cause: Some(ErrorCause {
1123                name: "UNKNOWN_BLOCK".to_string(),
1124                info: None,
1125            }),
1126            name: None,
1127        };
1128        let result = client.parse_rpc_error(&error);
1129        assert!(matches!(result, RpcError::UnknownBlock(_)));
1130    }
1131
1132    #[test]
1133    fn test_parse_rpc_error_unknown_chunk() {
1134        let client = RpcClient::new("https://example.com");
1135        let error = JsonRpcError {
1136            code: -32000,
1137            message: "Chunk not found".to_string(),
1138            data: None,
1139            cause: Some(ErrorCause {
1140                name: "UNKNOWN_CHUNK".to_string(),
1141                info: Some(serde_json::json!({
1142                    "chunk_hash": "abc123"
1143                })),
1144            }),
1145            name: None,
1146        };
1147        let result = client.parse_rpc_error(&error);
1148        assert!(matches!(result, RpcError::UnknownChunk(_)));
1149    }
1150
1151    #[test]
1152    fn test_parse_rpc_error_unknown_epoch() {
1153        let client = RpcClient::new("https://example.com");
1154        let error = JsonRpcError {
1155            code: -32000,
1156            message: "Epoch not found".to_string(),
1157            data: Some(serde_json::json!("epoch123")),
1158            cause: Some(ErrorCause {
1159                name: "UNKNOWN_EPOCH".to_string(),
1160                info: None,
1161            }),
1162            name: None,
1163        };
1164        let result = client.parse_rpc_error(&error);
1165        assert!(matches!(result, RpcError::UnknownEpoch(_)));
1166    }
1167
1168    #[test]
1169    fn test_parse_rpc_error_unknown_receipt() {
1170        let client = RpcClient::new("https://example.com");
1171        let error = JsonRpcError {
1172            code: -32000,
1173            message: "Receipt not found".to_string(),
1174            data: None,
1175            cause: Some(ErrorCause {
1176                name: "UNKNOWN_RECEIPT".to_string(),
1177                info: Some(serde_json::json!({
1178                    "receipt_id": "receipt123"
1179                })),
1180            }),
1181            name: None,
1182        };
1183        let result = client.parse_rpc_error(&error);
1184        assert!(matches!(result, RpcError::UnknownReceipt(_)));
1185    }
1186
1187    #[test]
1188    fn test_parse_rpc_error_no_contract_code() {
1189        let client = RpcClient::new("https://example.com");
1190        let error = JsonRpcError {
1191            code: -32000,
1192            message: "No contract code".to_string(),
1193            data: None,
1194            cause: Some(ErrorCause {
1195                name: "NO_CONTRACT_CODE".to_string(),
1196                info: Some(serde_json::json!({
1197                    "contract_account_id": "no-contract.near"
1198                })),
1199            }),
1200            name: None,
1201        };
1202        let result = client.parse_rpc_error(&error);
1203        assert!(matches!(result, RpcError::ContractNotDeployed(_)));
1204    }
1205
1206    #[test]
1207    fn test_parse_rpc_error_too_large_contract_state() {
1208        let client = RpcClient::new("https://example.com");
1209        let error = JsonRpcError {
1210            code: -32000,
1211            message: "Contract state too large".to_string(),
1212            data: None,
1213            cause: Some(ErrorCause {
1214                name: "TOO_LARGE_CONTRACT_STATE".to_string(),
1215                info: Some(serde_json::json!({
1216                    "account_id": "large-state.near"
1217                })),
1218            }),
1219            name: None,
1220        };
1221        let result = client.parse_rpc_error(&error);
1222        assert!(matches!(result, RpcError::ContractStateTooLarge(_)));
1223    }
1224
1225    #[test]
1226    fn test_parse_rpc_error_unavailable_shard() {
1227        let client = RpcClient::new("https://example.com");
1228        let error = JsonRpcError {
1229            code: -32000,
1230            message: "Shard unavailable".to_string(),
1231            data: None,
1232            cause: Some(ErrorCause {
1233                name: "UNAVAILABLE_SHARD".to_string(),
1234                info: None,
1235            }),
1236            name: None,
1237        };
1238        let result = client.parse_rpc_error(&error);
1239        assert!(matches!(result, RpcError::ShardUnavailable(_)));
1240    }
1241
1242    #[test]
1243    fn test_parse_rpc_error_not_synced() {
1244        let client = RpcClient::new("https://example.com");
1245
1246        // NO_SYNCED_BLOCKS
1247        let error = JsonRpcError {
1248            code: -32000,
1249            message: "No synced blocks".to_string(),
1250            data: None,
1251            cause: Some(ErrorCause {
1252                name: "NO_SYNCED_BLOCKS".to_string(),
1253                info: None,
1254            }),
1255            name: None,
1256        };
1257        let result = client.parse_rpc_error(&error);
1258        assert!(matches!(result, RpcError::NodeNotSynced(_)));
1259
1260        // NOT_SYNCED_YET
1261        let error = JsonRpcError {
1262            code: -32000,
1263            message: "Not synced yet".to_string(),
1264            data: None,
1265            cause: Some(ErrorCause {
1266                name: "NOT_SYNCED_YET".to_string(),
1267                info: None,
1268            }),
1269            name: None,
1270        };
1271        let result = client.parse_rpc_error(&error);
1272        assert!(matches!(result, RpcError::NodeNotSynced(_)));
1273    }
1274
1275    #[test]
1276    fn test_parse_rpc_error_invalid_shard_id() {
1277        let client = RpcClient::new("https://example.com");
1278        let error = JsonRpcError {
1279            code: -32000,
1280            message: "Invalid shard ID".to_string(),
1281            data: None,
1282            cause: Some(ErrorCause {
1283                name: "INVALID_SHARD_ID".to_string(),
1284                info: Some(serde_json::json!({
1285                    "shard_id": 99
1286                })),
1287            }),
1288            name: None,
1289        };
1290        let result = client.parse_rpc_error(&error);
1291        assert!(matches!(result, RpcError::InvalidShardId(_)));
1292    }
1293
1294    #[test]
1295    fn test_parse_rpc_error_invalid_transaction() {
1296        let client = RpcClient::new("https://example.com");
1297        let error = JsonRpcError {
1298            code: -32000,
1299            message: "Invalid transaction".to_string(),
1300            data: None,
1301            cause: Some(ErrorCause {
1302                name: "INVALID_TRANSACTION".to_string(),
1303                info: None,
1304            }),
1305            name: None,
1306        };
1307        let result = client.parse_rpc_error(&error);
1308        assert!(matches!(result, RpcError::InvalidTransaction { .. }));
1309    }
1310
1311    #[test]
1312    fn test_parse_rpc_error_timeout() {
1313        let client = RpcClient::new("https://example.com");
1314        let error = JsonRpcError {
1315            code: -32000,
1316            message: "Request timed out".to_string(),
1317            data: None,
1318            cause: Some(ErrorCause {
1319                name: "TIMEOUT_ERROR".to_string(),
1320                info: Some(serde_json::json!({
1321                    "transaction_hash": "tx123"
1322                })),
1323            }),
1324            name: None,
1325        };
1326        let result = client.parse_rpc_error(&error);
1327        assert!(matches!(result, RpcError::RequestTimeout { .. }));
1328    }
1329
1330    #[test]
1331    fn test_parse_rpc_error_parse_error() {
1332        let client = RpcClient::new("https://example.com");
1333        let error = JsonRpcError {
1334            code: -32700,
1335            message: "Parse error".to_string(),
1336            data: None,
1337            cause: Some(ErrorCause {
1338                name: "PARSE_ERROR".to_string(),
1339                info: None,
1340            }),
1341            name: None,
1342        };
1343        let result = client.parse_rpc_error(&error);
1344        assert!(matches!(result, RpcError::ParseError(_)));
1345    }
1346
1347    #[test]
1348    fn test_parse_rpc_error_internal_error() {
1349        let client = RpcClient::new("https://example.com");
1350        let error = JsonRpcError {
1351            code: -32603,
1352            message: "Internal error".to_string(),
1353            data: None,
1354            cause: Some(ErrorCause {
1355                name: "INTERNAL_ERROR".to_string(),
1356                info: None,
1357            }),
1358            name: None,
1359        };
1360        let result = client.parse_rpc_error(&error);
1361        assert!(matches!(result, RpcError::InternalError(_)));
1362    }
1363
1364    #[test]
1365    fn test_parse_rpc_error_contract_execution_legacy() {
1366        // Legacy `query` endpoint includes contract_id and method_name in info
1367        let client = RpcClient::new("https://example.com");
1368        let error = JsonRpcError {
1369            code: -32000,
1370            message: "Contract execution failed".to_string(),
1371            data: None,
1372            cause: Some(ErrorCause {
1373                name: "CONTRACT_EXECUTION_ERROR".to_string(),
1374                info: Some(serde_json::json!({
1375                    "contract_id": "contract.near",
1376                    "method_name": "my_method"
1377                })),
1378            }),
1379            name: None,
1380        };
1381        let result = client.parse_rpc_error(&error);
1382        match result {
1383            RpcError::ContractExecution {
1384                contract_id,
1385                method_name,
1386                ..
1387            } => {
1388                assert_eq!(contract_id.as_str(), "contract.near");
1389                assert_eq!(method_name.as_deref(), Some("my_method"));
1390            }
1391            _ => panic!("Expected ContractExecution error, got {:?}", result),
1392        }
1393    }
1394
1395    #[test]
1396    fn test_parse_rpc_error_contract_execution_experimental() {
1397        // EXPERIMENTAL_call_function omits contract_id/method_name from info,
1398        // but includes vm_error and a data string
1399        let client = RpcClient::new("https://example.com");
1400        let error = JsonRpcError {
1401            code: -32000,
1402            message: "Server error".to_string(),
1403            data: Some(serde_json::json!(
1404                "Function call returned an error: MethodResolveError(MethodNotFound)"
1405            )),
1406            cause: Some(ErrorCause {
1407                name: "CONTRACT_EXECUTION_ERROR".to_string(),
1408                info: Some(serde_json::json!({
1409                    "vm_error": { "MethodResolveError": "MethodNotFound" },
1410                    "block_height": 243803767,
1411                    "block_hash": "Et7So7jtsorkYLdVMMgV8gxA3Cfaztp75Ti6TPv2A"
1412                })),
1413            }),
1414            name: Some("HANDLER_ERROR".to_string()),
1415        };
1416        let result = client.parse_rpc_error(&error);
1417        match result {
1418            RpcError::ContractExecution {
1419                contract_id,
1420                message,
1421                ..
1422            } => {
1423                // contract_id falls back to "unknown" — caller enriches it
1424                assert_eq!(contract_id.as_str(), "unknown");
1425                assert!(message.contains("MethodResolveError"));
1426            }
1427            _ => panic!("Expected ContractExecution error, got {:?}", result),
1428        }
1429    }
1430
1431    #[test]
1432    fn test_parse_rpc_error_code_does_not_exist_experimental() {
1433        // EXPERIMENTAL_call_function returns CodeDoesNotExist as CONTRACT_EXECUTION_ERROR
1434        let client = RpcClient::new("https://example.com");
1435        let error = JsonRpcError {
1436            code: -32000,
1437            message: "Server error".to_string(),
1438            data: Some(serde_json::json!(
1439                "Function call returned an error: CompilationError(CodeDoesNotExist { account_id: AccountId(\"nonexistent.testnet\") })"
1440            )),
1441            cause: Some(ErrorCause {
1442                name: "CONTRACT_EXECUTION_ERROR".to_string(),
1443                info: Some(serde_json::json!({
1444                    "vm_error": {
1445                        "CompilationError": {
1446                            "CodeDoesNotExist": {
1447                                "account_id": "nonexistent.testnet"
1448                            }
1449                        }
1450                    },
1451                    "block_height": 243803764,
1452                    "block_hash": "H33oNAtVZDJjhpncQb5LY6NxYzQLMMVLptq99mwmLmnj"
1453                })),
1454            }),
1455            name: Some("HANDLER_ERROR".to_string()),
1456        };
1457        let result = client.parse_rpc_error(&error);
1458        match result {
1459            RpcError::ContractNotDeployed(account_id) => {
1460                assert_eq!(account_id.as_str(), "nonexistent.testnet");
1461            }
1462            _ => panic!("Expected ContractNotDeployed error, got {:?}", result),
1463        }
1464    }
1465
1466    #[test]
1467    fn test_parse_rpc_error_fallback_account_not_exist() {
1468        let client = RpcClient::new("https://example.com");
1469        let error = JsonRpcError {
1470            code: -32000,
1471            message: "Error".to_string(),
1472            data: Some(serde_json::json!(
1473                "account missing.near does not exist while viewing"
1474            )),
1475            cause: None,
1476            name: None,
1477        };
1478        let result = client.parse_rpc_error(&error);
1479        assert!(matches!(result, RpcError::AccountNotFound(_)));
1480    }
1481
1482    #[test]
1483    fn test_parse_rpc_error_unknown_cause_fallback_to_generic() {
1484        let client = RpcClient::new("https://example.com");
1485        let error = JsonRpcError {
1486            code: -32000,
1487            message: "Some error".to_string(),
1488            data: Some(serde_json::json!("some data")),
1489            cause: Some(ErrorCause {
1490                name: "UNKNOWN_ERROR_TYPE".to_string(),
1491                info: None,
1492            }),
1493            name: None,
1494        };
1495        let result = client.parse_rpc_error(&error);
1496        assert!(matches!(result, RpcError::Rpc { .. }));
1497    }
1498
1499    #[test]
1500    fn test_parse_rpc_error_no_cause_fallback_to_generic() {
1501        let client = RpcClient::new("https://example.com");
1502        let error = JsonRpcError {
1503            code: -32600,
1504            message: "Invalid request".to_string(),
1505            data: None,
1506            cause: None,
1507            name: None,
1508        };
1509        let result = client.parse_rpc_error(&error);
1510        match result {
1511            RpcError::Rpc { code, message, .. } => {
1512                assert_eq!(code, -32600);
1513                assert_eq!(message, "Invalid request");
1514            }
1515            _ => panic!("Expected generic Rpc error"),
1516        }
1517    }
1518
1519    // ========================================================================
1520    // block_id_or_null tests
1521    // ========================================================================
1522
1523    #[test]
1524    fn test_block_id_or_null_with_none() {
1525        let result = block_id_or_null(None);
1526        assert!(result.is_null());
1527    }
1528
1529    #[test]
1530    fn test_block_id_or_null_with_height() {
1531        let block = BlockReference::at_height(12345);
1532        let result = block_id_or_null(Some(&block));
1533        assert_eq!(result, serde_json::json!(12345));
1534    }
1535
1536    #[test]
1537    fn test_block_id_or_null_with_hash() {
1538        let hash = CryptoHash::hash(b"test block");
1539        let block = BlockReference::at_hash(hash);
1540        let result = block_id_or_null(Some(&block));
1541        assert_eq!(result, serde_json::json!(hash.to_string()));
1542    }
1543
1544    #[test]
1545    fn test_block_id_or_null_with_finality_falls_back_to_null() {
1546        let block = BlockReference::final_();
1547        let result = block_id_or_null(Some(&block));
1548        assert!(result.is_null(), "finality variants should map to null");
1549
1550        let block = BlockReference::optimistic();
1551        let result = block_id_or_null(Some(&block));
1552        assert!(result.is_null(), "optimistic should map to null");
1553    }
1554
1555    #[test]
1556    fn test_block_id_or_null_with_sync_checkpoint_falls_back_to_null() {
1557        let block = BlockReference::genesis();
1558        let result = block_id_or_null(Some(&block));
1559        assert!(result.is_null(), "sync checkpoint should map to null");
1560    }
1561}