Skip to main content

anyswap_sdk/driver/
record.rs

1use std::{collections::BTreeMap, sync::Mutex};
2
3use anyswap_core::types::{Chain, Outpoint, SwapInStatus, SwapOutStatus, SwapType};
4use async_trait::async_trait;
5use bitcoin::secp256k1::{PublicKey, SecretKey};
6use serde::{Deserialize, Serialize};
7use serde_with::{DisplayFromStr, serde_as};
8use thiserror::Error;
9use uuid::Uuid;
10
11use super::{
12    error::DriverError,
13    rt::{MaybeSend, MaybeSync},
14};
15use crate::swap_script::{Side, Terms};
16
17/// Unix seconds.
18pub type Timestamp = u64;
19
20/// A block height on the swap's chain.
21pub type Height = u32;
22
23/// One swap as the driver keeps it: both ends frozen at acceptance, the
24/// status, and what the chain cannot report. Our own keys derive from
25/// `swap_id`. Amounts serialize as decimal strings, as the API's do.
26#[serde_as]
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SwapRecord {
29    pub version: u32,
30    pub swap_id: Uuid,
31    pub payment_hash: [u8; 32],
32    pub source: Endpoint,
33    pub dest: Endpoint,
34    #[serde_as(as = "DisplayFromStr")]
35    pub send_amount: u64,
36    #[serde_as(as = "DisplayFromStr")]
37    pub receive_amount: u64,
38    /// The server's split of `send_amount - receive_amount`: `base_fee` for
39    /// its transactions, `service_fee` the rest.
40    #[serde_as(as = "DisplayFromStr")]
41    pub base_fee: u64,
42    #[serde_as(as = "DisplayFromStr")]
43    pub service_fee: u64,
44    /// Our own clock at acceptance.
45    pub started_at: Timestamp,
46    pub status: Status,
47    /// SwapIn: what under the script is ours, as the last pass left it.
48    #[serde(default)]
49    pub funds: Vec<Fund>,
50    /// When the status first became final: our clock, or the server's on
51    /// restore. What `refund_timeout` counts from.
52    #[serde(default)]
53    pub ended_at: Option<Timestamp>,
54    /// The preimage once seen and hashed to `payment_hash`: on a SwapIn the
55    /// server's, from its report or its claim, on a SwapOut ours, from a claim
56    /// of ours on chain.
57    pub preimage: Option<[u8; 32]>,
58    pub sent: Sent,
59    /// The server's status as the last pass that reached it read it, `None`
60    /// before one. Shown, never decided on.
61    #[serde(default)]
62    pub server_status: Option<String>,
63    /// The server's cancel, kept from the first pass that read one. Never
64    /// cleared, since a cancel is final on the server.
65    #[serde(default)]
66    pub server_cancel: Option<ServerCancel>,
67}
68
69/// The server's status by name, `In` for a SwapIn and `Out` for a SwapOut.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
71pub enum ServerStatus {
72    #[strum(default)]
73    In(SwapInStatus),
74    #[strum(default)]
75    Out(SwapOutStatus),
76}
77
78impl ServerStatus {
79    pub fn is_terminal(&self) -> bool {
80        match self {
81            Self::In(status) => matches!(status, SwapInStatus::Claimed | SwapInStatus::Canceled),
82            Self::Out(status) => matches!(
83                status,
84                SwapOutStatus::Claimed | SwapOutStatus::Canceled | SwapOutStatus::Refunded
85            ),
86        }
87    }
88}
89
90/// One end of a swap: on chain when `chain` is set, Lightning when it is
91/// not. The server holds the opposite role on each end, the claim key of a
92/// source and the refund key of a dest.
93#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Endpoint {
95    pub chain: Option<Chain>,
96    pub asset: Option<String>,
97    pub csv: Option<u32>,
98    pub server_pubkey: Option<PublicKey>,
99    pub server_blinding_pubkey: Option<PublicKey>,
100    /// On chain, where the end pays out: the refund address of a source, the
101    /// claim address of a dest.
102    pub destination: Option<String>,
103    /// Lightning: the invoice, paid to a SwapIn, paid by a SwapOut.
104    pub invoice: Option<String>,
105    /// The server's key, once it arrived and verified against `server_pubkey`.
106    pub server_privkey: Option<SecretKey>,
107}
108
109impl Endpoint {
110    pub fn lightning(invoice: String) -> Self {
111        Self {
112            invoice: Some(invoice),
113            ..Self::default()
114        }
115    }
116
117    pub fn on_chain(terms: Terms, destination: Option<String>) -> Self {
118        Self {
119            chain: Some(terms.chain),
120            asset: Some(terms.asset),
121            csv: Some(terms.csv),
122            server_pubkey: Some(terms.server_pubkey),
123            server_blinding_pubkey: terms.server_blinding_pubkey,
124            destination,
125            invoice: None,
126            server_privkey: None,
127        }
128    }
129}
130
131impl SwapRecord {
132    /// The record form this SDK writes and drives.
133    pub const VERSION: u32 = 1;
134
135    /// Nothing a pass could move. A SwapOut once its status is final. A SwapIn
136    /// once its status is final, no refund of ours is unconfirmed, nothing of
137    /// ours is locked, so a fund is seen turn refundable even with no
138    /// destination, nothing refundable waits for a destination to take it, and
139    /// `window` has passed since it ended, so a deposit that crossed the end is
140    /// still found. `hook` is whether the driver can ask for a refund address.
141    pub fn is_quiet(&self, now: Timestamp, window: u64, hook: bool) -> bool {
142        let destination = hook || self.source.destination.is_some();
143        let busy = self.funds.iter().any(|fund| match fund.state {
144            FundState::Refunding { .. } | FundState::Locked { .. } => true,
145            FundState::Refundable => destination,
146            FundState::Dust | FundState::Refunded { .. } => false,
147        });
148        let watched = self
149            .ended_at
150            .is_none_or(|ended| now < ended.saturating_add(window));
151        let swap_in = matches!(self.swap_type(), Ok(SwapType::SwapIn));
152        self.status.is_terminal() && !busy && !(swap_in && watched)
153    }
154
155    /// SwapIn: the server's key while our refund key is home, which is when a
156    /// refund takes the keypath. Once our key leaves the server holds both, so
157    /// a keypath spend from then on is read as the server's.
158    pub(crate) fn refund_keypath(&self) -> Option<SecretKey> {
159        self.source.server_privkey.filter(|_| !self.sent.refund_key)
160    }
161
162    /// SwapIn: whether an output with `confs` confirmations is out of our
163    /// reach: unconfirmed, so a refund never spends a mempool chain, or its
164    /// CSV still running and no keypath to refund it by.
165    pub(crate) fn locked(&self, csv: u32, confs: u32) -> bool {
166        confs == 0 || (self.refund_keypath().is_none() && confs < csv)
167    }
168
169    pub fn server_status(&self) -> Option<ServerStatus> {
170        let label = self.server_status.as_deref()?;
171        match self.swap_type().ok()? {
172            SwapType::SwapIn => label.parse().ok().map(ServerStatus::In),
173            SwapType::SwapOut => label.parse().ok().map(ServerStatus::Out),
174            SwapType::SwapChain => None,
175        }
176    }
177
178    /// What the ends make: one on chain is a SwapIn or a SwapOut, both a
179    /// SwapChain.
180    pub fn swap_type(&self) -> Result<SwapType, DriverError> {
181        match (self.source.chain, self.dest.chain) {
182            (Some(_), None) => Ok(SwapType::SwapIn),
183            (None, Some(_)) => Ok(SwapType::SwapOut),
184            (Some(_), Some(_)) => Ok(SwapType::SwapChain),
185            (None, None) => Err(self.missing("chain")),
186        }
187    }
188
189    pub fn endpoint(&self, side: Side) -> &Endpoint {
190        match side {
191            Side::Source => &self.source,
192            Side::Dest => &self.dest,
193        }
194    }
195
196    pub fn endpoint_mut(&mut self, side: Side) -> &mut Endpoint {
197        match side {
198            Side::Source => &mut self.source,
199            Side::Dest => &mut self.dest,
200        }
201    }
202
203    /// The script terms of the end on `side`. Every use of our keys starts
204    /// here, so a record of another version stops here.
205    pub fn terms(&self, side: Side) -> Result<Terms, DriverError> {
206        if self.version != Self::VERSION {
207            return Err(DriverError::RecordVersion(self.swap_id, self.version));
208        }
209        let end = self.endpoint(side);
210        let missing = |name: &str| self.missing(&format!("{side} {name}"));
211        Ok(Terms {
212            swap_id: self.swap_id,
213            side,
214            chain: end.chain.ok_or_else(|| missing("chain"))?,
215            asset: end.asset.clone().ok_or_else(|| missing("asset"))?,
216            csv: end.csv.ok_or_else(|| missing("csv"))?,
217            payment_hash: self.payment_hash,
218            server_pubkey: end.server_pubkey.ok_or_else(|| missing("server pubkey"))?,
219            server_blinding_pubkey: end.server_blinding_pubkey,
220        })
221    }
222
223    pub(crate) fn missing(&self, what: &str) -> DriverError {
224        DriverError::MissingField(self.swap_id, what.to_owned())
225    }
226}
227
228/// The messages already delivered, since a message leaves no trace on chain.
229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct Sent {
231    pub cancel: bool,
232    pub refund_key: bool,
233    pub preimage: bool,
234}
235
236/// Where a swap stands, resolved from the chain on every pass. Deadlines are
237/// absolute so two quiet passes produce an equal status.
238#[serde_as]
239#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
240#[serde(tag = "status")]
241pub enum Status {
242    /// Restored, before its first pass: where it stands is not known yet.
243    Restoring,
244    Funding {
245        expires_at: Timestamp,
246        cancel_sent: bool,
247    },
248    /// `max_fee_rate` is the chain's, in sat/vB. SwapOut: `fee_rate_spike` is
249    /// the fee estimate while it holds the preimage: above `max_fee_rate`, or
250    /// more than a claim of the opening can pay.
251    Confirming {
252        txid: String,
253        confs: u32,
254        needed: u32,
255        #[serde(default)]
256        max_fee_rate: f64,
257        #[serde(default, skip_serializing_if = "Option::is_none")]
258        fee_rate_spike: Option<f64>,
259    },
260    /// SwapOut only. The preimage may have left: claiming the opening, with
261    /// `claim` and its `fee` once a claim of ours is broadcast.
262    Claiming {
263        opening: String,
264        claim: Option<String>,
265        confs: u32,
266        #[serde(default)]
267        #[serde_as(as = "Option<DisplayFromStr>")]
268        fee: Option<u64>,
269    },
270    /// `fee` is our claim's on a SwapOut, and `None` on a SwapIn, whose
271    /// payout is the server's.
272    Completed {
273        opening: String,
274        payout: Option<String>,
275        #[serde(default)]
276        #[serde_as(as = "Option<DisplayFromStr>")]
277        fee: Option<u64>,
278    },
279    /// The swap did not happen, for `reason`. On a SwapOut the server canceled
280    /// before any opening, or `opening` is left to its CSV refund, and the
281    /// invoice fails back, unless a server that took the preimage refunded as
282    /// well, which only the payer's wallet can tell. On a SwapIn the deposit
283    /// stays ours, with the rest of `funds`.
284    Canceled {
285        opening: Option<String>,
286        reason: CancelReason,
287    },
288}
289
290impl Status {
291    pub fn is_terminal(&self) -> bool {
292        matches!(self, Self::Completed { .. } | Self::Canceled { .. })
293    }
294}
295
296// `max_fee_rate` is finite and `fee_rate_spike` is never set to NaN, so neither
297// is NaN.
298impl Eq for Status {}
299
300/// Why a swap ended `Canceled`.
301#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(tag = "kind")]
303pub enum CancelReason {
304    /// The server gave the swap up, in its words: a SwapIn's key, a SwapOut's
305    /// report before any opening.
306    ServerCanceled(ServerCancel),
307    /// SwapIn: the deposit's CSV matured with neither the server's key nor
308    /// its preimage.
309    CsvMatured,
310    /// SwapOut: the server's CSV refund took the opening.
311    ServerRefunded,
312    /// SwapOut: less than `claim_margin` of the CSV was left before the
313    /// preimage went out, so it never will. The invoice fails back once the
314    /// server's CSV refund takes the opening, from block `at`.
315    ClaimWindowClosed { at: Height },
316}
317
318/// The server's cancel in its words: its `ErrorType` name and the message
319/// the cancel came with.
320#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
321pub struct ServerCancel {
322    pub error_type: Option<String>,
323    pub cancel_message: Option<String>,
324}
325
326/// SwapIn: one output under the script that is ours, and where its refund
327/// stands. The deposit the server was paid for is not listed, nor is an
328/// output the server took by its own path.
329#[serde_as]
330#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
331pub struct Fund {
332    pub outpoint: Outpoint,
333    #[serde_as(as = "DisplayFromStr")]
334    pub amount: u64,
335    pub state: FundState,
336}
337
338#[serde_as]
339#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(tag = "state")]
341pub enum FundState {
342    /// Under the CSV until `at`, or unconfirmed.
343    Locked { at: Option<Height> },
344    /// Spendable now and worth its refund: the next refund takes it.
345    Refundable,
346    /// Spendable, but not worth its fee and the smallest output a refund
347    /// address takes at the last rate. `run` takes the swap up again once
348    /// the rate makes it worth its refund.
349    Dust,
350    /// In our refund `txid`, not yet in a block. `fee` is the whole
351    /// transaction's, shared by every fund it took.
352    Refunding {
353        txid: String,
354        #[serde(default)]
355        #[serde_as(as = "Option<DisplayFromStr>")]
356        fee: Option<u64>,
357    },
358    /// In our refund `txid`, in a block, with `fee` as in `Refunding`.
359    Refunded {
360        txid: String,
361        #[serde(default)]
362        #[serde_as(as = "Option<DisplayFromStr>")]
363        fee: Option<u64>,
364    },
365}
366
367#[derive(Clone, Debug, Error)]
368#[error("store: {0}")]
369pub struct StoreError(pub String);
370
371/// Durable records, wanted by id and nothing else.
372#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
373#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
374pub trait SwapStore: MaybeSend + MaybeSync {
375    /// Replaces any record already stored under the same swap id.
376    async fn save(&self, record: &SwapRecord) -> Result<(), StoreError>;
377    async fn load(&self, swap_id: &Uuid) -> Result<Option<SwapRecord>, StoreError>;
378    /// Every stored swap id, without reading the records, so one that fails
379    /// to load stops only its own swap.
380    async fn list(&self) -> Result<Vec<Uuid>, StoreError>;
381}
382
383#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
384#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
385impl<S: SwapStore + ?Sized> SwapStore for std::sync::Arc<S> {
386    async fn save(&self, record: &SwapRecord) -> Result<(), StoreError> {
387        (**self).save(record).await
388    }
389
390    async fn load(&self, swap_id: &Uuid) -> Result<Option<SwapRecord>, StoreError> {
391        (**self).load(swap_id).await
392    }
393
394    async fn list(&self) -> Result<Vec<Uuid>, StoreError> {
395        (**self).list().await
396    }
397}
398
399/// Records kept in memory, for tests and short-lived tools.
400#[derive(Default)]
401pub struct MemoryStore(Mutex<BTreeMap<Uuid, SwapRecord>>);
402
403#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
404#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
405impl SwapStore for MemoryStore {
406    async fn save(&self, record: &SwapRecord) -> Result<(), StoreError> {
407        self.0
408            .lock()
409            .unwrap()
410            .insert(record.swap_id, record.clone());
411        Ok(())
412    }
413
414    async fn load(&self, swap_id: &Uuid) -> Result<Option<SwapRecord>, StoreError> {
415        Ok(self.0.lock().unwrap().get(swap_id).cloned())
416    }
417
418    async fn list(&self) -> Result<Vec<Uuid>, StoreError> {
419        Ok(self.0.lock().unwrap().keys().copied().collect())
420    }
421}