Skip to main content

lichen_client_sdk/
client.rs

1//! RPC client for Lichen
2
3use crate::error::{Error, Result};
4use crate::types::{Balance, Block, NetworkInfo};
5use crate::{
6    ContractInstruction, Hash, Instruction, Keypair, Pubkey, TransactionBuilder,
7    CONTRACT_PROGRAM_ID, SYSTEM_PROGRAM_ID,
8};
9use reqwest;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Arc;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ReadonlyContractResult {
17    pub success: bool,
18    #[serde(rename = "returnData")]
19    pub return_data: Option<String>,
20    #[serde(rename = "returnCode")]
21    pub return_code: Option<u32>,
22    #[serde(default)]
23    pub logs: Vec<String>,
24    pub error: Option<String>,
25    #[serde(rename = "computeUsed")]
26    pub compute_used: Option<u64>,
27}
28
29/// Lichen RPC client
30#[derive(Debug, Clone)]
31pub struct Client {
32    rpc_url: String,
33    client: reqwest::Client,
34    next_id: Arc<AtomicU64>,
35}
36
37impl Client {
38    /// Create a new client with default settings
39    pub fn new(rpc_url: impl Into<String>) -> Self {
40        Self {
41            rpc_url: rpc_url.into(),
42            client: reqwest::Client::new(),
43            next_id: Arc::new(AtomicU64::new(1)),
44        }
45    }
46
47    /// Create a client using the LICHEN_RPC_URL env var, falling back to localhost:8899.
48    pub fn from_env() -> Self {
49        let url =
50            std::env::var("LICHEN_RPC_URL").unwrap_or_else(|_| "http://localhost:8899".to_string());
51        Self::new(url)
52    }
53
54    /// Create a client builder for custom configuration
55    pub fn builder() -> ClientBuilder {
56        ClientBuilder::default()
57    }
58
59    /// Make an RPC call
60    pub(crate) async fn rpc_call(&self, method: &str, params: Value) -> Result<Value> {
61        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
62        let request = json!({
63            "jsonrpc": "2.0",
64            "id": id,
65            "method": method,
66            "params": params
67        });
68
69        let response = self
70            .client
71            .post(&self.rpc_url)
72            .json(&request)
73            .send()
74            .await?
75            .json::<Value>()
76            .await?;
77
78        if let Some(error) = response.get("error") {
79            return Err(Error::RpcError(error.to_string()));
80        }
81
82        response
83            .get("result")
84            .cloned()
85            .ok_or(Error::RpcError("No result in response".to_string()))
86    }
87
88    /// Get current slot
89    pub async fn get_slot(&self) -> Result<u64> {
90        let result = self.rpc_call("getSlot", json!([])).await?;
91        result
92            .as_u64()
93            .ok_or(Error::ParseError("Invalid slot format".to_string()))
94    }
95
96    /// Get account balance
97    pub async fn get_balance(&self, pubkey: &Pubkey) -> Result<Balance> {
98        let result = self
99            .rpc_call("getBalance", json!([pubkey.to_base58()]))
100            .await?;
101
102        let spores = result["spores"]
103            .as_u64()
104            .ok_or(Error::ParseError("Invalid balance format".to_string()))?;
105
106        Ok(Balance::from_spores(spores))
107    }
108
109    /// Get block by slot
110    pub async fn get_block(&self, slot: u64) -> Result<Block> {
111        let result = self.rpc_call("getBlock", json!([slot])).await?;
112        serde_json::from_value(result).map_err(|e| Error::ParseError(e.to_string()))
113    }
114
115    /// Get latest block
116    pub async fn get_latest_block(&self) -> Result<Block> {
117        let result = self.rpc_call("getLatestBlock", json!([])).await?;
118        serde_json::from_value(result).map_err(|e| Error::ParseError(e.to_string()))
119    }
120
121    /// Get network information
122    pub async fn get_network_info(&self) -> Result<NetworkInfo> {
123        let result = self.rpc_call("getNetworkInfo", json!([])).await?;
124        serde_json::from_value(result).map_err(|e| Error::ParseError(e.to_string()))
125    }
126
127    /// Get validators
128    pub async fn get_validators(&self) -> Result<Vec<Value>> {
129        let result = self.rpc_call("getValidators", json!([])).await?;
130        // Handle both array format and object with "validators" field
131        if let Some(arr) = result.as_array() {
132            Ok(arr.clone())
133        } else if let Some(validators) = result.get("validators").and_then(|v| v.as_array()) {
134            Ok(validators.clone())
135        } else {
136            Err(Error::ParseError("Invalid validators format".to_string()))
137        }
138    }
139
140    /// Send raw transaction (base64-encoded bincode)
141    pub async fn send_raw_transaction(&self, tx_base64: &str) -> Result<String> {
142        let result = self.rpc_call("sendTransaction", json!([tx_base64])).await?;
143        result
144            .as_str()
145            .map(|s| s.to_string())
146            .ok_or(Error::ParseError("Invalid transaction hash".to_string()))
147    }
148
149    /// Send transaction (serializes with wire envelope and encodes automatically)
150    pub async fn send_transaction(&self, tx: &crate::types::Transaction) -> Result<String> {
151        let tx_bytes = tx.to_wire();
152        let tx_base64 =
153            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &tx_bytes);
154        self.send_raw_transaction(&tx_base64).await
155    }
156
157    /// Get transaction by signature
158    pub async fn get_transaction(&self, signature: &str) -> Result<Value> {
159        self.rpc_call("getTransaction", json!([signature])).await
160    }
161
162    /// Get account info
163    pub async fn get_account_info(&self, pubkey: &Pubkey) -> Result<Value> {
164        self.rpc_call("getAccountInfo", json!([pubkey.to_base58()]))
165            .await
166    }
167
168    /// Get transaction history for an account
169    pub async fn get_transaction_history(
170        &self,
171        pubkey: &Pubkey,
172        limit: Option<u64>,
173    ) -> Result<Value> {
174        let limit = limit.unwrap_or(10);
175        self.rpc_call("getTransactionHistory", json!([pubkey.to_base58(), limit]))
176            .await
177    }
178
179    /// Execute a read-only contract call without submitting a transaction.
180    pub async fn call_readonly_contract(
181        &self,
182        contract: &Pubkey,
183        function: &str,
184        args: Vec<u8>,
185        from: Option<&Pubkey>,
186    ) -> Result<ReadonlyContractResult> {
187        let args_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &args);
188        let mut params = vec![
189            json!(contract.to_base58()),
190            json!(function),
191            json!(args_b64),
192        ];
193        if let Some(from_pubkey) = from {
194            params.push(json!(from_pubkey.to_base58()));
195        }
196
197        let result = self.rpc_call("callContract", Value::Array(params)).await?;
198        serde_json::from_value(result).map_err(|err| Error::ParseError(err.to_string()))
199    }
200
201    /// Get recent blockhash (for transaction building)
202    pub async fn get_recent_blockhash(&self) -> Result<String> {
203        let result = self.rpc_call("getRecentBlockhash", json!([])).await?;
204        // Handle both string format and object with "blockhash" field
205        if let Some(hash_str) = result.as_str() {
206            Ok(hash_str.to_string())
207        } else if let Some(hash_str) = result.get("blockhash").and_then(|v| v.as_str()) {
208            Ok(hash_str.to_string())
209        } else {
210            Err(Error::ParseError("Invalid blockhash format".to_string()))
211        }
212    }
213
214    // ============================================================================
215    // VALIDATOR OPERATIONS
216    // ============================================================================
217
218    /// Get detailed validator information
219    pub async fn get_validator_info(&self, pubkey: &Pubkey) -> Result<Value> {
220        self.rpc_call("getValidatorInfo", json!([pubkey.to_base58()]))
221            .await
222    }
223
224    /// Get validator performance metrics
225    pub async fn get_validator_performance(&self, pubkey: &Pubkey) -> Result<Value> {
226        self.rpc_call("getValidatorPerformance", json!([pubkey.to_base58()]))
227            .await
228    }
229
230    /// Get comprehensive chain status
231    pub async fn get_chain_status(&self) -> Result<Value> {
232        self.rpc_call("getChainStatus", json!([])).await
233    }
234
235    // ============================================================================
236    // STAKING OPERATIONS
237    // ============================================================================
238
239    /// Create stake transaction
240    pub async fn stake(&self, staker: &Keypair, validator: &Pubkey, amount: u64) -> Result<String> {
241        let blockhash_str = self.get_recent_blockhash().await?;
242        let blockhash = Hash::from_hex(&blockhash_str).map_err(|e| Error::ParseError(e))?;
243
244        let mut data = vec![9u8];
245        data.extend_from_slice(&amount.to_le_bytes());
246
247        let instruction = Instruction {
248            program_id: SYSTEM_PROGRAM_ID,
249            accounts: vec![staker.pubkey(), *validator],
250            data,
251        };
252
253        let tx = TransactionBuilder::new()
254            .add_instruction(instruction)
255            .recent_blockhash(blockhash)
256            .build_and_sign(staker)?;
257
258        self.send_transaction(&tx).await
259    }
260
261    /// Create unstake transaction
262    pub async fn unstake(
263        &self,
264        staker: &Keypair,
265        validator: &Pubkey,
266        amount: u64,
267    ) -> Result<String> {
268        let blockhash_str = self.get_recent_blockhash().await?;
269        let blockhash = Hash::from_hex(&blockhash_str).map_err(|e| Error::ParseError(e))?;
270
271        let mut data = vec![10u8];
272        data.extend_from_slice(&amount.to_le_bytes());
273
274        let instruction = Instruction {
275            program_id: SYSTEM_PROGRAM_ID,
276            accounts: vec![staker.pubkey(), *validator],
277            data,
278        };
279
280        let tx = TransactionBuilder::new()
281            .add_instruction(instruction)
282            .recent_blockhash(blockhash)
283            .build_and_sign(staker)?;
284
285        self.send_transaction(&tx).await
286    }
287
288    /// Get staking status for an account
289    pub async fn get_staking_status(&self, pubkey: &Pubkey) -> Result<Value> {
290        self.rpc_call("getStakingStatus", json!([pubkey.to_base58()]))
291            .await
292    }
293
294    /// Get staking rewards for an account
295    pub async fn get_staking_rewards(&self, pubkey: &Pubkey) -> Result<Value> {
296        self.rpc_call("getStakingRewards", json!([pubkey.to_base58()]))
297            .await
298    }
299
300    // ============================================================================
301    // TRANSFER & CONTRACT OPERATIONS
302    // ============================================================================
303
304    /// Transfer native LICN (spores) from one account to another.
305    pub async fn transfer(&self, from: &Keypair, to: &Pubkey, amount: u64) -> Result<String> {
306        let blockhash_str = self.get_recent_blockhash().await?;
307        let blockhash = Hash::from_hex(&blockhash_str).map_err(|e| Error::ParseError(e))?;
308
309        let mut data = vec![0u8]; // Transfer instruction type
310        data.extend_from_slice(&amount.to_le_bytes());
311
312        let instruction = Instruction {
313            program_id: SYSTEM_PROGRAM_ID,
314            accounts: vec![from.pubkey(), *to],
315            data,
316        };
317
318        let tx = TransactionBuilder::new()
319            .add_instruction(instruction)
320            .recent_blockhash(blockhash)
321            .build_and_sign(from)?;
322
323        self.send_transaction(&tx).await
324    }
325
326    /// Deploy a WASM smart contract.
327    ///
328    /// # Arguments
329    /// * `deployer` - Deployer keypair (signer, pays deploy fee)
330    /// * `code` - WASM bytecode (must start with \0asm magic, max 512 KB)
331    /// * `init_data` - Optional initialization data passed to contract init
332    pub async fn deploy_contract(
333        &self,
334        deployer: &Keypair,
335        code: Vec<u8>,
336        init_data: Vec<u8>,
337    ) -> Result<String> {
338        if code.len() < 4 || &code[..4] != b"\0asm" {
339            return Err(Error::BuildError(
340                "Invalid WASM bytecode: missing magic header (\\0asm)".into(),
341            ));
342        }
343        if code.len() > 512 * 1024 {
344            return Err(Error::BuildError(
345                "Contract code exceeds 512 KB limit".into(),
346            ));
347        }
348
349        let blockhash_str = self.get_recent_blockhash().await?;
350        let blockhash = Hash::from_hex(&blockhash_str).map_err(|e| Error::ParseError(e))?;
351
352        let contract_ix = ContractInstruction::Deploy { code, init_data };
353        let data = serde_json::to_vec(&contract_ix)
354            .map_err(|e| Error::SerializationError(e.to_string()))?;
355
356        let instruction = Instruction {
357            program_id: CONTRACT_PROGRAM_ID,
358            accounts: vec![deployer.pubkey()],
359            data,
360        };
361
362        let tx = TransactionBuilder::new()
363            .add_instruction(instruction)
364            .recent_blockhash(blockhash)
365            .build_and_sign(deployer)?;
366
367        self.send_transaction(&tx).await
368    }
369
370    /// Call a function on a deployed WASM smart contract.
371    ///
372    /// # Arguments
373    /// * `caller` - Caller keypair (signer)
374    /// * `contract` - Contract account public key
375    /// * `function` - Name of the contract function to invoke
376    /// * `args` - Serialized function arguments
377    /// * `value` - Native LICN to send with the call in spores
378    pub async fn call_contract(
379        &self,
380        caller: &Keypair,
381        contract: &Pubkey,
382        function: &str,
383        args: Vec<u8>,
384        value: u64,
385    ) -> Result<String> {
386        let blockhash_str = self.get_recent_blockhash().await?;
387        let blockhash = Hash::from_hex(&blockhash_str).map_err(|e| Error::ParseError(e))?;
388
389        let contract_ix = ContractInstruction::Call {
390            function: function.to_string(),
391            args,
392            value,
393        };
394        let data = serde_json::to_vec(&contract_ix)
395            .map_err(|e| Error::SerializationError(e.to_string()))?;
396
397        let instruction = Instruction {
398            program_id: CONTRACT_PROGRAM_ID,
399            accounts: vec![caller.pubkey(), *contract],
400            data,
401        };
402
403        let tx = TransactionBuilder::new()
404            .add_instruction(instruction)
405            .recent_blockhash(blockhash)
406            .build_and_sign(caller)?;
407
408        self.send_transaction(&tx).await
409    }
410
411    /// Upgrade a deployed WASM smart contract (owner only).
412    pub async fn upgrade_contract(
413        &self,
414        owner: &Keypair,
415        contract: &Pubkey,
416        code: Vec<u8>,
417    ) -> Result<String> {
418        if code.len() < 4 || &code[..4] != b"\0asm" {
419            return Err(Error::BuildError(
420                "Invalid WASM bytecode: missing magic header (\\0asm)".into(),
421            ));
422        }
423        if code.len() > 512 * 1024 {
424            return Err(Error::BuildError(
425                "Contract code exceeds 512 KB limit".into(),
426            ));
427        }
428
429        let blockhash_str = self.get_recent_blockhash().await?;
430        let blockhash = Hash::from_hex(&blockhash_str).map_err(|e| Error::ParseError(e))?;
431
432        let contract_ix = ContractInstruction::Upgrade { code };
433        let data = serde_json::to_vec(&contract_ix)
434            .map_err(|e| Error::SerializationError(e.to_string()))?;
435
436        let instruction = Instruction {
437            program_id: CONTRACT_PROGRAM_ID,
438            accounts: vec![owner.pubkey(), *contract],
439            data,
440        };
441
442        let tx = TransactionBuilder::new()
443            .add_instruction(instruction)
444            .recent_blockhash(blockhash)
445            .build_and_sign(owner)?;
446
447        self.send_transaction(&tx).await
448    }
449
450    // ============================================================================
451    // NETWORK OPERATIONS
452    // ============================================================================
453
454    /// Get connected peers
455    pub async fn get_peers(&self) -> Result<Value> {
456        self.rpc_call("getPeers", json!([])).await
457    }
458
459    /// Get network metrics
460    pub async fn get_metrics(&self) -> Result<Value> {
461        self.rpc_call("getMetrics", json!([])).await
462    }
463
464    /// Get total burned tokens
465    pub async fn get_total_burned(&self) -> Result<Value> {
466        self.rpc_call("getTotalBurned", json!([])).await
467    }
468
469    // ============================================================================
470    // CONTRACT/PROGRAM OPERATIONS
471    // ============================================================================
472
473    /// Get contract information
474    pub async fn get_contract_info(&self, contract_id: &Pubkey) -> Result<Value> {
475        self.rpc_call("getContractInfo", json!([contract_id.to_base58()]))
476            .await
477    }
478
479    /// Get contract execution logs
480    pub async fn get_contract_logs(&self, contract_id: &Pubkey) -> Result<Value> {
481        self.rpc_call("getContractLogs", json!([contract_id.to_base58()]))
482            .await
483    }
484
485    /// Get a symbol-registry entry.
486    pub async fn get_symbol_registry(&self, symbol: &str) -> Result<Value> {
487        self.rpc_call("getSymbolRegistry", json!([symbol])).await
488    }
489
490    /// Get the complete LichenID profile for an address.
491    pub async fn get_lichenid_profile(&self, pubkey: &Pubkey) -> Result<Value> {
492        self.rpc_call("getLichenIdProfile", json!([pubkey.to_base58()]))
493            .await
494    }
495
496    /// Get the LichenID reputation summary for an address.
497    pub async fn get_lichenid_reputation(&self, pubkey: &Pubkey) -> Result<Value> {
498        self.rpc_call("getLichenIdReputation", json!([pubkey.to_base58()]))
499            .await
500    }
501
502    /// Get LichenID skills for an address.
503    pub async fn get_lichenid_skills(&self, pubkey: &Pubkey) -> Result<Value> {
504        self.rpc_call("getLichenIdSkills", json!([pubkey.to_base58()]))
505            .await
506    }
507
508    /// Get LichenID vouches for an address.
509    pub async fn get_lichenid_vouches(&self, pubkey: &Pubkey) -> Result<Value> {
510        self.rpc_call("getLichenIdVouches", json!([pubkey.to_base58()]))
511            .await
512    }
513
514    /// Resolve a .lichen name to its owner.
515    pub async fn resolve_lichen_name(&self, name: &str) -> Result<Value> {
516        self.rpc_call("resolveLichenName", json!([name])).await
517    }
518
519    /// Get premium-name auction state for a .lichen label.
520    pub async fn get_name_auction(&self, name: &str) -> Result<Value> {
521        self.rpc_call("getNameAuction", json!([name])).await
522    }
523
524    /// Get the LichenID agent directory.
525    pub async fn get_lichenid_agent_directory(&self, options: Option<Value>) -> Result<Value> {
526        match options {
527            Some(options) => {
528                self.rpc_call("getLichenIdAgentDirectory", json!([options]))
529                    .await
530            }
531            None => self.rpc_call("getLichenIdAgentDirectory", json!([])).await,
532        }
533    }
534
535    /// Get aggregated LichenID statistics.
536    pub async fn get_lichenid_stats(&self) -> Result<Value> {
537        self.rpc_call("getLichenIdStats", json!([])).await
538    }
539
540    /// Get aggregated SporePay streaming statistics.
541    pub async fn get_sporepay_stats(&self) -> Result<Value> {
542        self.rpc_call("getSporePayStats", json!([])).await
543    }
544
545    /// Get aggregated LichenSwap statistics.
546    pub async fn get_lichenswap_stats(&self) -> Result<Value> {
547        self.rpc_call("getLichenSwapStats", json!([])).await
548    }
549
550    /// Get aggregated ThallLend lending statistics.
551    pub async fn get_thalllend_stats(&self) -> Result<Value> {
552        self.rpc_call("getThallLendStats", json!([])).await
553    }
554
555    /// Get aggregated SporeVault yield-vault statistics.
556    pub async fn get_sporevault_stats(&self) -> Result<Value> {
557        self.rpc_call("getSporeVaultStats", json!([])).await
558    }
559
560    /// Get aggregated Neo GAS rewards vault statistics.
561    pub async fn get_neo_gas_rewards_stats(&self) -> Result<Value> {
562        self.rpc_call("getNeoGasRewardsStats", json!([])).await
563    }
564
565    /// Get per-wallet Neo GAS rewards vault accounting.
566    pub async fn get_neo_gas_rewards_position(&self, address: &Pubkey) -> Result<Value> {
567        self.rpc_call("getNeoGasRewardsPosition", json!([address.to_base58()]))
568            .await
569    }
570
571    /// Get Neo reserve/liability proof-service verifier metadata.
572    pub async fn get_neo_zk_proof_service_status(&self) -> Result<Value> {
573        self.rpc_call("getNeoZkProofServiceStatus", json!([])).await
574    }
575
576    /// Verify a CLI-produced Neo reserve/liability proof envelope.
577    pub async fn verify_neo_reserve_liability_proof(&self, proof_envelope: Value) -> Result<Value> {
578        self.rpc_call("verifyNeoReserveLiabilityProof", json!([proof_envelope]))
579            .await
580    }
581
582    /// Get aggregated BountyBoard marketplace statistics.
583    pub async fn get_bountyboard_stats(&self) -> Result<Value> {
584        self.rpc_call("getBountyBoardStats", json!([])).await
585    }
586
587    // ============================================================================
588    // PROGRAM OPERATIONS (DRAFT)
589    // ============================================================================
590
591    pub async fn get_program(&self, program_id: &Pubkey) -> Result<Value> {
592        self.rpc_call("getProgram", json!([program_id.to_base58()]))
593            .await
594    }
595
596    pub async fn get_program_stats(&self, program_id: &Pubkey) -> Result<Value> {
597        self.rpc_call("getProgramStats", json!([program_id.to_base58()]))
598            .await
599    }
600
601    pub async fn get_programs(&self) -> Result<Value> {
602        self.rpc_call("getPrograms", json!([])).await
603    }
604
605    pub async fn get_program_calls(&self, program_id: &Pubkey) -> Result<Value> {
606        self.rpc_call("getProgramCalls", json!([program_id.to_base58()]))
607            .await
608    }
609
610    pub async fn get_program_storage(&self, program_id: &Pubkey) -> Result<Value> {
611        self.rpc_call("getProgramStorage", json!([program_id.to_base58()]))
612            .await
613    }
614
615    // ============================================================================
616    // NFT OPERATIONS (DRAFT)
617    // ============================================================================
618
619    pub async fn get_collection(&self, collection_id: &Pubkey) -> Result<Value> {
620        self.rpc_call("getCollection", json!([collection_id.to_base58()]))
621            .await
622    }
623
624    pub async fn get_nft(&self, collection_id: &Pubkey, token_id: u64) -> Result<Value> {
625        self.rpc_call("getNFT", json!([collection_id.to_base58(), token_id]))
626            .await
627    }
628
629    pub async fn get_nfts_by_owner(&self, owner: &Pubkey) -> Result<Value> {
630        self.rpc_call("getNFTsByOwner", json!([owner.to_base58()]))
631            .await
632    }
633
634    pub async fn get_nfts_by_collection(&self, collection_id: &Pubkey) -> Result<Value> {
635        self.rpc_call("getNFTsByCollection", json!([collection_id.to_base58()]))
636            .await
637    }
638
639    pub async fn get_nft_activity(&self, collection_id: &Pubkey, token_id: u64) -> Result<Value> {
640        self.rpc_call(
641            "getNFTActivity",
642            json!([collection_id.to_base58(), token_id]),
643        )
644        .await
645    }
646
647    /// Get all deployed contracts
648    pub async fn get_all_contracts(&self) -> Result<Value> {
649        self.rpc_call("getAllContracts", json!([])).await
650    }
651
652    /// Health check
653    pub async fn health(&self) -> Result<bool> {
654        let result = self.rpc_call("health", json!([])).await?;
655        Ok(result.get("status").and_then(|v| v.as_str()) == Some("ok"))
656    }
657}
658
659/// Builder for Client with custom configuration
660#[derive(Default)]
661pub struct ClientBuilder {
662    rpc_url: Option<String>,
663    timeout: Option<std::time::Duration>,
664}
665
666impl ClientBuilder {
667    /// Set RPC URL
668    pub fn rpc_url(mut self, url: impl Into<String>) -> Self {
669        self.rpc_url = Some(url.into());
670        self
671    }
672
673    /// Set request timeout
674    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
675        self.timeout = Some(timeout);
676        self
677    }
678
679    /// Build the client
680    pub fn build(self) -> Result<Client> {
681        let rpc_url = self
682            .rpc_url
683            .ok_or(Error::ConfigError("RPC URL not set".to_string()))?;
684
685        let mut client_builder = reqwest::Client::builder();
686
687        if let Some(timeout) = self.timeout {
688            client_builder = client_builder.timeout(timeout);
689        }
690
691        Ok(Client {
692            rpc_url,
693            client: client_builder.build()?,
694            next_id: Arc::new(AtomicU64::new(1)),
695        })
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use std::sync::{Mutex, OnceLock};
703
704    fn env_lock() -> &'static Mutex<()> {
705        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
706        LOCK.get_or_init(|| Mutex::new(()))
707    }
708
709    // ── Client::new ─────────────────────────────────────────────────
710
711    #[test]
712    fn test_client_new() {
713        let client = Client::new("http://localhost:8899");
714        assert_eq!(client.rpc_url, "http://localhost:8899");
715    }
716
717    #[test]
718    fn test_client_new_custom_url() {
719        let client = Client::new("https://rpc.lichen.network:443");
720        assert_eq!(client.rpc_url, "https://rpc.lichen.network:443");
721    }
722
723    #[test]
724    fn test_client_new_id_starts_at_1() {
725        let client = Client::new("http://localhost:8899");
726        assert_eq!(client.next_id.load(Ordering::Relaxed), 1);
727    }
728
729    // ── Client::from_env ────────────────────────────────────────────
730
731    #[test]
732    fn test_client_from_env_defaults_to_localhost() {
733        let _guard = env_lock().lock().unwrap();
734        // Clear the env var to ensure fallback
735        std::env::remove_var("LICHEN_RPC_URL");
736        let client = Client::from_env();
737        assert_eq!(client.rpc_url, "http://localhost:8899");
738    }
739
740    #[test]
741    fn test_client_from_env_uses_var() {
742        let _guard = env_lock().lock().unwrap();
743        std::env::set_var("LICHEN_RPC_URL", "http://custom:9999");
744        let client = Client::from_env();
745        assert_eq!(client.rpc_url, "http://custom:9999");
746        std::env::remove_var("LICHEN_RPC_URL");
747    }
748
749    // ── ClientBuilder ───────────────────────────────────────────────
750
751    #[test]
752    fn test_client_builder() {
753        let client = Client::builder()
754            .rpc_url("http://localhost:8899")
755            .timeout(std::time::Duration::from_secs(30))
756            .build()
757            .expect("should build client");
758        assert_eq!(client.rpc_url, "http://localhost:8899");
759    }
760
761    #[test]
762    fn test_client_builder_no_url_fails() {
763        let result = Client::builder().build();
764        assert!(result.is_err(), "should fail without URL");
765    }
766
767    #[test]
768    fn test_client_builder_no_timeout() {
769        let client = Client::builder()
770            .rpc_url("http://localhost:8899")
771            .build()
772            .expect("should build without timeout");
773        assert_eq!(client.rpc_url, "http://localhost:8899");
774    }
775
776    #[test]
777    fn test_client_builder_default() {
778        let builder = ClientBuilder::default();
779        assert!(builder.rpc_url.is_none());
780        assert!(builder.timeout.is_none());
781    }
782
783    // ── Request ID counter ──────────────────────────────────────────
784
785    #[test]
786    fn test_client_id_increments() {
787        let client = Client::new("http://localhost:8899");
788        let v1 = client.next_id.fetch_add(1, Ordering::Relaxed);
789        let v2 = client.next_id.fetch_add(1, Ordering::Relaxed);
790        assert_eq!(v1, 1);
791        assert_eq!(v2, 2);
792    }
793
794    #[test]
795    fn test_client_clone_shares_counter() {
796        let client = Client::new("http://localhost:8899");
797        client.next_id.fetch_add(1, Ordering::Relaxed);
798        let clone = client.clone();
799        let v = clone.next_id.fetch_add(1, Ordering::Relaxed);
800        assert_eq!(v, 2); // Shared via Arc
801    }
802
803    #[test]
804    fn test_client_clone_shares_url() {
805        let client = Client::new("http://my-rpc:8899");
806        let clone = client.clone();
807        assert_eq!(clone.rpc_url, "http://my-rpc:8899");
808    }
809}