openzeppelin_relayer/api/controllers/
network.rs

1//! # Network Controller
2//!
3//! Handles HTTP endpoints for network operations including:
4//! - Listing networks
5//! - Getting network details
6//! - Updating network RPC URLs
7
8use crate::{
9    config::{EvmNetworkConfig, SolanaNetworkConfig, StellarNetworkConfig},
10    jobs::JobProducerTrait,
11    models::{
12        ApiError, ApiResponse, NetworkConfigData, NetworkRepoModel, NetworkResponse,
13        PaginationMeta, PaginationQuery, ThinDataAppState, UpdateNetworkRequest,
14    },
15    repositories::{
16        ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository,
17        Repository, TransactionCounterTrait, TransactionRepository,
18    },
19};
20use actix_web::HttpResponse;
21use eyre::Result;
22
23/// Lists all networks with pagination support.
24///
25/// # Arguments
26///
27/// * `query` - The pagination query parameters.
28/// * `state` - The application state containing the network repository.
29///
30/// # Returns
31///
32/// A paginated list of networks.
33pub async fn list_networks<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
34    query: PaginationQuery,
35    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
36) -> Result<HttpResponse, ApiError>
37where
38    J: JobProducerTrait + Send + Sync + 'static,
39    RR: RelayerRepository
40        + Repository<crate::models::RelayerRepoModel, String>
41        + Send
42        + Sync
43        + 'static,
44    TR: TransactionRepository
45        + Repository<crate::models::TransactionRepoModel, String>
46        + Send
47        + Sync
48        + 'static,
49    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
50    NFR: Repository<crate::models::NotificationRepoModel, String> + Send + Sync + 'static,
51    SR: Repository<crate::models::SignerRepoModel, String> + Send + Sync + 'static,
52    TCR: TransactionCounterTrait + Send + Sync + 'static,
53    PR: PluginRepositoryTrait + Send + Sync + 'static,
54    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
55{
56    let networks = state.network_repository.list_paginated(query).await?;
57
58    let mapped_networks: Vec<NetworkResponse> =
59        networks.items.into_iter().map(|n| n.into()).collect();
60
61    Ok(HttpResponse::Ok().json(ApiResponse::paginated(
62        mapped_networks,
63        PaginationMeta {
64            total_items: networks.total,
65            current_page: networks.page,
66            per_page: networks.per_page,
67        },
68    )))
69}
70
71/// Retrieves details of a specific network by ID.
72///
73/// # Arguments
74///
75/// * `network_id` - The ID of the network (e.g., "evm:sepolia", "solana:mainnet").
76/// * `state` - The application state containing the network repository.
77///
78/// # Returns
79///
80/// The details of the specified network or 404 if not found.
81pub async fn get_network<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
82    network_id: String,
83    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
84) -> Result<HttpResponse, ApiError>
85where
86    J: JobProducerTrait + Send + Sync + 'static,
87    RR: RelayerRepository
88        + Repository<crate::models::RelayerRepoModel, String>
89        + Send
90        + Sync
91        + 'static,
92    TR: TransactionRepository
93        + Repository<crate::models::TransactionRepoModel, String>
94        + Send
95        + Sync
96        + 'static,
97    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
98    NFR: Repository<crate::models::NotificationRepoModel, String> + Send + Sync + 'static,
99    SR: Repository<crate::models::SignerRepoModel, String> + Send + Sync + 'static,
100    TCR: TransactionCounterTrait + Send + Sync + 'static,
101    PR: PluginRepositoryTrait + Send + Sync + 'static,
102    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
103{
104    let network = state
105        .network_repository
106        .get_by_id(network_id.clone())
107        .await
108        .map_err(|e| match e {
109            crate::models::RepositoryError::NotFound(_) => {
110                ApiError::NotFound(format!("Network with ID '{network_id}' not found"))
111            }
112            _ => ApiError::InternalError(format!("Failed to retrieve network: {e}")),
113        })?;
114
115    let network_response: NetworkResponse = network.into();
116
117    Ok(HttpResponse::Ok().json(ApiResponse::success(network_response)))
118}
119
120/// Updates a network's configuration.
121/// Currently supports updating RPC URLs only. Can be extended to support other fields.
122///
123/// # Arguments
124///
125/// * `network_id` - The ID of the network (e.g., "evm:sepolia", "solana:mainnet").
126/// * `request` - The update request containing fields to update.
127/// * `state` - The application state containing the network repository.
128///
129/// # Returns
130///
131/// The updated network or an error if update fails.
132pub async fn update_network<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
133    network_id: String,
134    request: UpdateNetworkRequest,
135    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
136) -> Result<HttpResponse, ApiError>
137where
138    J: JobProducerTrait + Send + Sync + 'static,
139    RR: RelayerRepository
140        + Repository<crate::models::RelayerRepoModel, String>
141        + Send
142        + Sync
143        + 'static,
144    TR: TransactionRepository
145        + Repository<crate::models::TransactionRepoModel, String>
146        + Send
147        + Sync
148        + 'static,
149    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
150    NFR: Repository<crate::models::NotificationRepoModel, String> + Send + Sync + 'static,
151    SR: Repository<crate::models::SignerRepoModel, String> + Send + Sync + 'static,
152    TCR: TransactionCounterTrait + Send + Sync + 'static,
153    PR: PluginRepositoryTrait + Send + Sync + 'static,
154    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
155{
156    // Validate request
157    request.validate()?;
158
159    // Ensure at least one field is provided for update
160    if request.rpc_urls.is_none() {
161        return Err(ApiError::BadRequest(
162            "At least one field must be provided for update".to_string(),
163        ));
164    }
165
166    // Get existing network
167    let mut network = state
168        .network_repository
169        .get_by_id(network_id.clone())
170        .await
171        .map_err(|e| match e {
172            crate::models::RepositoryError::NotFound(_) => {
173                ApiError::NotFound(format!("Network with ID '{network_id}' not found"))
174            }
175            _ => ApiError::InternalError(format!("Failed to retrieve network: {e}")),
176        })?;
177
178    // Update fields in the network config
179    let common = network.common();
180    let mut updated_common = common.clone();
181
182    // Update RPC URLs if provided
183    if let Some(rpc_urls) = request.rpc_urls {
184        updated_common.rpc_urls = Some(rpc_urls);
185    }
186
187    // Update the network config based on type
188    let updated_config = match network.config {
189        NetworkConfigData::Evm(evm_config) => NetworkConfigData::Evm(EvmNetworkConfig {
190            common: updated_common,
191            chain_id: evm_config.chain_id,
192            required_confirmations: evm_config.required_confirmations,
193            features: evm_config.features,
194            symbol: evm_config.symbol,
195            gas_price_cache: evm_config.gas_price_cache,
196        }),
197        NetworkConfigData::Solana(_) => NetworkConfigData::Solana(SolanaNetworkConfig {
198            common: updated_common,
199        }),
200        NetworkConfigData::Stellar(stellar_config) => {
201            NetworkConfigData::Stellar(StellarNetworkConfig {
202                common: updated_common,
203                passphrase: stellar_config.passphrase,
204                horizon_url: stellar_config.horizon_url,
205            })
206        }
207    };
208
209    // Update the network model
210    network.config = updated_config;
211
212    // Save the updated network
213    let saved_network = state
214        .network_repository
215        .update(network.id.clone(), network)
216        .await?;
217
218    let network_response: NetworkResponse = saved_network.into();
219
220    Ok(HttpResponse::Ok().json(ApiResponse::success(network_response)))
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::{
227        config::{NetworkConfigCommon, StellarNetworkConfig},
228        models::{NetworkType, PaginationQuery, RpcConfig},
229        utils::mocks::mockutils::{
230            create_mock_app_state, create_mock_network, create_mock_solana_network,
231        },
232    };
233    use actix_web::web::ThinData;
234
235    /// Helper function to create a mock Stellar network for testing
236    fn create_mock_stellar_network() -> NetworkRepoModel {
237        NetworkRepoModel {
238            id: "stellar:testnet".to_string(),
239            name: "Stellar Testnet".to_string(),
240            network_type: NetworkType::Stellar,
241            config: NetworkConfigData::Stellar(StellarNetworkConfig {
242                common: NetworkConfigCommon {
243                    network: "stellar-testnet".to_string(),
244                    from: None,
245                    rpc_urls: Some(vec![RpcConfig::new(
246                        "https://soroban-testnet.stellar.org".to_string(),
247                    )]),
248                    explorer_urls: None,
249                    average_blocktime_ms: Some(5000),
250                    is_testnet: Some(true),
251                    tags: None,
252                },
253                passphrase: Some("Test SDF Network ; September 2015".to_string()),
254                horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
255            }),
256        }
257    }
258
259    /// Helper function to create a mock EVM network with a specific ID
260    fn create_mock_evm_network_with_id(id: &str, name: &str) -> NetworkRepoModel {
261        let mut network = create_mock_network();
262        network.id = id.to_string();
263        network.name = name.to_string();
264        network
265    }
266
267    // ============================================
268    // Tests for list_networks
269    // ============================================
270
271    #[actix_web::test]
272    async fn test_list_networks_empty() {
273        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
274        let query = PaginationQuery {
275            page: 1,
276            per_page: 10,
277        };
278
279        let result = list_networks(query, ThinData(app_state)).await;
280
281        assert!(result.is_ok());
282        let response = result.unwrap();
283        assert_eq!(response.status(), 200);
284    }
285
286    #[actix_web::test]
287    async fn test_list_networks_with_single_network() {
288        let network = create_mock_network();
289        let app_state =
290            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
291        let query = PaginationQuery {
292            page: 1,
293            per_page: 10,
294        };
295
296        let result = list_networks(query, ThinData(app_state)).await;
297
298        assert!(result.is_ok());
299        let response = result.unwrap();
300        assert_eq!(response.status(), 200);
301    }
302
303    #[actix_web::test]
304    async fn test_list_networks_with_multiple_networks() {
305        let evm_network = create_mock_evm_network_with_id("evm:sepolia", "Sepolia");
306        let solana_network = create_mock_solana_network();
307        let stellar_network = create_mock_stellar_network();
308
309        let app_state = create_mock_app_state(
310            None,
311            None,
312            None,
313            Some(vec![evm_network, solana_network, stellar_network]),
314            None,
315            None,
316        )
317        .await;
318
319        let query = PaginationQuery {
320            page: 1,
321            per_page: 10,
322        };
323
324        let result = list_networks(query, ThinData(app_state)).await;
325
326        assert!(result.is_ok());
327        let response = result.unwrap();
328        assert_eq!(response.status(), 200);
329    }
330
331    #[actix_web::test]
332    async fn test_list_networks_pagination() {
333        let network1 = create_mock_evm_network_with_id("evm:network1", "Network 1");
334        let network2 = create_mock_evm_network_with_id("evm:network2", "Network 2");
335        let network3 = create_mock_evm_network_with_id("evm:network3", "Network 3");
336
337        let app_state = create_mock_app_state(
338            None,
339            None,
340            None,
341            Some(vec![network1, network2, network3]),
342            None,
343            None,
344        )
345        .await;
346
347        // Request first page with 2 items per page
348        let query = PaginationQuery {
349            page: 1,
350            per_page: 2,
351        };
352
353        let result = list_networks(query, ThinData(app_state)).await;
354
355        assert!(result.is_ok());
356        let response = result.unwrap();
357        assert_eq!(response.status(), 200);
358    }
359
360    // ============================================
361    // Tests for get_network
362    // ============================================
363
364    #[actix_web::test]
365    async fn test_get_network_success() {
366        let network = create_mock_network();
367        let network_id = network.id.clone();
368        let app_state =
369            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
370
371        let result = get_network(network_id, ThinData(app_state)).await;
372
373        assert!(result.is_ok());
374        let response = result.unwrap();
375        assert_eq!(response.status(), 200);
376    }
377
378    #[actix_web::test]
379    async fn test_get_network_not_found() {
380        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
381
382        let result = get_network("nonexistent-network".to_string(), ThinData(app_state)).await;
383
384        assert!(result.is_err());
385        let error = result.unwrap_err();
386        match error {
387            ApiError::NotFound(msg) => {
388                assert!(msg.contains("nonexistent-network"));
389            }
390            _ => panic!("Expected NotFound error, got {error:?}"),
391        }
392    }
393
394    #[actix_web::test]
395    async fn test_get_network_evm() {
396        let network = create_mock_network();
397        let network_id = network.id.clone();
398        let app_state =
399            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
400
401        let result = get_network(network_id, ThinData(app_state)).await;
402
403        assert!(result.is_ok());
404        let response = result.unwrap();
405        assert_eq!(response.status(), 200);
406    }
407
408    #[actix_web::test]
409    async fn test_get_network_solana() {
410        let mut network = create_mock_solana_network();
411        network.id = "solana:devnet".to_string();
412        let network_id = network.id.clone();
413        let app_state =
414            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
415
416        let result = get_network(network_id, ThinData(app_state)).await;
417
418        assert!(result.is_ok());
419        let response = result.unwrap();
420        assert_eq!(response.status(), 200);
421    }
422
423    #[actix_web::test]
424    async fn test_get_network_stellar() {
425        let network = create_mock_stellar_network();
426        let network_id = network.id.clone();
427        let app_state =
428            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
429
430        let result = get_network(network_id, ThinData(app_state)).await;
431
432        assert!(result.is_ok());
433        let response = result.unwrap();
434        assert_eq!(response.status(), 200);
435    }
436
437    // ============================================
438    // Tests for update_network
439    // ============================================
440
441    #[actix_web::test]
442    async fn test_update_network_evm_success() {
443        let network = create_mock_network();
444        let network_id = network.id.clone();
445        let app_state =
446            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
447
448        let request = UpdateNetworkRequest {
449            rpc_urls: Some(vec![
450                RpcConfig::new("https://new-rpc1.example.com".to_string()),
451                RpcConfig::new("https://new-rpc2.example.com".to_string()),
452            ]),
453        };
454
455        let result = update_network(network_id, request, ThinData(app_state)).await;
456
457        assert!(result.is_ok());
458        let response = result.unwrap();
459        assert_eq!(response.status(), 200);
460    }
461
462    #[actix_web::test]
463    async fn test_update_network_solana_success() {
464        let mut network = create_mock_solana_network();
465        network.id = "solana:devnet".to_string();
466        let network_id = network.id.clone();
467        let app_state =
468            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
469
470        let request = UpdateNetworkRequest {
471            rpc_urls: Some(vec![RpcConfig::new(
472                "https://api.devnet.solana.com".to_string(),
473            )]),
474        };
475
476        let result = update_network(network_id, request, ThinData(app_state)).await;
477
478        assert!(result.is_ok());
479        let response = result.unwrap();
480        assert_eq!(response.status(), 200);
481    }
482
483    #[actix_web::test]
484    async fn test_update_network_stellar_success() {
485        let network = create_mock_stellar_network();
486        let network_id = network.id.clone();
487        let app_state =
488            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
489
490        let request = UpdateNetworkRequest {
491            rpc_urls: Some(vec![RpcConfig::new(
492                "https://new-soroban-testnet.stellar.org".to_string(),
493            )]),
494        };
495
496        let result = update_network(network_id, request, ThinData(app_state)).await;
497
498        assert!(result.is_ok());
499        let response = result.unwrap();
500        assert_eq!(response.status(), 200);
501    }
502
503    #[actix_web::test]
504    async fn test_update_network_not_found() {
505        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
506
507        let request = UpdateNetworkRequest {
508            rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]),
509        };
510
511        let result = update_network(
512            "nonexistent-network".to_string(),
513            request,
514            ThinData(app_state),
515        )
516        .await;
517
518        assert!(result.is_err());
519        let error = result.unwrap_err();
520        match error {
521            ApiError::NotFound(msg) => {
522                assert!(msg.contains("nonexistent-network"));
523            }
524            _ => panic!("Expected NotFound error, got {error:?}"),
525        }
526    }
527
528    #[actix_web::test]
529    async fn test_update_network_no_fields_provided() {
530        let network = create_mock_network();
531        let network_id = network.id.clone();
532        let app_state =
533            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
534
535        let request = UpdateNetworkRequest { rpc_urls: None };
536
537        let result = update_network(network_id, request, ThinData(app_state)).await;
538
539        assert!(result.is_err());
540        let error = result.unwrap_err();
541        match error {
542            ApiError::BadRequest(msg) => {
543                assert!(msg.contains("At least one field must be provided"));
544            }
545            _ => panic!("Expected BadRequest error, got {error:?}"),
546        }
547    }
548
549    #[actix_web::test]
550    async fn test_update_network_empty_rpc_urls() {
551        let network = create_mock_network();
552        let network_id = network.id.clone();
553        let app_state =
554            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
555
556        let request = UpdateNetworkRequest {
557            rpc_urls: Some(vec![]),
558        };
559
560        let result = update_network(network_id, request, ThinData(app_state)).await;
561
562        assert!(result.is_err());
563        let error = result.unwrap_err();
564        match error {
565            ApiError::BadRequest(msg) => {
566                assert!(msg.contains("at least one RPC endpoint"));
567            }
568            _ => panic!("Expected BadRequest error, got {error:?}"),
569        }
570    }
571
572    #[actix_web::test]
573    async fn test_update_network_invalid_rpc_url() {
574        let network = create_mock_network();
575        let network_id = network.id.clone();
576        let app_state =
577            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
578
579        let request = UpdateNetworkRequest {
580            rpc_urls: Some(vec![RpcConfig::new(
581                "ftp://invalid-protocol.com".to_string(),
582            )]),
583        };
584
585        let result = update_network(network_id, request, ThinData(app_state)).await;
586
587        assert!(result.is_err());
588        let error = result.unwrap_err();
589        match error {
590            ApiError::BadRequest(msg) => {
591                assert!(msg.contains("Invalid RPC URL"));
592            }
593            _ => panic!("Expected BadRequest error, got {error:?}"),
594        }
595    }
596
597    #[actix_web::test]
598    async fn test_update_network_with_weighted_rpc_urls() {
599        let network = create_mock_network();
600        let network_id = network.id.clone();
601        let app_state =
602            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
603
604        let request = UpdateNetworkRequest {
605            rpc_urls: Some(vec![
606                RpcConfig {
607                    url: "https://primary-rpc.example.com".to_string(),
608                    weight: 80,
609                },
610                RpcConfig {
611                    url: "https://backup-rpc.example.com".to_string(),
612                    weight: 20,
613                },
614            ]),
615        };
616
617        let result = update_network(network_id, request, ThinData(app_state)).await;
618
619        assert!(result.is_ok());
620        let response = result.unwrap();
621        assert_eq!(response.status(), 200);
622    }
623
624    #[actix_web::test]
625    async fn test_update_network_preserves_other_evm_fields() {
626        // This test verifies that updating RPC URLs doesn't affect other EVM-specific fields
627        let network = create_mock_network();
628        let network_id = network.id.clone();
629        let app_state =
630            create_mock_app_state(None, None, None, Some(vec![network]), None, None).await;
631
632        let request = UpdateNetworkRequest {
633            rpc_urls: Some(vec![RpcConfig::new(
634                "https://new-rpc.example.com".to_string(),
635            )]),
636        };
637
638        let result = update_network(network_id.clone(), request, ThinData(app_state.clone())).await;
639
640        assert!(result.is_ok());
641
642        // Verify the network was updated by fetching it again
643        let get_result = get_network(network_id, ThinData(app_state)).await;
644        assert!(get_result.is_ok());
645    }
646}