1use serde::{Deserialize, Serialize};
2use utoipa::ToSchema;
3
4use crate::domain::transaction::stellar::StellarTransactionValidator;
5use crate::models::ApiError;
6use crate::{
7 domain::stellar::validation::validate_operations, models::transaction::stellar::OperationSpec,
8};
9#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
10#[serde(deny_unknown_fields)]
11#[derive(Clone)]
12#[schema(as = StellarFeeEstimateRequestParams)]
13pub struct FeeEstimateRequestParams {
14 #[schema(nullable = true)]
18 pub transaction_xdr: Option<String>,
19 #[schema(nullable = true)]
22 pub source_account: Option<String>,
23 #[schema(nullable = true)]
26 pub operations: Option<Vec<OperationSpec>>,
27 pub fee_token: String,
31}
32
33impl FeeEstimateRequestParams {
34 pub fn validate(&self) -> Result<(), crate::models::ApiError> {
38 StellarTransactionValidator::validate_fee_token_structure(&self.fee_token)
40 .map_err(|e| ApiError::BadRequest(format!("Invalid fee_token structure: {e}")))?;
41
42 let has_operations = self
44 .operations
45 .as_ref()
46 .map(|ops| !ops.is_empty())
47 .unwrap_or(false);
48 let has_xdr = self.transaction_xdr.is_some();
49
50 if has_operations {
51 validate_operations(self.operations.as_ref().unwrap())
52 .map_err(|e| ApiError::BadRequest(format!("Invalid operations: {e}")))?;
53 if self.source_account.is_none() || self.source_account.as_ref().unwrap().is_empty() {
54 return Err(ApiError::BadRequest(
55 "source_account is required when providing operations".to_string(),
56 ));
57 }
58 }
59
60 match (has_operations, has_xdr) {
61 (true, true) => {
62 return Err(ApiError::BadRequest(
63 "Cannot provide both transaction_xdr and operations".to_string(),
64 ));
65 }
66 (false, false) => {
67 return Err(ApiError::BadRequest(
68 "Must provide either transaction_xdr or operations".to_string(),
69 ));
70 }
71 _ => {}
72 }
73
74 Ok(())
75 }
76}
77
78#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ToSchema)]
79#[schema(as = StellarFeeEstimateResult)]
80pub struct FeeEstimateResult {
81 pub fee_in_token_ui: String,
83 pub fee_in_token: String,
85 pub conversion_rate: String,
87 #[serde(skip_serializing_if = "Option::is_none")]
90 #[schema(nullable = true)]
91 pub max_fee_in_token: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 #[schema(nullable = true)]
96 pub max_fee_in_token_ui: Option<String>,
97}
98
99#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
101#[serde(deny_unknown_fields)]
102#[derive(Clone)]
103#[schema(as = StellarPrepareTransactionRequestParams)]
104pub struct PrepareTransactionRequestParams {
105 #[schema(nullable = true)]
109 pub transaction_xdr: Option<String>,
110 #[schema(nullable = true)]
113 pub operations: Option<Vec<OperationSpec>>,
114 #[schema(nullable = true)]
117 pub source_account: Option<String>,
118 pub fee_token: String,
122}
123
124impl PrepareTransactionRequestParams {
125 pub fn validate(&self) -> Result<(), crate::models::ApiError> {
130 StellarTransactionValidator::validate_fee_token_structure(&self.fee_token)
132 .map_err(|e| ApiError::BadRequest(format!("Invalid fee_token structure: {e}")))?;
133
134 let has_operations = self
136 .operations
137 .as_ref()
138 .map(|ops| !ops.is_empty())
139 .unwrap_or(false);
140 let has_xdr = self.transaction_xdr.is_some();
141
142 match (has_operations, has_xdr) {
143 (true, true) => {
144 return Err(ApiError::BadRequest(
145 "Cannot provide both transaction_xdr and operations".to_string(),
146 ));
147 }
148 (false, false) => {
149 return Err(ApiError::BadRequest(
150 "Must provide either transaction_xdr or operations".to_string(),
151 ));
152 }
153 _ => {}
154 }
155
156 if has_operations {
158 validate_operations(self.operations.as_ref().unwrap())
159 .map_err(|e| ApiError::BadRequest(format!("Invalid operations: {e}")))?;
160 if self.source_account.is_none() || self.source_account.as_ref().unwrap().is_empty() {
161 return Err(ApiError::BadRequest(
162 "source_account is required when providing operations".to_string(),
163 ));
164 }
165 }
166
167 Ok(())
168 }
169}
170
171#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ToSchema)]
172#[schema(as = StellarPrepareTransactionResult)]
173pub struct PrepareTransactionResult {
174 pub transaction: String,
176 pub fee_in_token: String,
178 pub fee_in_token_ui: String,
180 pub fee_in_stroops: String,
182 pub fee_token: String,
184 pub valid_until: String,
186 #[serde(skip_serializing_if = "Option::is_none")]
189 #[schema(nullable = true)]
190 pub user_auth_entry: Option<String>,
191 #[serde(skip_serializing_if = "Option::is_none")]
194 #[schema(nullable = true)]
195 pub max_fee_in_token: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
199 #[schema(nullable = true)]
200 pub max_fee_in_token_ui: Option<String>,
201}
202
203pub enum StellarRpcMethod {
205 Generic(String),
206}
207
208#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Clone)]
209#[serde(untagged)]
210#[schema(as = StellarRpcRequest)]
211pub enum StellarRpcRequest {
212 #[serde(rename = "rawRpcRequest")]
213 #[schema(example = "rawRpcRequest")]
214 RawRpcRequest {
215 method: String,
216 params: serde_json::Value,
217 },
218}
219
220#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq)]
221#[serde(untagged)]
222pub enum StellarRpcResult {
223 RawRpcResult(serde_json::Value),
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::models::transaction::stellar::{asset::AssetSpec, OperationSpec};
231
232 const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
233 const VALID_FEE_TOKEN_NATIVE: &str = "native";
234 const VALID_FEE_TOKEN_USDC: &str =
235 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
236 const INVALID_FEE_TOKEN: &str = "invalid-token";
237
238 #[test]
241 fn test_fee_estimate_validate_with_xdr_success() {
242 let params = FeeEstimateRequestParams {
243 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
244 operations: None,
245 source_account: None,
246 fee_token: VALID_FEE_TOKEN_USDC.to_string(),
247 };
248 assert!(params.validate().is_ok());
249 }
250
251 #[test]
252 fn test_fee_estimate_validate_with_operations_success() {
253 let params = FeeEstimateRequestParams {
254 transaction_xdr: None,
255 operations: Some(vec![OperationSpec::Payment {
256 destination: TEST_PK.to_string(),
257 amount: 1000000,
258 asset: AssetSpec::Native,
259 }]),
260 source_account: Some(TEST_PK.to_string()),
261 fee_token: VALID_FEE_TOKEN_USDC.to_string(),
262 };
263 assert!(params.validate().is_ok());
264 }
265
266 #[test]
267 fn test_fee_estimate_validate_with_usdc_token_success() {
268 let params = FeeEstimateRequestParams {
269 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
270 operations: None,
271 source_account: None,
272 fee_token: VALID_FEE_TOKEN_USDC.to_string(),
273 };
274 assert!(params.validate().is_ok());
275 }
276
277 #[test]
278 fn test_fee_estimate_validate_invalid_fee_token() {
279 let params = FeeEstimateRequestParams {
280 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
281 operations: None,
282 source_account: None,
283 fee_token: INVALID_FEE_TOKEN.to_string(),
284 };
285 let result = params.validate();
286 assert!(result.is_err());
287 if let Err(ApiError::BadRequest(msg)) = result {
288 assert!(msg.contains("Invalid fee_token structure"));
289 } else {
290 panic!("Expected BadRequest error for invalid fee_token");
291 }
292 }
293
294 #[test]
295 fn test_fee_estimate_validate_both_xdr_and_operations() {
296 let params = FeeEstimateRequestParams {
297 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
298 operations: Some(vec![OperationSpec::Payment {
299 destination: TEST_PK.to_string(),
300 amount: 1000000,
301 asset: AssetSpec::Native,
302 }]),
303 source_account: Some(TEST_PK.to_string()),
304 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
305 };
306 let result = params.validate();
307 assert!(result.is_err());
308 if let Err(ApiError::BadRequest(msg)) = result {
309 assert!(msg.contains("Cannot provide both transaction_xdr and operations"));
310 } else {
311 panic!("Expected BadRequest error for both xdr and operations");
312 }
313 }
314
315 #[test]
316 fn test_fee_estimate_validate_neither_xdr_nor_operations() {
317 let params = FeeEstimateRequestParams {
318 transaction_xdr: None,
319 operations: None,
320 source_account: None,
321 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
322 };
323 let result = params.validate();
324 assert!(result.is_err());
325 if let Err(ApiError::BadRequest(msg)) = result {
326 assert!(msg.contains("Must provide either transaction_xdr or operations"));
327 } else {
328 panic!("Expected BadRequest error for missing both xdr and operations");
329 }
330 }
331
332 #[test]
333 fn test_fee_estimate_validate_operations_without_source_account() {
334 let params = FeeEstimateRequestParams {
335 transaction_xdr: None,
336 operations: Some(vec![OperationSpec::Payment {
337 destination: TEST_PK.to_string(),
338 amount: 1000000,
339 asset: AssetSpec::Native,
340 }]),
341 source_account: None,
342 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
343 };
344 let result = params.validate();
345 assert!(result.is_err());
346 if let Err(ApiError::BadRequest(msg)) = result {
347 assert!(msg.contains("source_account is required when providing operations"));
348 } else {
349 panic!("Expected BadRequest error for missing source_account");
350 }
351 }
352
353 #[test]
354 fn test_fee_estimate_validate_operations_with_empty_source_account() {
355 let params = FeeEstimateRequestParams {
356 transaction_xdr: None,
357 operations: Some(vec![OperationSpec::Payment {
358 destination: TEST_PK.to_string(),
359 amount: 1000000,
360 asset: AssetSpec::Native,
361 }]),
362 source_account: Some("".to_string()),
363 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
364 };
365 let result = params.validate();
366 assert!(result.is_err());
367 if let Err(ApiError::BadRequest(msg)) = result {
368 assert!(msg.contains("source_account is required when providing operations"));
369 } else {
370 panic!("Expected BadRequest error for empty source_account");
371 }
372 }
373
374 #[test]
375 fn test_fee_estimate_validate_empty_operations() {
376 let params = FeeEstimateRequestParams {
377 transaction_xdr: None,
378 operations: Some(vec![]),
379 source_account: Some(TEST_PK.to_string()),
380 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
381 };
382 let result = params.validate();
383 assert!(result.is_err());
384 if let Err(ApiError::BadRequest(msg)) = result {
385 assert!(msg.contains("Must provide either transaction_xdr or operations"));
386 } else {
387 panic!("Expected BadRequest error for empty operations");
388 }
389 }
390
391 #[test]
394 fn test_prepare_transaction_validate_with_xdr_success() {
395 let params = PrepareTransactionRequestParams {
396 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
397 operations: None,
398 source_account: None,
399 fee_token: VALID_FEE_TOKEN_USDC.to_string(),
400 };
401 assert!(params.validate().is_ok());
402 }
403
404 #[test]
405 fn test_prepare_transaction_validate_with_operations_success() {
406 let params = PrepareTransactionRequestParams {
407 transaction_xdr: None,
408 operations: Some(vec![OperationSpec::Payment {
409 destination: TEST_PK.to_string(),
410 amount: 1000000,
411 asset: AssetSpec::Native,
412 }]),
413 source_account: Some(TEST_PK.to_string()),
414 fee_token: VALID_FEE_TOKEN_USDC.to_string(),
415 };
416 assert!(params.validate().is_ok());
417 }
418
419 #[test]
420 fn test_prepare_transaction_validate_with_usdc_token_success() {
421 let params = PrepareTransactionRequestParams {
422 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
423 operations: None,
424 source_account: None,
425 fee_token: VALID_FEE_TOKEN_USDC.to_string(),
426 };
427 assert!(params.validate().is_ok());
428 }
429
430 #[test]
431 fn test_prepare_transaction_validate_invalid_fee_token() {
432 let params = PrepareTransactionRequestParams {
433 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
434 operations: None,
435 source_account: None,
436 fee_token: INVALID_FEE_TOKEN.to_string(),
437 };
438 let result = params.validate();
439 assert!(result.is_err());
440 if let Err(ApiError::BadRequest(msg)) = result {
441 assert!(msg.contains("Invalid fee_token structure"));
442 } else {
443 panic!("Expected BadRequest error for invalid fee_token");
444 }
445 }
446
447 #[test]
448 fn test_prepare_transaction_validate_both_xdr_and_operations() {
449 let params = PrepareTransactionRequestParams {
450 transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
451 operations: Some(vec![OperationSpec::Payment {
452 destination: TEST_PK.to_string(),
453 amount: 1000000,
454 asset: AssetSpec::Native,
455 }]),
456 source_account: Some(TEST_PK.to_string()),
457 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
458 };
459 let result = params.validate();
460 assert!(result.is_err());
461 if let Err(ApiError::BadRequest(msg)) = result {
462 assert!(msg.contains("Cannot provide both transaction_xdr and operations"));
463 } else {
464 panic!("Expected BadRequest error for both xdr and operations");
465 }
466 }
467
468 #[test]
469 fn test_prepare_transaction_validate_neither_xdr_nor_operations() {
470 let params = PrepareTransactionRequestParams {
471 transaction_xdr: None,
472 operations: None,
473 source_account: None,
474 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
475 };
476 let result = params.validate();
477 assert!(result.is_err());
478 if let Err(ApiError::BadRequest(msg)) = result {
479 assert!(msg.contains("Must provide either transaction_xdr or operations"));
480 } else {
481 panic!("Expected BadRequest error for missing both xdr and operations");
482 }
483 }
484
485 #[test]
486 fn test_prepare_transaction_validate_operations_without_source_account() {
487 let params = PrepareTransactionRequestParams {
488 transaction_xdr: None,
489 operations: Some(vec![OperationSpec::Payment {
490 destination: TEST_PK.to_string(),
491 amount: 1000000,
492 asset: AssetSpec::Native,
493 }]),
494 source_account: None,
495 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
496 };
497 let result = params.validate();
498 assert!(result.is_err());
499 if let Err(ApiError::BadRequest(msg)) = result {
500 assert!(msg.contains("source_account is required when providing operations"));
501 } else {
502 panic!("Expected BadRequest error for missing source_account");
503 }
504 }
505
506 #[test]
507 fn test_prepare_transaction_validate_operations_with_empty_source_account() {
508 let params = PrepareTransactionRequestParams {
509 transaction_xdr: None,
510 operations: Some(vec![OperationSpec::Payment {
511 destination: TEST_PK.to_string(),
512 amount: 1000000,
513 asset: AssetSpec::Native,
514 }]),
515 source_account: Some("".to_string()),
516 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
517 };
518 let result = params.validate();
519 assert!(result.is_err());
520 if let Err(ApiError::BadRequest(msg)) = result {
521 assert!(msg.contains("source_account is required when providing operations"));
522 } else {
523 panic!("Expected BadRequest error for empty source_account");
524 }
525 }
526
527 #[test]
528 fn test_prepare_transaction_validate_empty_operations() {
529 let params = PrepareTransactionRequestParams {
530 transaction_xdr: None,
531 operations: Some(vec![]),
532 source_account: Some(TEST_PK.to_string()),
533 fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
534 };
535 let result = params.validate();
536 assert!(result.is_err());
537 if let Err(ApiError::BadRequest(msg)) = result {
540 assert!(msg.contains("Must provide either transaction_xdr or operations"));
541 } else {
542 panic!("Expected BadRequest error for empty operations");
543 }
544 }
545
546 #[test]
547 fn test_fee_estimate_result() {
548 let result = FeeEstimateResult {
549 fee_in_token_ui: "1.5".to_string(),
550 fee_in_token: "1500000".to_string(),
551 conversion_rate: "10.0".to_string(),
552 max_fee_in_token: None,
553 max_fee_in_token_ui: None,
554 };
555 assert_eq!(result.fee_in_token_ui, "1.5");
556 assert_eq!(result.fee_in_token, "1500000");
557 assert_eq!(result.conversion_rate, "10.0");
558 }
559
560 #[test]
561 fn test_fee_estimate_result_with_max_fee() {
562 let result = FeeEstimateResult {
563 fee_in_token_ui: "1.5".to_string(),
564 fee_in_token: "1500000".to_string(),
565 conversion_rate: "10.0".to_string(),
566 max_fee_in_token: Some("1575000".to_string()),
567 max_fee_in_token_ui: Some("1.575".to_string()),
568 };
569 assert_eq!(result.max_fee_in_token, Some("1575000".to_string()));
570 assert_eq!(result.max_fee_in_token_ui, Some("1.575".to_string()));
571 let json = serde_json::to_string(&result).unwrap();
573 assert!(json.contains("max_fee_in_token"));
574 assert!(json.contains("max_fee_in_token_ui"));
575 }
576
577 #[test]
578 fn test_fee_estimate_result_skips_none_max_fee() {
579 let result = FeeEstimateResult {
580 fee_in_token_ui: "1.5".to_string(),
581 fee_in_token: "1500000".to_string(),
582 conversion_rate: "10.0".to_string(),
583 max_fee_in_token: None,
584 max_fee_in_token_ui: None,
585 };
586 let json = serde_json::to_string(&result).unwrap();
588 assert!(!json.contains("max_fee_in_token"));
589 }
590
591 #[test]
592 fn test_prepare_transaction_result_with_soroban_fields() {
593 let result = PrepareTransactionResult {
594 transaction: "AAAAAgAAAAA=".to_string(),
595 fee_in_token: "1500000".to_string(),
596 fee_in_token_ui: "1.5".to_string(),
597 fee_in_stroops: "150000".to_string(),
598 fee_token: "CUSDC".to_string(),
599 valid_until: "2024-01-01T00:00:00Z".to_string(),
600 user_auth_entry: Some("AAAABgAAAAA=".to_string()),
601 max_fee_in_token: Some("1575000".to_string()),
602 max_fee_in_token_ui: Some("1.575".to_string()),
603 };
604 assert!(result.user_auth_entry.is_some());
605 assert!(result.max_fee_in_token.is_some());
606 assert!(result.max_fee_in_token_ui.is_some());
607 }
608
609 #[test]
610 fn test_prepare_transaction_result_without_soroban_fields() {
611 let result = PrepareTransactionResult {
612 transaction: "AAAAAgAAAAA=".to_string(),
613 fee_in_token: "1500000".to_string(),
614 fee_in_token_ui: "1.5".to_string(),
615 fee_in_stroops: "150000".to_string(),
616 fee_token: "USDC:GA...".to_string(),
617 valid_until: "2024-01-01T00:00:00Z".to_string(),
618 user_auth_entry: None,
619 max_fee_in_token: None,
620 max_fee_in_token_ui: None,
621 };
622 let json = serde_json::to_string(&result).unwrap();
624 assert!(!json.contains("user_auth_entry"));
625 assert!(!json.contains("max_fee_in_token"));
626 }
627}