Skip to main content

anyswap_core/
api.rs

1//! HTTP API request and response types shared between the server and the SDK.
2//!
3//! Amounts in sats are 64-bit and cross as decimal strings, so a reader whose
4//! numbers are doubles keeps every bit.
5
6use 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    /// Whether the Lightning node pays and receives; `false` while it runs in
19    /// offline mode.
20    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    /// Seconds a `quote_id` stays valid once issued.
27    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    /// Whether this node accepts webhook registrations.
36    pub enabled: bool,
37    /// Whether a registered url must use https.
38    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    /// CSV timelock on a swap-in opening tx, in this chain's blocks.
80    pub source_csv: u32,
81    /// CSV timelock on a swap-out opening tx, in this chain's blocks.
82    pub dest_csv: u32,
83    pub confs: u32,
84    pub block_time: u32,
85    pub timeouts: TimeoutResponse,
86    pub service_ppm: u64,
87    /// Dust-guard floor: minimum receive amount as a multiple of this chain's
88    /// base fee.
89    pub min_base_fee_multiplier: u64,
90    pub asset: HashMap<String, AssetPolicyResponse>,
91}
92
93/// Deadlines this chain enforces as a SwapIn source, in seconds. A swap left in
94/// the matching status past its deadline is canceled. SwapOut has no deadline
95/// here: once its opening tx confirms, the bound is the `dest_csv` timelock
96/// in blocks.
97#[derive(Serialize, Deserialize, Debug)]
98#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99pub struct TimeoutResponse {
100    /// Maximum wait for the opening tx to appear at the swap address and
101    /// confirm. The claim invoice expiry may end it sooner.
102    pub await_opening_tx: u64,
103    /// Deadline to reveal the cooperative claim key.
104    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/// Status webhook registration supplied with a swap.
121#[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    /// Statuses to deliver. Omitted or empty delivers every status change.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub statuses: Option<Vec<String>>,
129    /// Send SHA256 of the swap id instead of the id itself.
130    #[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    /// Optional `GET /v1/info` quote locking the base fee.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub quote_id: Option<Uuid>,
154    /// swap-in only.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub claim_invoice: Option<String>,
157    /// swap-in and swap-chain.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub source_refund_pubkey: Option<String>,
160    /// swap-in and swap-chain, Liquid only.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub source_blinding_pubkey: Option<String>,
163    /// swap-out and swap-chain.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub dest_claim_pubkey: Option<String>,
166    /// swap-out and swap-chain, Liquid only.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub dest_blinding_pubkey: Option<String>,
169    /// swap-out and swap-chain.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub user_claim_address: Option<String>,
172    /// swap-in and swap-chain.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub user_refund_address: Option<String>,
175    /// swap-out and swap-chain: preimage hash for the destination HTLC.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub payment_hash: Option<String>,
178    /// Optional webhook for swap status changes.
179    #[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    /// Liquid: the user's blinding pubkey for the source output.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub source_user_blinding_pubkey: Option<String>,
247    /// Liquid: anyswap's blinding pubkey for the source output.
248    #[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    /// Address the server's swap-in claim pays. Internal:
271    /// redacted from HTTP responses by [`Self::into_api_response`].
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    #[cfg_attr(feature = "openapi", schema(ignore))]
274    pub server_claim_address: Option<String>,
275    /// Address the server's swap-out refund pays. Internal, as above.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    #[cfg_attr(feature = "openapi", schema(ignore))]
278    pub server_refund_address: Option<String>,
279    /// The server's swap-in claim txid. Internal, as above.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    #[cfg_attr(feature = "openapi", schema(ignore))]
282    pub source_claim_txid: Option<String>,
283    /// The server's swap-out refund txid. Internal, as above.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    #[cfg_attr(feature = "openapi", schema(ignore))]
286    pub dest_refund_txid: Option<String>,
287    /// Liquid: the user's blinding pubkey for the destination output.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub dest_user_blinding_pubkey: Option<String>,
290    /// Liquid: anyswap's blinding pubkey for the destination output.
291    #[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    /// The `POST /v1/swap` body this swap was created from, verbatim. With the
296    /// two fields below it lets a client verify the swap's terms against its own
297    /// signature rather than against this response. Returned by
298    /// `GET /v1/swap/{id}`, and by the listing under `with_create_request`.
299    #[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    /// Hex of the 64-byte Schnorr signature over that request.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub create_request_signature: Option<String>,
306}
307
308impl SwapState {
309    /// Redacts private keys not disclosed in the current status.
310    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    /// Redacts fields that must not be returned by public HTTP responses.
320    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    /// Drops the signed create request. A history page reads tens of swaps at a
334    /// time and each body is the size of the swap itself, so a page carries
335    /// them only when asked.
336    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
390/// The largest page `GET /v1/swap` serves. A history page reads tens of swaps
391/// at a time; the cap is what stops a client asking for the whole table in one
392/// response, and it bounds how much the server holds in memory to answer.
393pub const MAX_SWAP_LIST_LIMIT: u32 = 100;
394
395/// Machine-readable `code` carried beside `message` in every error response,
396/// so a client never infers semantics from the HTTP status.
397#[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    /// The FSM refused a message it is past. A client treats it the same as
418    /// having delivered the message.
419    TransitionRejected,
420    PolicyError,
421    TooManyConnections,
422    Unavailable,
423    Forbidden,
424    PayloadTooLarge,
425    InsufficientLiquidity,
426}
427
428/// The body of every `4xx`/`5xx` response.
429#[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/// Filters for `GET /v1/swap`. They intersect, and all of them apply before
437/// `limit` and `offset`, so `Pagination::total` counts what they matched. A
438/// filter sent with nothing in it means the same as one left out.
439#[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    /// Page size, at most 100. A larger one is rejected, not clamped.
445    #[cfg_attr(feature = "openapi", param(maximum = 100), schema(maximum = 100))]
446    pub limit: u32,
447    pub offset: u32,
448    /// `true` keeps only active swaps, `false` only finished ones; omit for both.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub is_active: Option<bool>,
451    /// Keeps only swaps of this type: `SwapIn`, `SwapOut`, or `SwapChain`.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub swap_type: Option<String>,
454    /// Comma-separated FSM statuses to keep, e.g. `Claimed,Refunded`.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub status: Option<String>,
457    /// Comma-separated cancellation reasons to keep, e.g. `UserCanceled,Timeout`.
458    /// Only a cancelled swap has one, so this filters out every other swap too.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub error_type: Option<String>,
461    /// Keeps swaps created at or after this unix timestamp, in seconds.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub created_after: Option<u64>,
464    /// Keeps swaps created at or before this unix timestamp, in seconds.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub created_before: Option<u64>,
467    /// Keeps swaps whose `receive_amount` is at least this many sats.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub min_amount: Option<u64>,
470    /// Keeps swaps whose `receive_amount` is at most this many sats.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub max_amount: Option<u64>,
473    /// Keeps swaps where `swap_id`, `payment_hash`, `claim_invoice`,
474    /// `source_txid`, `dest_txid`, or `user_claim_address` equals this value.
475    /// The match ignores case but is exact, not a prefix.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub search: Option<String>,
478    /// `true` carries each swap's `create_request_*` fields as
479    /// `GET /v1/swap/{id}` does; a page omits them otherwise.
480    #[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}