1use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9use serde_with::{DisplayFromStr, serde_as};
10use tracing::error;
11use uuid::Uuid;
12
13use crate::types::{Chain, SwapInStatus, SwapNetwork, SwapOutStatus, SwapType};
14
15#[derive(Serialize, Deserialize, Debug)]
16#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
17pub struct InfoResponse {
18 pub online: bool,
21 pub policy: Policy,
22 #[serde(default)]
23 pub fee_estimates: HashMap<Chain, FeeEstimate>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub quote_id: Option<Uuid>,
26 pub quote_ttl: u64,
28 #[serde(default)]
29 pub webhook: WebhookInfo,
30}
31
32#[derive(Serialize, Deserialize, Debug, Clone, Default)]
33#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
34pub struct WebhookInfo {
35 pub enabled: bool,
37 pub https_required: bool,
39}
40
41#[derive(Serialize, Deserialize, Debug)]
42#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
43pub struct Policy {
44 pub protocol_version: String,
45 pub network: SwapNetwork,
46 pub invoice_expiry: u64,
47 pub chain: HashMap<Chain, ChainPolicyResponse>,
48 pub lightning: LightningPolicyResponse,
49}
50
51#[serde_as]
52#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54pub struct FeeEstimate {
55 #[serde_as(as = "DisplayFromStr")]
56 #[cfg_attr(feature = "openapi", schema(value_type = String))]
57 pub source: u64,
58 #[serde_as(as = "DisplayFromStr")]
59 #[cfg_attr(feature = "openapi", schema(value_type = String))]
60 pub dest: u64,
61}
62
63#[serde_as]
64#[derive(Serialize, Deserialize, Debug)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66pub struct LightningPolicyResponse {
67 #[serde_as(as = "DisplayFromStr")]
68 #[cfg_attr(feature = "openapi", schema(value_type = String))]
69 pub min_amount: u64,
70 #[serde_as(as = "DisplayFromStr")]
71 #[cfg_attr(feature = "openapi", schema(value_type = String))]
72 pub max_amount: u64,
73 pub service_ppm: u64,
74}
75
76#[derive(Serialize, Deserialize, Debug)]
77#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
78pub struct ChainPolicyResponse {
79 pub source_csv: u32,
81 pub dest_csv: u32,
83 pub confs: u32,
84 pub block_time: u32,
85 pub timeouts: TimeoutResponse,
86 pub service_ppm: u64,
87 pub min_base_fee_multiplier: u64,
90 pub asset: HashMap<String, AssetPolicyResponse>,
91}
92
93#[derive(Serialize, Deserialize, Debug)]
98#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99pub struct TimeoutResponse {
100 pub await_opening_tx: u64,
103 pub await_claim_key_reveal_message: u64,
105}
106
107#[serde_as]
108#[derive(Serialize, Deserialize, Debug)]
109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
110pub struct AssetPolicyResponse {
111 pub asset_id: String,
112 #[serde_as(as = "DisplayFromStr")]
113 #[cfg_attr(feature = "openapi", schema(value_type = String))]
114 pub min_amount: u64,
115 #[serde_as(as = "DisplayFromStr")]
116 #[cfg_attr(feature = "openapi", schema(value_type = String))]
117 pub max_amount: u64,
118}
119
120#[derive(Serialize, Deserialize, Debug, Clone)]
122#[serde(deny_unknown_fields)]
123#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
124pub struct WebhookRequest {
125 pub url: String,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub statuses: Option<Vec<String>>,
129 #[serde(default)]
131 pub hash_swap_id: bool,
132}
133
134#[serde_as]
135#[derive(Serialize, Deserialize, Debug)]
136#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
137pub struct CreateSwapRequest {
138 pub protocol_version: String,
139 pub swap_id: Uuid,
140 pub user_id: String,
141 pub source_chain: Option<Chain>,
142 pub source_asset_id: Option<String>,
143 pub dest_chain: Option<Chain>,
144 pub dest_asset_id: Option<String>,
145 #[serde_as(as = "DisplayFromStr")]
146 #[cfg_attr(feature = "openapi", schema(value_type = String))]
147 pub receive_amount: u64,
148 #[serde_as(as = "DisplayFromStr")]
149 #[cfg_attr(feature = "openapi", schema(value_type = String))]
150 pub send_amount: u64,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub quote_id: Option<Uuid>,
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub claim_invoice: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub source_refund_pubkey: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub source_blinding_pubkey: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub dest_claim_pubkey: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub dest_blinding_pubkey: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub user_claim_address: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub user_refund_address: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub payment_hash: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub webhook: Option<WebhookRequest>,
181}
182
183#[serde_as]
184#[derive(Serialize, Deserialize, Debug, Clone)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186pub struct SwapState {
187 pub protocol_version: String,
188 pub swap_id: Uuid,
189 pub swap_type: SwapType,
190 pub status: String,
191 #[serde_as(as = "DisplayFromStr")]
192 #[cfg_attr(feature = "openapi", schema(value_type = String))]
193 pub receive_amount: u64,
194 #[serde_as(as = "DisplayFromStr")]
195 #[cfg_attr(feature = "openapi", schema(value_type = String))]
196 pub send_amount: u64,
197 #[serde_as(as = "DisplayFromStr")]
198 #[cfg_attr(feature = "openapi", schema(value_type = String))]
199 pub base_fee: u64,
200 #[serde_as(as = "DisplayFromStr")]
201 #[cfg_attr(feature = "openapi", schema(value_type = String))]
202 pub service_fee: u64,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 #[serde_as(as = "Option<DisplayFromStr>")]
205 #[cfg_attr(feature = "openapi", schema(ignore))]
206 pub invoice_fee: Option<u64>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 #[serde_as(as = "Option<DisplayFromStr>")]
209 #[cfg_attr(feature = "openapi", schema(ignore))]
210 pub opening_tx_fee: Option<u64>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 #[serde_as(as = "Option<DisplayFromStr>")]
213 #[cfg_attr(feature = "openapi", schema(ignore))]
214 pub claim_tx_fee: Option<u64>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 #[serde_as(as = "Option<DisplayFromStr>")]
217 #[cfg_attr(feature = "openapi", schema(ignore))]
218 pub refund_tx_fee: Option<u64>,
219 pub payment_hash: String,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub preimage: Option<String>,
222 pub transitioned_at: u64,
223 pub created_at: u64,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub error_type: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub cancel_message: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub source_chain: Option<Chain>,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub source_asset: Option<String>,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub source_csv: Option<u32>,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub source_txid: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub source_vout: Option<u32>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub source_refund_pubkey: Option<String>,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub source_claim_pubkey: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub source_claim_privkey: Option<String>,
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub source_user_blinding_pubkey: Option<String>,
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub source_service_blinding_pubkey: Option<String>,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub dest_chain: Option<Chain>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub dest_asset: Option<String>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub dest_csv: Option<u32>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub dest_txid: Option<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub dest_vout: Option<u32>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub dest_refund_pubkey: Option<String>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub dest_claim_pubkey: Option<String>,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub dest_refund_privkey: Option<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub user_claim_address: Option<String>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub user_refund_address: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
273 #[cfg_attr(feature = "openapi", schema(ignore))]
274 pub server_claim_address: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 #[cfg_attr(feature = "openapi", schema(ignore))]
278 pub server_refund_address: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 #[cfg_attr(feature = "openapi", schema(ignore))]
282 pub source_claim_txid: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 #[cfg_attr(feature = "openapi", schema(ignore))]
286 pub dest_refund_txid: Option<String>,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub dest_user_blinding_pubkey: Option<String>,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub dest_service_blinding_pubkey: Option<String>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub claim_invoice: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
300 pub create_request_body: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub create_request_timestamp: Option<i64>,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub create_request_signature: Option<String>,
306}
307
308impl SwapState {
309 pub fn blind(mut self) -> Self {
311 match self.swap_type {
312 SwapType::SwapIn => self.blind_swap_in(),
313 SwapType::SwapOut => self.blind_swap_out(),
314 SwapType::SwapChain => {}
315 }
316 self
317 }
318
319 pub fn into_api_response(mut self) -> Self {
321 self = self.blind();
322 self.invoice_fee = None;
323 self.opening_tx_fee = None;
324 self.claim_tx_fee = None;
325 self.refund_tx_fee = None;
326 self.server_claim_address = None;
327 self.server_refund_address = None;
328 self.source_claim_txid = None;
329 self.dest_refund_txid = None;
330 self
331 }
332
333 pub fn without_create_request(mut self) -> Self {
337 self.create_request_body = None;
338 self.create_request_timestamp = None;
339 self.create_request_signature = None;
340 self
341 }
342
343 fn blind_swap_in(&mut self) {
344 match SwapInStatus::try_from(self.status.as_str()) {
345 Ok(SwapInStatus::Canceled) => {}
346 Ok(_) => self.source_claim_privkey = None,
347 Err(_) => {
348 error!(
349 swap_id = %self.swap_id, status = %self.status,
350 "unknown swap-in status; redacting claim privkey",
351 );
352 self.source_claim_privkey = None;
353 }
354 }
355 }
356
357 fn blind_swap_out(&mut self) {
358 match SwapOutStatus::try_from(self.status.as_str()) {
359 Ok(SwapOutStatus::Claimed) => {}
360 Ok(_) => self.dest_refund_privkey = None,
361 Err(_) => {
362 error!(
363 swap_id = %self.swap_id, status = %self.status,
364 "unknown swap-out status; redacting refund privkey",
365 );
366 self.dest_refund_privkey = None;
367 }
368 }
369 }
370}
371
372#[derive(Serialize, Deserialize, Debug)]
373#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
374pub struct CancelRequest {
375 pub cancel_message: String,
376}
377
378#[derive(Serialize, Deserialize, Debug)]
379#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
380pub struct RevealClaimRequest {
381 pub source_refund_privkey: String,
382}
383
384#[derive(Serialize, Deserialize, Debug)]
385#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
386pub struct RevealPreimageRequest {
387 pub preimage: String,
388}
389
390pub const MAX_SWAP_LIST_LIMIT: u32 = 100;
394
395#[derive(
398 Debug,
399 Clone,
400 Copy,
401 PartialEq,
402 Eq,
403 Serialize,
404 Deserialize,
405 strum::Display,
406 strum::IntoStaticStr,
407 strum::EnumString,
408)]
409#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
410pub enum ErrorCode {
411 Internal,
412 BadRequest,
413 NotImplemented,
414 QuoteExpired,
415 SwapNotFound,
416 SwapAlreadyExists,
417 TransitionRejected,
420 PolicyError,
421 TooManyConnections,
422 Unavailable,
423 Forbidden,
424 PayloadTooLarge,
425 InsufficientLiquidity,
426}
427
428#[derive(Serialize, Deserialize, Debug)]
430#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
431pub struct ErrorResponse {
432 pub message: String,
433 pub code: ErrorCode,
434}
435
436#[derive(Serialize, Deserialize, Debug, Default)]
440#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema, utoipa::IntoParams))]
441#[cfg_attr(feature = "openapi", into_params(parameter_in = Query))]
442pub struct SwapListQuery {
443 pub user_id: String,
444 #[cfg_attr(feature = "openapi", param(maximum = 100), schema(maximum = 100))]
446 pub limit: u32,
447 pub offset: u32,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub is_active: Option<bool>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub swap_type: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub status: Option<String>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub error_type: Option<String>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub created_after: Option<u64>,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub created_before: Option<u64>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub min_amount: Option<u64>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub max_amount: Option<u64>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub search: Option<String>,
478 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
481 pub with_create_request: bool,
482}
483
484#[derive(Serialize, Deserialize, Debug)]
485#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
486pub struct SwapListResponse {
487 pub swaps: Vec<SwapState>,
488 pub pagination: Pagination,
489}
490
491#[derive(Serialize, Deserialize, Debug)]
492#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
493pub struct Pagination {
494 pub total: u32,
495 pub limit: u32,
496 pub offset: u32,
497 pub has_next: bool,
498}
499
500#[derive(Serialize, Deserialize, Debug, Clone)]
501#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
502pub struct SwapStateUpdate {
503 pub swap_id: Uuid,
504 pub status: String,
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 fn swap_state(swap_type: SwapType, status: String) -> SwapState {
512 SwapState {
513 protocol_version: "1.0.0".into(),
514 swap_id: Uuid::nil(),
515 swap_type,
516 status,
517 receive_amount: 100_000,
518 send_amount: 101_000,
519 base_fee: 1_000,
520 service_fee: 0,
521 invoice_fee: Some(10),
522 opening_tx_fee: Some(20),
523 claim_tx_fee: Some(30),
524 refund_tx_fee: Some(40),
525 payment_hash: "00".repeat(32),
526 preimage: None,
527 transitioned_at: 0,
528 created_at: 0,
529 error_type: None,
530 cancel_message: None,
531 source_chain: None,
532 source_asset: None,
533 source_csv: None,
534 source_txid: None,
535 source_vout: None,
536 source_refund_pubkey: None,
537 source_claim_pubkey: None,
538 source_claim_privkey: Some("source-claim-secret".into()),
539 source_user_blinding_pubkey: None,
540 source_service_blinding_pubkey: None,
541 dest_chain: None,
542 dest_asset: None,
543 dest_csv: None,
544 dest_txid: None,
545 dest_vout: None,
546 dest_refund_pubkey: None,
547 dest_claim_pubkey: None,
548 dest_refund_privkey: Some("dest-refund-secret".into()),
549 user_claim_address: None,
550 user_refund_address: None,
551 server_claim_address: Some("bc1qserver".into()),
552 server_refund_address: Some("bc1qserver2".into()),
553 source_claim_txid: Some("00".repeat(32)),
554 dest_refund_txid: Some("11".repeat(32)),
555 dest_user_blinding_pubkey: None,
556 dest_service_blinding_pubkey: None,
557 claim_invoice: None,
558 create_request_body: None,
559 create_request_timestamp: None,
560 create_request_signature: None,
561 }
562 }
563
564 #[test]
565 fn swap_in_claim_privkey_is_only_visible_after_cancel() {
566 let active = swap_state(
567 SwapType::SwapIn,
568 SwapInStatus::AwaitOpeningTxConfirmation.to_string(),
569 )
570 .blind();
571 assert_eq!(active.source_claim_privkey, None);
572
573 let canceled = swap_state(SwapType::SwapIn, SwapInStatus::Canceled.to_string()).blind();
574 assert_eq!(
575 canceled.source_claim_privkey.as_deref(),
576 Some("source-claim-secret")
577 );
578 }
579
580 #[test]
581 fn swap_out_refund_privkey_is_only_visible_after_claim() {
582 let before_open = swap_state(
583 SwapType::SwapOut,
584 SwapOutStatus::OpeningTxBroadcast.to_string(),
585 )
586 .blind();
587 assert_eq!(before_open.dest_refund_privkey, None);
588
589 let awaiting_preimage = swap_state(
590 SwapType::SwapOut,
591 SwapOutStatus::AwaitPreimageRevealed.to_string(),
592 )
593 .blind();
594 assert_eq!(awaiting_preimage.dest_refund_privkey, None);
595
596 let claimed = swap_state(SwapType::SwapOut, SwapOutStatus::Claimed.to_string()).blind();
597 assert_eq!(
598 claimed.dest_refund_privkey.as_deref(),
599 Some("dest-refund-secret")
600 );
601 }
602
603 #[test]
604 fn unknown_statuses_are_redacted() {
605 let swap_in = swap_state(SwapType::SwapIn, "Surprise".into()).blind();
606 assert_eq!(swap_in.source_claim_privkey, None);
607
608 let swap_out = swap_state(SwapType::SwapOut, "Surprise".into()).blind();
609 assert_eq!(swap_out.dest_refund_privkey, None);
610 }
611
612 #[test]
613 fn api_response_hides_internal_fields() {
614 let state = swap_state(
615 SwapType::SwapIn,
616 SwapInStatus::AwaitOpeningTxConfirmation.to_string(),
617 );
618 assert!(
619 serde_json::to_value(&state)
620 .expect("json")
621 .get("server_claim_address")
622 .is_some()
623 );
624
625 let json = serde_json::to_value(state.into_api_response()).expect("json");
626 assert!(json.get("invoice_fee").is_none());
627 assert!(json.get("opening_tx_fee").is_none());
628 assert!(json.get("claim_tx_fee").is_none());
629 assert!(json.get("refund_tx_fee").is_none());
630 assert!(json.get("server_claim_address").is_none());
631 assert!(json.get("server_refund_address").is_none());
632 assert!(json.get("source_claim_txid").is_none());
633 assert!(json.get("dest_refund_txid").is_none());
634 }
635}
636
637#[cfg(all(test, feature = "openapi"))]
638mod openapi_tests {
639 use utoipa::{IntoParams, PartialSchema};
640
641 use super::*;
642
643 fn maximum(value: &serde_json::Value) -> f64 {
644 value
645 .get("maximum")
646 .and_then(serde_json::Value::as_f64)
647 .expect("limit carries a maximum")
648 }
649
650 #[test]
651 fn openapi_limit_maximum_matches_the_cap() {
652 let expected = f64::from(MAX_SWAP_LIST_LIMIT);
653
654 let schema = serde_json::to_value(SwapListQuery::schema()).expect("schema");
655 assert_eq!(maximum(&schema["properties"]["limit"]), expected);
656
657 let params = serde_json::to_value(SwapListQuery::into_params(|| None)).expect("params");
658 let limit = params
659 .as_array()
660 .expect("params array")
661 .iter()
662 .find(|param| param["name"] == "limit")
663 .expect("limit param");
664 assert_eq!(maximum(&limit["schema"]), expected);
665 }
666}