openzeppelin_relayer/api/routes/
relayer.rs

1//! This module defines the HTTP routes for relayer operations.
2//! It includes handlers for listing, retrieving, updating, and managing relayer transactions.
3//! The routes are integrated with the Actix-web framework and interact with the relayer controller.
4use crate::{
5    api::controllers::relayer,
6    domain::{SignDataRequest, SignTransactionRequest, SignTypedDataRequest},
7    models::{
8        transaction::request::{
9            SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest,
10        },
11        CreateRelayerRequest, DefaultAppState, PaginationQuery,
12    },
13};
14use actix_web::{delete, get, patch, post, put, web, Responder};
15use serde::Deserialize;
16use utoipa::ToSchema;
17
18/// Lists all relayers with pagination support.
19#[get("/relayers")]
20async fn list_relayers(
21    query: web::Query<PaginationQuery>,
22    data: web::ThinData<DefaultAppState>,
23) -> impl Responder {
24    relayer::list_relayers(query.into_inner(), data).await
25}
26
27/// Retrieves details of a specific relayer by ID.
28#[get("/relayers/{relayer_id}")]
29async fn get_relayer(
30    relayer_id: web::Path<String>,
31    data: web::ThinData<DefaultAppState>,
32) -> impl Responder {
33    relayer::get_relayer(relayer_id.into_inner(), data).await
34}
35
36/// Creates a new relayer.
37#[post("/relayers")]
38async fn create_relayer(
39    request: web::Json<CreateRelayerRequest>,
40    data: web::ThinData<DefaultAppState>,
41) -> impl Responder {
42    relayer::create_relayer(request.into_inner(), data).await
43}
44
45/// Updates a relayer's information using JSON Merge Patch (RFC 7396).
46#[patch("/relayers/{relayer_id}")]
47async fn update_relayer(
48    relayer_id: web::Path<String>,
49    patch: web::Json<serde_json::Value>,
50    data: web::ThinData<DefaultAppState>,
51) -> impl Responder {
52    relayer::update_relayer(relayer_id.into_inner(), patch.into_inner(), data).await
53}
54
55/// Deletes a relayer by ID.
56#[delete("/relayers/{relayer_id}")]
57async fn delete_relayer(
58    relayer_id: web::Path<String>,
59    data: web::ThinData<DefaultAppState>,
60) -> impl Responder {
61    relayer::delete_relayer(relayer_id.into_inner(), data).await
62}
63
64/// Query parameters for the relayer status endpoint.
65#[derive(Debug, Deserialize)]
66pub struct StatusQuery {
67    #[serde(default = "default_true")]
68    pub include_balance: bool,
69    #[serde(default = "default_true")]
70    pub include_pending_count: bool,
71    #[serde(default = "default_true")]
72    pub include_last_confirmed_tx: bool,
73}
74
75fn default_true() -> bool {
76    true
77}
78
79/// Fetches the current status of a specific relayer.
80#[get("/relayers/{relayer_id}/status")]
81async fn get_relayer_status(
82    relayer_id: web::Path<String>,
83    query: web::Query<StatusQuery>,
84    data: web::ThinData<DefaultAppState>,
85) -> impl Responder {
86    let q = query.into_inner();
87    relayer::get_relayer_status(
88        relayer_id.into_inner(),
89        crate::models::GetStatusOptions {
90            include_balance: q.include_balance,
91            include_pending_count: q.include_pending_count,
92            include_last_confirmed_tx: q.include_last_confirmed_tx,
93        },
94        data,
95    )
96    .await
97}
98
99/// Retrieves the balance of a specific relayer.
100#[get("/relayers/{relayer_id}/balance")]
101async fn get_relayer_balance(
102    relayer_id: web::Path<String>,
103    data: web::ThinData<DefaultAppState>,
104) -> impl Responder {
105    relayer::get_relayer_balance(relayer_id.into_inner(), data).await
106}
107
108/// Sends a transaction through the specified relayer.
109#[post("/relayers/{relayer_id}/transactions")]
110async fn send_transaction(
111    relayer_id: web::Path<String>,
112    req: web::Json<serde_json::Value>,
113    data: web::ThinData<DefaultAppState>,
114) -> impl Responder {
115    relayer::send_transaction(relayer_id.into_inner(), req.into_inner(), data).await
116}
117
118#[derive(Deserialize, ToSchema)]
119pub struct TransactionPath {
120    relayer_id: String,
121    transaction_id: String,
122}
123
124/// Retrieves a specific transaction by its ID.
125#[get("/relayers/{relayer_id}/transactions/{transaction_id}")]
126async fn get_transaction_by_id(
127    path: web::Path<TransactionPath>,
128    data: web::ThinData<DefaultAppState>,
129) -> impl Responder {
130    let path = path.into_inner();
131    relayer::get_transaction_by_id(path.relayer_id, path.transaction_id, data).await
132}
133
134/// Retrieves a transaction by its nonce value.
135#[get("/relayers/{relayer_id}/transactions/by-nonce/{nonce}")]
136async fn get_transaction_by_nonce(
137    params: web::Path<(String, u64)>,
138    data: web::ThinData<DefaultAppState>,
139) -> impl Responder {
140    let params = params.into_inner();
141    relayer::get_transaction_by_nonce(params.0, params.1, data).await
142}
143
144/// Lists all transactions for a specific relayer with pagination.
145#[get("/relayers/{relayer_id}/transactions")]
146async fn list_transactions(
147    relayer_id: web::Path<String>,
148    query: web::Query<PaginationQuery>,
149    data: web::ThinData<DefaultAppState>,
150) -> impl Responder {
151    relayer::list_transactions(relayer_id.into_inner(), query.into_inner(), data).await
152}
153
154/// Deletes all pending transactions for a specific relayer.
155#[delete("/relayers/{relayer_id}/transactions/pending")]
156async fn delete_pending_transactions(
157    relayer_id: web::Path<String>,
158    data: web::ThinData<DefaultAppState>,
159) -> impl Responder {
160    relayer::delete_pending_transactions(relayer_id.into_inner(), data).await
161}
162
163/// Cancels a specific transaction by its ID.
164#[delete("/relayers/{relayer_id}/transactions/{transaction_id}")]
165async fn cancel_transaction(
166    path: web::Path<TransactionPath>,
167    data: web::ThinData<DefaultAppState>,
168) -> impl Responder {
169    let path = path.into_inner();
170    relayer::cancel_transaction(path.relayer_id, path.transaction_id, data).await
171}
172
173/// Replaces a specific transaction with a new one.
174#[put("/relayers/{relayer_id}/transactions/{transaction_id}")]
175async fn replace_transaction(
176    path: web::Path<TransactionPath>,
177    req: web::Json<serde_json::Value>,
178    data: web::ThinData<DefaultAppState>,
179) -> impl Responder {
180    let path = path.into_inner();
181    relayer::replace_transaction(path.relayer_id, path.transaction_id, req.into_inner(), data).await
182}
183
184/// Signs data using the specified relayer.
185#[post("/relayers/{relayer_id}/sign")]
186async fn sign(
187    relayer_id: web::Path<String>,
188    req: web::Json<SignDataRequest>,
189    data: web::ThinData<DefaultAppState>,
190) -> impl Responder {
191    relayer::sign_data(relayer_id.into_inner(), req.into_inner(), data).await
192}
193
194/// Signs typed data using the specified relayer.
195#[post("/relayers/{relayer_id}/sign-typed-data")]
196async fn sign_typed_data(
197    relayer_id: web::Path<String>,
198    req: web::Json<SignTypedDataRequest>,
199    data: web::ThinData<DefaultAppState>,
200) -> impl Responder {
201    relayer::sign_typed_data(relayer_id.into_inner(), req.into_inner(), data).await
202}
203
204/// Signs a transaction using the specified relayer (Stellar only).
205#[post("/relayers/{relayer_id}/sign-transaction")]
206async fn sign_transaction(
207    relayer_id: web::Path<String>,
208    req: web::Json<SignTransactionRequest>,
209    data: web::ThinData<DefaultAppState>,
210) -> impl Responder {
211    relayer::sign_transaction(relayer_id.into_inner(), req.into_inner(), data).await
212}
213
214/// Performs a JSON-RPC call using the specified relayer.
215#[post("/relayers/{relayer_id}/rpc")]
216async fn rpc(
217    relayer_id: web::Path<String>,
218    req: web::Json<serde_json::Value>,
219    data: web::ThinData<DefaultAppState>,
220) -> impl Responder {
221    relayer::relayer_rpc(relayer_id.into_inner(), req.into_inner(), data).await
222}
223
224/// Estimates fees for a transaction (gas abstraction endpoint).
225#[post("/relayers/{relayer_id}/transactions/sponsored/quote")]
226async fn quote_sponsored_transaction(
227    relayer_id: web::Path<String>,
228    req: web::Json<SponsoredTransactionQuoteRequest>,
229    data: web::ThinData<DefaultAppState>,
230) -> impl Responder {
231    relayer::quote_sponsored_transaction(relayer_id.into_inner(), req.into_inner(), data).await
232}
233
234/// Prepares a transaction with fee payments (gas abstraction endpoint).
235#[post("/relayers/{relayer_id}/transactions/sponsored/build")]
236async fn build_sponsored_transaction(
237    relayer_id: web::Path<String>,
238    req: web::Json<SponsoredTransactionBuildRequest>,
239    data: web::ThinData<DefaultAppState>,
240) -> impl Responder {
241    relayer::build_sponsored_transaction(relayer_id.into_inner(), req.into_inner(), data).await
242}
243
244/// Initializes the routes for the relayer module.
245pub fn init(cfg: &mut web::ServiceConfig) {
246    // Register routes with literal segments before routes with path parameters
247    cfg.service(delete_pending_transactions); // /relayers/{id}/transactions/pending
248    cfg.service(quote_sponsored_transaction); // /relayers/{id}/transactions/sponsored/quote
249    cfg.service(build_sponsored_transaction); // /relayers/{id}/transactions/sponsored/build
250
251    // Then register other routes
252    cfg.service(cancel_transaction); // /relayers/{id}/transactions/{tx_id}
253    cfg.service(replace_transaction); // /relayers/{id}/transactions/{tx_id}
254    cfg.service(get_transaction_by_id); // /relayers/{id}/transactions/{tx_id}
255    cfg.service(get_transaction_by_nonce); // /relayers/{id}/transactions/by-nonce/{nonce}
256    cfg.service(send_transaction); // /relayers/{id}/transactions
257    cfg.service(list_transactions); // /relayers/{id}/transactions
258    cfg.service(get_relayer_status); // /relayers/{id}/status
259    cfg.service(get_relayer_balance); // /relayers/{id}/balance
260    cfg.service(sign); // /relayers/{id}/sign
261    cfg.service(sign_typed_data); // /relayers/{id}/sign-typed-data
262    cfg.service(sign_transaction); // /relayers/{id}/sign-transaction
263    cfg.service(rpc); // /relayers/{id}/rpc
264    cfg.service(get_relayer); // /relayers/{id}
265    cfg.service(create_relayer); // /relayers
266    cfg.service(update_relayer); // /relayers/{id}
267    cfg.service(delete_relayer); // /relayers/{id}
268    cfg.service(list_relayers); // /relayers
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::{
275        config::{EvmNetworkConfig, NetworkConfigCommon},
276        jobs::MockJobProducerTrait,
277        models::{
278            ApiKeyRepoModel, AppState, EvmTransactionData, LocalSignerConfigStorage,
279            NetworkConfigData, NetworkRepoModel, NetworkTransactionData, NetworkType,
280            RelayerEvmPolicy, RelayerNetworkPolicy, RelayerRepoModel, RpcConfig, SecretString,
281            SignerConfigStorage, SignerRepoModel, TransactionRepoModel, TransactionStatus, U256,
282        },
283        repositories::{
284            ApiKeyRepositoryStorage, ApiKeyRepositoryTrait, NetworkRepositoryStorage,
285            NotificationRepositoryStorage, PluginRepositoryStorage, RelayerRepositoryStorage,
286            Repository, SignerRepositoryStorage, TransactionCounterRepositoryStorage,
287            TransactionRepositoryStorage,
288        },
289    };
290    use actix_web::{http::StatusCode, test, App};
291    use std::sync::Arc;
292
293    // Simple mock for AppState
294    async fn get_test_app_state() -> AppState<
295        MockJobProducerTrait,
296        RelayerRepositoryStorage,
297        TransactionRepositoryStorage,
298        NetworkRepositoryStorage,
299        NotificationRepositoryStorage,
300        SignerRepositoryStorage,
301        TransactionCounterRepositoryStorage,
302        PluginRepositoryStorage,
303        ApiKeyRepositoryStorage,
304    > {
305        let relayer_repo = Arc::new(RelayerRepositoryStorage::new_in_memory());
306        let transaction_repo = Arc::new(TransactionRepositoryStorage::new_in_memory());
307        let signer_repo = Arc::new(SignerRepositoryStorage::new_in_memory());
308        let network_repo = Arc::new(NetworkRepositoryStorage::new_in_memory());
309        let api_key_repo = Arc::new(ApiKeyRepositoryStorage::new_in_memory());
310
311        // Create test entities so routes don't return 404
312
313        // Create test network configuration first
314        let test_network = NetworkRepoModel {
315            id: "evm:ethereum".to_string(),
316            name: "ethereum".to_string(),
317            network_type: NetworkType::Evm,
318            config: NetworkConfigData::Evm(EvmNetworkConfig {
319                common: NetworkConfigCommon {
320                    network: "ethereum".to_string(),
321                    from: None,
322                    rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]),
323                    explorer_urls: None,
324                    average_blocktime_ms: Some(12000),
325                    is_testnet: Some(false),
326                    tags: None,
327                },
328                chain_id: Some(1),
329                required_confirmations: Some(12),
330                features: None,
331                symbol: Some("ETH".to_string()),
332                gas_price_cache: None,
333            }),
334        };
335        network_repo.create(test_network).await.unwrap();
336
337        // Create local signer first
338        let test_signer = SignerRepoModel {
339            id: "test-signer".to_string(),
340            config: SignerConfigStorage::Local(LocalSignerConfigStorage {
341                raw_key: secrets::SecretVec::new(32, |v| v.copy_from_slice(&[0u8; 32])),
342            }),
343        };
344        signer_repo.create(test_signer).await.unwrap();
345
346        // Create test relayer
347        let test_relayer = RelayerRepoModel {
348            id: "test-id".to_string(),
349            name: "Test Relayer".to_string(),
350            network: "ethereum".to_string(),
351            network_type: NetworkType::Evm,
352            signer_id: "test-signer".to_string(),
353            address: "0x1234567890123456789012345678901234567890".to_string(),
354            paused: false,
355            system_disabled: false,
356            policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
357            notification_id: None,
358            custom_rpc_urls: None,
359            ..Default::default()
360        };
361        relayer_repo.create(test_relayer).await.unwrap();
362
363        // Create test transaction
364        let test_transaction = TransactionRepoModel {
365            id: "tx-123".to_string(),
366            relayer_id: "test-id".to_string(),
367            status: TransactionStatus::Pending,
368            status_reason: None,
369            created_at: chrono::Utc::now().to_rfc3339(),
370            sent_at: None,
371            confirmed_at: None,
372            valid_until: None,
373            network_data: NetworkTransactionData::Evm(EvmTransactionData {
374                gas_price: Some(20000000000u128),
375                gas_limit: Some(21000u64),
376                nonce: Some(1u64),
377                value: U256::from(0u64),
378                data: Some("0x".to_string()),
379                from: "0x1234567890123456789012345678901234567890".to_string(),
380                to: Some("0x9876543210987654321098765432109876543210".to_string()),
381                chain_id: 1u64,
382                hash: Some("0xabcdef".to_string()),
383                signature: None,
384                speed: None,
385                max_fee_per_gas: None,
386                max_priority_fee_per_gas: None,
387                raw: None,
388            }),
389            priced_at: None,
390            hashes: vec!["0xabcdef".to_string()],
391            network_type: NetworkType::Evm,
392            noop_count: None,
393            is_canceled: Some(false),
394            delete_at: None,
395        };
396        transaction_repo.create(test_transaction).await.unwrap();
397
398        // Create test api key
399        let test_api_key = ApiKeyRepoModel {
400            id: "test-api-key".to_string(),
401            name: "Test API Key".to_string(),
402            value: SecretString::new("test-value"),
403            permissions: vec!["test-permission".to_string()],
404            created_at: chrono::Utc::now().to_rfc3339(),
405            allowed_origins: vec!["*".to_string()],
406        };
407        api_key_repo.create(test_api_key).await.unwrap();
408
409        AppState {
410            relayer_repository: relayer_repo,
411            transaction_repository: transaction_repo,
412            signer_repository: signer_repo,
413            notification_repository: Arc::new(NotificationRepositoryStorage::new_in_memory()),
414            network_repository: network_repo,
415            transaction_counter_store: Arc::new(
416                TransactionCounterRepositoryStorage::new_in_memory(),
417            ),
418            job_producer: Arc::new(MockJobProducerTrait::new()),
419            plugin_repository: Arc::new(PluginRepositoryStorage::new_in_memory()),
420            api_key_repository: api_key_repo,
421        }
422    }
423
424    #[actix_web::test]
425    async fn test_routes_are_registered() -> Result<(), color_eyre::eyre::Error> {
426        // Create a test app with our routes
427        let app = test::init_service(
428            App::new()
429                .app_data(web::Data::new(get_test_app_state().await))
430                .configure(init),
431        )
432        .await;
433
434        // Test that routes are registered by checking they return 500 (not 404)
435
436        // Test GET /relayers
437        let req = test::TestRequest::get().uri("/relayers").to_request();
438        let resp = test::call_service(&app, req).await;
439        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
440
441        // Test GET /relayers/{id}
442        let req = test::TestRequest::get()
443            .uri("/relayers/test-id")
444            .to_request();
445        let resp = test::call_service(&app, req).await;
446        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
447
448        // Test PATCH /relayers/{id}
449        let req = test::TestRequest::patch()
450            .uri("/relayers/test-id")
451            .set_json(serde_json::json!({"paused": false}))
452            .to_request();
453        let resp = test::call_service(&app, req).await;
454        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
455
456        // Test GET /relayers/{id}/status
457        let req = test::TestRequest::get()
458            .uri("/relayers/test-id/status")
459            .to_request();
460        let resp = test::call_service(&app, req).await;
461        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
462
463        // Test GET /relayers/{id}/balance
464        let req = test::TestRequest::get()
465            .uri("/relayers/test-id/balance")
466            .to_request();
467        let resp = test::call_service(&app, req).await;
468        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
469
470        // Test POST /relayers/{id}/transactions
471        let req = test::TestRequest::post()
472            .uri("/relayers/test-id/transactions")
473            .set_json(serde_json::json!({}))
474            .to_request();
475        let resp = test::call_service(&app, req).await;
476        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
477
478        // Test GET /relayers/{id}/transactions/{tx_id}
479        let req = test::TestRequest::get()
480            .uri("/relayers/test-id/transactions/tx-123")
481            .to_request();
482        let resp = test::call_service(&app, req).await;
483        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
484
485        // Test GET /relayers/{id}/transactions/by-nonce/{nonce}
486        let req = test::TestRequest::get()
487            .uri("/relayers/test-id/transactions/by-nonce/123")
488            .to_request();
489        let resp = test::call_service(&app, req).await;
490        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
491
492        // Test GET /relayers/{id}/transactions
493        let req = test::TestRequest::get()
494            .uri("/relayers/test-id/transactions")
495            .to_request();
496        let resp = test::call_service(&app, req).await;
497        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
498
499        // Test DELETE /relayers/{id}/transactions/pending
500        let req = test::TestRequest::delete()
501            .uri("/relayers/test-id/transactions/pending")
502            .to_request();
503        let resp = test::call_service(&app, req).await;
504        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
505
506        // Test DELETE /relayers/{id}/transactions/{tx_id}
507        let req = test::TestRequest::delete()
508            .uri("/relayers/test-id/transactions/tx-123")
509            .to_request();
510        let resp = test::call_service(&app, req).await;
511        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
512
513        // Test PUT /relayers/{id}/transactions/{tx_id}
514        let req = test::TestRequest::put()
515            .uri("/relayers/test-id/transactions/tx-123")
516            .set_json(serde_json::json!({}))
517            .to_request();
518        let resp = test::call_service(&app, req).await;
519        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
520
521        // Test POST /relayers/{id}/sign
522        let req = test::TestRequest::post()
523            .uri("/relayers/test-id/sign")
524            .set_json(serde_json::json!({
525                "message": "0x1234567890abcdef"
526            }))
527            .to_request();
528        let resp = test::call_service(&app, req).await;
529        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
530
531        // Test POST /relayers/{id}/sign-typed-data
532        let req = test::TestRequest::post()
533            .uri("/relayers/test-id/sign-typed-data")
534            .set_json(serde_json::json!({
535                "domain_separator": "0x1234567890abcdef",
536                "hash_struct_message": "0x1234567890abcdef"
537            }))
538            .to_request();
539        let resp = test::call_service(&app, req).await;
540        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
541
542        // Test POST /relayers/{id}/rpc
543        let req = test::TestRequest::post()
544            .uri("/relayers/test-id/rpc")
545            .set_json(serde_json::json!({
546                "jsonrpc": "2.0",
547                "method": "eth_getBlockByNumber",
548                "params": ["0x1", true],
549                "id": 1
550            }))
551            .to_request();
552        let resp = test::call_service(&app, req).await;
553        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
554
555        // Test POST /relayers/{id}/transactions/sponsored/quote
556        let req = test::TestRequest::post()
557            .uri("/relayers/test-id/transactions/sponsored/quote")
558            .set_json(serde_json::json!({
559                "stellar": {
560                    "transaction_xdr": "test-xdr",
561                    "fee_token": "native"
562                }
563            }))
564            .to_request();
565        let resp = test::call_service(&app, req).await;
566        // Route exists if status is not 404 (could be 400 for validation errors or 500 for internal errors)
567        assert_ne!(resp.status(), StatusCode::NOT_FOUND);
568
569        // Test POST /relayers/{id}/transactions/sponsored/build
570        let req = test::TestRequest::post()
571            .uri("/relayers/test-id/transactions/sponsored/build")
572            .set_json(serde_json::json!({
573                "stellar": {
574                    "transaction_xdr": "test-xdr",
575                    "fee_token": "native"
576                }
577            }))
578            .to_request();
579        let resp = test::call_service(&app, req).await;
580        // Route exists if status is not 404 (could be 400 for validation errors or 500 for internal errors)
581        assert_ne!(resp.status(), StatusCode::NOT_FOUND);
582
583        Ok(())
584    }
585
586    #[actix_web::test]
587    async fn test_status_query_defaults() {
588        // Empty JSON object should default all fields to true
589        let query: StatusQuery = serde_json::from_value(serde_json::json!({})).unwrap();
590        assert!(query.include_balance);
591        assert!(query.include_pending_count);
592        assert!(query.include_last_confirmed_tx);
593    }
594
595    #[actix_web::test]
596    async fn test_status_query_all_false() {
597        let query: StatusQuery = serde_json::from_value(serde_json::json!({
598            "include_balance": false,
599            "include_pending_count": false,
600            "include_last_confirmed_tx": false,
601        }))
602        .unwrap();
603        assert!(!query.include_balance);
604        assert!(!query.include_pending_count);
605        assert!(!query.include_last_confirmed_tx);
606    }
607
608    #[actix_web::test]
609    async fn test_status_query_partial() {
610        // Only override one param, others should default to true
611        let query: StatusQuery = serde_json::from_value(serde_json::json!({
612            "include_balance": false,
613        }))
614        .unwrap();
615        assert!(!query.include_balance);
616        assert!(query.include_pending_count);
617        assert!(query.include_last_confirmed_tx);
618    }
619
620    #[actix_web::test]
621    async fn test_status_route_accepts_query_params() {
622        let app = test::init_service(
623            App::new()
624                .app_data(web::Data::new(get_test_app_state().await))
625                .configure(init),
626        )
627        .await;
628
629        // With query params — route should still be registered (not 404)
630        let req = test::TestRequest::get()
631            .uri("/relayers/test-id/status?include_balance=false&include_pending_count=false")
632            .to_request();
633        let resp = test::call_service(&app, req).await;
634        assert_ne!(resp.status(), StatusCode::NOT_FOUND);
635    }
636}