1use cosmrs::Tx;
22use nautilus_network::{ConnectionUrl, url_origin};
23use tonic::transport::Channel;
24
25use crate::{
26 error::DydxError,
27 proto::{
28 AccountAuthenticator, AccountPlusClient, GetAuthenticatorsRequest,
29 cosmos_sdk_proto::{
30 cosmos::{
31 auth::v1beta1::{
32 BaseAccount, QueryAccountRequest, query_client::QueryClient as AuthClient,
33 },
34 bank::v1beta1::{QueryAllBalancesRequest, query_client::QueryClient as BankClient},
35 base::{
36 tendermint::v1beta1::{
37 Block, GetLatestBlockRequest, GetNodeInfoRequest, GetNodeInfoResponse,
38 service_client::ServiceClient as BaseClient,
39 },
40 v1beta1::Coin,
41 },
42 tx::v1beta1::{
43 BroadcastMode, BroadcastTxRequest, GetTxRequest, SimulateRequest,
44 service_client::ServiceClient as TxClient,
45 },
46 },
47 traits::Message as ProstMessage,
48 },
49 dydxprotocol::{
50 clob::{ClobPair, QueryAllClobPairRequest, query_client::QueryClient as ClobClient},
51 perpetuals::{
52 Perpetual, QueryAllPerpetualsRequest, query_client::QueryClient as PerpetualsClient,
53 },
54 subaccounts::{
55 QueryGetSubaccountRequest, Subaccount as SubaccountInfo,
56 query_client::QueryClient as SubaccountsClient,
57 },
58 },
59 },
60};
61
62pub type TxHash = String;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
67pub struct Height(pub u32);
68
69#[derive(Debug, Clone)]
77pub struct DydxGrpcClient {
78 channel: Channel,
79 auth: AuthClient<Channel>,
80 bank: BankClient<Channel>,
81 base: BaseClient<Channel>,
82 tx: TxClient<Channel>,
83 clob: ClobClient<Channel>,
84 perpetuals: PerpetualsClient<Channel>,
85 subaccounts: SubaccountsClient<Channel>,
86 accountplus: AccountPlusClient<Channel>,
87 current_url: ConnectionUrl,
88}
89
90impl DydxGrpcClient {
91 pub async fn new(grpc_url: String) -> Result<Self, DydxError> {
97 let mut endpoint = Channel::from_shared(grpc_url.clone())
98 .map_err(|e| DydxError::Config(format!("Invalid gRPC URL: {e}")))?
99 .connect_timeout(std::time::Duration::from_secs(10))
100 .timeout(std::time::Duration::from_secs(30));
101
102 if grpc_url.starts_with("https://") {
104 let tls = tonic::transport::ClientTlsConfig::new().with_enabled_roots();
105 endpoint = endpoint
106 .tls_config(tls)
107 .map_err(|e| DydxError::Config(format!("TLS config failed: {e}")))?;
108 }
109
110 let channel = endpoint.connect().await.map_err(|e| {
111 DydxError::Grpc(Box::new(tonic::Status::unavailable(format!(
112 "Connection failed: {e}"
113 ))))
114 })?;
115
116 Ok(Self {
117 auth: AuthClient::new(channel.clone()),
118 bank: BankClient::new(channel.clone()),
119 base: BaseClient::new(channel.clone()),
120 tx: TxClient::new(channel.clone()),
121 clob: ClobClient::new(channel.clone()),
122 perpetuals: PerpetualsClient::new(channel.clone()),
123 subaccounts: SubaccountsClient::new(channel.clone()),
124 accountplus: AccountPlusClient::new(channel.clone()),
125 channel,
126 current_url: grpc_url.into(),
127 })
128 }
129
130 pub async fn new_with_fallback(grpc_urls: &[impl AsRef<str>]) -> Result<Self, DydxError> {
140 if grpc_urls.is_empty() {
141 return Err(DydxError::Config("No gRPC URLs provided".to_string()));
142 }
143
144 let mut last_error = None;
145
146 for (idx, url) in grpc_urls.iter().enumerate() {
147 let url_str = url.as_ref();
148 log::debug!(
149 "Attempting to connect to gRPC node: {} (attempt {}/{})",
150 url_origin(url_str),
151 idx + 1,
152 grpc_urls.len()
153 );
154
155 match Self::new(url_str.to_string()).await {
156 Ok(client) => {
157 log::debug!(
158 "Successfully connected to gRPC node: {}",
159 url_origin(url_str)
160 );
161 return Ok(client);
162 }
163 Err(e) => {
164 log::warn!("Failed to connect to gRPC node {}", url_origin(url_str));
165 last_error = Some(e);
166 }
167 }
168 }
169
170 Err(last_error.unwrap_or_else(|| {
171 DydxError::Grpc(Box::new(tonic::Status::unavailable(
172 "All gRPC connection attempts failed".to_string(),
173 )))
174 }))
175 }
176
177 pub async fn reconnect_with_fallback(
187 &mut self,
188 grpc_urls: &[impl AsRef<str>],
189 ) -> Result<(), DydxError> {
190 if grpc_urls.is_empty() {
191 return Err(DydxError::Config("No gRPC URLs provided".to_string()));
192 }
193
194 let mut last_error = None;
195
196 for (idx, url) in grpc_urls.iter().enumerate() {
197 let url_str = url.as_ref();
198
199 if url_str == self.current_url.expose() {
201 log::debug!("Skipping current URL: {}", url_origin(url_str));
202 continue;
203 }
204
205 log::debug!(
206 "Attempting to reconnect to gRPC node: {} (attempt {}/{})",
207 url_origin(url_str),
208 idx + 1,
209 grpc_urls.len()
210 );
211
212 let mut endpoint = match Channel::from_shared(url_str.to_string())
213 .map_err(|e| DydxError::Config(format!("Invalid gRPC URL: {e}")))
214 {
215 Ok(ep) => ep
216 .connect_timeout(std::time::Duration::from_secs(10))
217 .timeout(std::time::Duration::from_secs(30)),
218 Err(e) => {
219 last_error = Some(e);
220 continue;
221 }
222 };
223
224 if url_str.starts_with("https://") {
226 let tls = tonic::transport::ClientTlsConfig::new().with_enabled_roots();
227 endpoint = match endpoint.tls_config(tls) {
228 Ok(ep) => ep,
229 Err(e) => {
230 last_error = Some(DydxError::Config(format!("TLS config failed: {e}")));
231 continue;
232 }
233 };
234 }
235
236 match endpoint.connect().await {
237 Ok(connected_channel) => {
238 log::debug!(
239 "Successfully reconnected to gRPC node: {}",
240 url_origin(url_str)
241 );
242
243 self.channel = connected_channel.clone();
245 self.auth = AuthClient::new(connected_channel.clone());
246 self.bank = BankClient::new(connected_channel.clone());
247 self.base = BaseClient::new(connected_channel.clone());
248 self.tx = TxClient::new(connected_channel.clone());
249 self.clob = ClobClient::new(connected_channel.clone());
250 self.perpetuals = PerpetualsClient::new(connected_channel.clone());
251 self.subaccounts = SubaccountsClient::new(connected_channel);
252 self.current_url = url_str.into();
253
254 return Ok(());
255 }
256 Err(e) => {
257 log::warn!("Failed to reconnect to gRPC node {}", url_origin(url_str));
258 last_error = Some(DydxError::Grpc(Box::new(tonic::Status::unavailable(
259 format!("Connection failed: {e}"),
260 ))));
261 }
262 }
263 }
264
265 Err(last_error.unwrap_or_else(|| {
266 DydxError::Grpc(Box::new(tonic::Status::unavailable(
267 "All gRPC reconnection attempts failed".to_string(),
268 )))
269 }))
270 }
271
272 #[must_use]
274 pub fn current_url(&self) -> &str {
275 self.current_url.expose()
276 }
277
278 #[must_use]
282 pub fn channel(&self) -> &Channel {
283 &self.channel
284 }
285
286 pub async fn query_address(&mut self, address: &str) -> Result<(u64, u64), anyhow::Error> {
294 let req = QueryAccountRequest {
295 address: address.to_string(),
296 };
297 let resp = self
298 .auth
299 .account(req)
300 .await?
301 .into_inner()
302 .account
303 .ok_or_else(|| {
304 anyhow::anyhow!("Query account request failure, account should exist")
305 })?;
306
307 let account = BaseAccount::decode(&*resp.value)?;
308 Ok((account.account_number, account.sequence))
309 }
310
311 pub async fn get_account(&mut self, address: &str) -> Result<BaseAccount, anyhow::Error> {
318 let req = QueryAccountRequest {
319 address: address.to_string(),
320 };
321 let resp = self
322 .auth
323 .account(req)
324 .await?
325 .into_inner()
326 .account
327 .ok_or_else(|| {
328 anyhow::anyhow!("Query account request failure, account should exist")
329 })?;
330
331 Ok(BaseAccount::decode(&*resp.value)?)
332 }
333
334 pub async fn get_account_balances(
341 &mut self,
342 address: &str,
343 ) -> Result<Vec<Coin>, anyhow::Error> {
344 let req = QueryAllBalancesRequest {
345 address: address.to_string(),
346 resolve_denom: false,
347 pagination: None,
348 };
349 let balances = self.bank.all_balances(req).await?.into_inner().balances;
350 Ok(balances)
351 }
352
353 pub async fn get_authenticators(
362 &mut self,
363 address: &str,
364 ) -> Result<Vec<AccountAuthenticator>, anyhow::Error> {
365 let req = GetAuthenticatorsRequest {
366 account: address.to_string(),
367 };
368 let resp = self.accountplus.get_authenticators(req).await?.into_inner();
369 Ok(resp.account_authenticators)
370 }
371
372 pub async fn get_node_info(&mut self) -> Result<GetNodeInfoResponse, anyhow::Error> {
378 let req = GetNodeInfoRequest {};
379 let info = self.base.get_node_info(req).await?.into_inner();
380 Ok(info)
381 }
382
383 pub async fn latest_block(&mut self) -> Result<Block, anyhow::Error> {
389 let req = GetLatestBlockRequest::default();
390 let latest_block = self
391 .base
392 .get_latest_block(req)
393 .await?
394 .into_inner()
395 .sdk_block
396 .ok_or_else(|| anyhow::anyhow!("The latest block is empty"))?;
397 Ok(latest_block)
398 }
399
400 pub async fn latest_block_height(&mut self) -> Result<Height, anyhow::Error> {
406 let latest_block = self.latest_block().await?;
407 let header = latest_block
408 .header
409 .ok_or_else(|| anyhow::anyhow!("The block doesn't contain a header"))?;
410 let height = Height(header.height.try_into()?);
411 Ok(height)
412 }
413
414 pub async fn get_perpetuals(&mut self) -> Result<Vec<Perpetual>, anyhow::Error> {
420 let req = QueryAllPerpetualsRequest { pagination: None };
421 let response = self.perpetuals.all_perpetuals(req).await?.into_inner();
422 Ok(response.perpetual)
423 }
424
425 pub async fn get_clob_pairs(&mut self) -> Result<Vec<ClobPair>, anyhow::Error> {
431 let req = QueryAllClobPairRequest { pagination: None };
432 let pairs = self.clob.clob_pair_all(req).await?.into_inner().clob_pair;
433 Ok(pairs)
434 }
435
436 pub async fn get_subaccount(
442 &mut self,
443 address: &str,
444 number: u32,
445 ) -> Result<SubaccountInfo, anyhow::Error> {
446 let req = QueryGetSubaccountRequest {
447 owner: address.to_string(),
448 number,
449 };
450 let subaccount = self
451 .subaccounts
452 .subaccount(req)
453 .await?
454 .into_inner()
455 .subaccount
456 .ok_or_else(|| {
457 anyhow::anyhow!("Subaccount query response does not contain subaccount")
458 })?;
459 Ok(subaccount)
460 }
461
462 pub async fn simulate_tx(&mut self, tx_bytes: Vec<u8>) -> Result<u64, anyhow::Error> {
468 let req = SimulateRequest {
469 tx_bytes,
470 ..Default::default()
471 };
472 let gas_used = self
473 .tx
474 .simulate(req)
475 .await?
476 .into_inner()
477 .gas_info
478 .ok_or_else(|| anyhow::anyhow!("Simulation response does not contain gas info"))?
479 .gas_used;
480 Ok(gas_used)
481 }
482
483 pub async fn broadcast_tx(&mut self, tx_bytes: Vec<u8>) -> Result<TxHash, anyhow::Error> {
489 let req = BroadcastTxRequest {
490 tx_bytes,
491 mode: BroadcastMode::Sync as i32,
492 };
493 let response = self.tx.broadcast_tx(req).await?.into_inner();
494
495 if let Some(tx_response) = response.tx_response {
496 if tx_response.code != 0 {
497 anyhow::bail!(
498 "Transaction broadcast failed: code={}, log={}",
499 tx_response.code,
500 tx_response.raw_log
501 );
502 }
503 Ok(tx_response.txhash)
504 } else {
505 Err(anyhow::anyhow!(
506 "Broadcast response does not contain tx_response"
507 ))
508 }
509 }
510
511 pub async fn get_tx(&mut self, hash: &str) -> Result<Tx, anyhow::Error> {
517 let req = GetTxRequest {
518 hash: hash.to_string(),
519 };
520 let response = self.tx.get_tx(req).await?.into_inner();
521
522 if let Some(tx) = response.tx {
523 let tx_bytes = tx.encode_to_vec();
525 Tx::try_from(tx_bytes.as_slice()).map_err(|e| anyhow::anyhow!("{e}"))
526 } else {
527 anyhow::bail!("Transaction not found")
528 }
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use rstest::rstest;
535
536 use super::*;
537
538 #[rstest]
539 fn test_height_ordering() {
540 let h1 = Height(100);
541 let h2 = Height(200);
542 assert!(h1 < h2);
543 assert_eq!(h1, Height(100));
544 }
545
546 #[tokio::test]
547 async fn test_new_with_fallback_empty_urls() {
548 let result = DydxGrpcClient::new_with_fallback(&[] as &[&str]).await;
549 assert!(result.is_err());
550 if let Err(DydxError::Config(msg)) = result {
551 assert_eq!(msg, "No gRPC URLs provided");
552 } else {
553 panic!("Expected Config error");
554 }
555 }
556
557 #[tokio::test]
558 async fn test_new_with_fallback_invalid_urls() {
559 let invalid_urls = vec!["http://", "http://[::1"];
561 let result = DydxGrpcClient::new_with_fallback(&invalid_urls).await;
562
563 assert!(matches!(result, Err(DydxError::Config(_))));
564 }
565}
566
567#[cfg(test)]
568mod logging_tests {
569 use super::*;
570
571 #[rstest::rstest]
572 #[case("http://node.example.com/grpc", "http://node.example.com")]
573 #[case(
574 "https://URL_SECRET_USER:URL_SECRET_PASSWORD@node.example.com:8443/URL_SECRET_PATH?token=URL_SECRET_QUERY",
575 "https://node.example.com:8443"
576 )]
577 #[tokio::test]
578 async fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
579 let channel = Channel::from_static("http://127.0.0.1:1").connect_lazy();
580
581 let client = DydxGrpcClient {
582 auth: AuthClient::new(channel.clone()),
583 bank: BankClient::new(channel.clone()),
584 base: BaseClient::new(channel.clone()),
585 tx: TxClient::new(channel.clone()),
586 clob: ClobClient::new(channel.clone()),
587 perpetuals: PerpetualsClient::new(channel.clone()),
588 subaccounts: SubaccountsClient::new(channel.clone()),
589 accountplus: AccountPlusClient::new(channel.clone()),
590 channel,
591 current_url: url.into(),
592 };
593
594 let debug = format!("{client:?}");
595 assert!(debug.contains(&format!("current_url: {origin:?}")));
596 assert!(!debug.contains("URL_SECRET"));
597 assert_eq!(client.current_url.expose(), url);
598 }
599}