Skip to main content

anyswap_sdk/
swap_out.rs

1//! Typed client-side view of a swap-out.
2//!
3//! [`SwapOut`] is the parsed form of the wire [`SwapState`]: hex fields become
4//! keys and outpoints, and per-status fields move into [`SwapOutStatus`]
5//! variants so the consumer sees exactly what is available in each state.
6//! Created via `TryFrom<SwapState>` on the polling response.
7//!
8//! A swap-out moves funds from Lightning to on-chain. The user holds the
9//! preimage and pays a held `claim_invoice`; the server then funds the
10//! destination opening transaction. The user is the claim side, the server is
11//! the refund side - the mirror of [`swap_in`](crate::swap_in).
12
13pub use anyswap_core::types::{ErrorType, SwapOutStatus as Status};
14use anyswap_core::{
15    api::SwapState,
16    transaction::TxError,
17    types::{Chain, Outpoint, SwapType},
18};
19use bitcoin::secp256k1::{PublicKey, SecretKey};
20use thiserror::Error;
21use uuid::Uuid;
22
23use crate::{
24    codec::{self, CodecError},
25    context::SwapContext,
26    swap_script::{ScriptError, Side, SwapScript, Terms, Tx},
27    utxo::{SwapUtxo, UtxoError},
28    wallet::SwapWallet,
29};
30
31#[derive(Debug, Error)]
32pub enum SwapOutError {
33    #[error("expected swap type SwapOut, got {0}")]
34    UnexpectedSwapType(SwapType),
35
36    #[error("unknown status: {0}")]
37    UnknownStatus(String),
38
39    #[error("unknown error type: {0}")]
40    UnknownErrorType(String),
41
42    #[error("missing field: {0}")]
43    MissingField(&'static str),
44
45    #[error("a keypath claim needs the refund key the server reveals once the preimage is out")]
46    KeypathClaimUnavailable,
47
48    #[error("no opening outpoint in current status")]
49    NoOutpoint,
50
51    #[error("expected opening tx {expected}, got {found}")]
52    UnexpectedTxid { expected: String, found: String },
53
54    #[error("opening output value {found_sat} sat does not match expected {expected_sat} sat")]
55    AmountMismatch { expected_sat: u64, found_sat: u64 },
56
57    #[error("this client is not set up for Liquid; add the Liquid parameters to the context")]
58    LiquidNotConfigured,
59
60    #[error("the swap trades asset {found}, but this client is set up for {expected}")]
61    UnexpectedAsset { expected: String, found: String },
62
63    #[error("wallet: {0}")]
64    Wallet(String),
65
66    #[error("invalid swap script: {0}")]
67    Script(String),
68
69    #[error("the output does not belong to this swap")]
70    UnknownOutput,
71
72    #[error(transparent)]
73    Utxo(#[from] UtxoError),
74
75    #[error(transparent)]
76    Tx(#[from] TxError),
77
78    #[error(transparent)]
79    Codec(#[from] CodecError),
80}
81
82impl From<ScriptError> for SwapOutError {
83    fn from(e: ScriptError) -> Self {
84        match e {
85            ScriptError::LiquidNotConfigured => Self::LiquidNotConfigured,
86            ScriptError::UnexpectedAsset { expected, found } => {
87                Self::UnexpectedAsset { expected, found }
88            }
89            ScriptError::MissingField(field) => Self::MissingField(field),
90            ScriptError::Wallet(e) => Self::Wallet(e),
91            ScriptError::Script(e) => Self::Script(e),
92        }
93    }
94}
95
96/// A swap-out: the user pays a held `claim_invoice` and the server locks the
97/// proceeds into the swap script on-chain. The user claims that output with the
98/// preimage; the server refunds it after the CSV timeout if the user never
99/// claims. The user holds the claim key, the server holds the refund key.
100#[derive(Debug, Clone)]
101pub struct SwapOut {
102    pub swap_id: Uuid,
103    pub protocol_version: String,
104    pub chain: Chain,
105    pub asset: String,
106    pub receive_amount: u64,
107    pub send_amount: u64,
108    pub base_fee: u64,
109    pub service_fee: u64,
110    pub payment_hash: [u8; 32],
111    pub csv: u32,
112    /// User's pubkey in the swap script (claim side).
113    pub claim_pubkey: PublicKey,
114    /// Server's pubkey in the swap script (refund side).
115    pub refund_pubkey: PublicKey,
116    /// User's ECDH blinding pubkey for the Liquid opening output.
117    pub user_blinding_pubkey: Option<PublicKey>,
118    /// Server's ECDH blinding pubkey for the Liquid opening output.
119    pub service_blinding_pubkey: Option<PublicKey>,
120    /// User's on-chain destination for a successful claim.
121    pub claim_address: String,
122    /// Held invoice the user pays to start the swap.
123    pub claim_invoice: String,
124    pub created_at: u64,
125    pub transitioned_at: u64,
126    pub status: SwapOutStatus,
127}
128
129/// Server-reported swap-out lifecycle state with the fields available in it.
130///
131/// Normal flow: [`AwaitClaimInvoicePay`](Self::AwaitClaimInvoicePay) ->
132/// [`OpeningTxBroadcast`](Self::OpeningTxBroadcast) ->
133/// [`AwaitPreimageRevealed`](Self::AwaitPreimageRevealed) ->
134/// [`Claimed`](Self::Claimed).
135///
136/// If the user never claims, the server refunds the destination output after
137/// the CSV timeout: [`AwaitRefundTxConfirmation`](Self::AwaitRefundTxConfirmation)
138/// -> [`Refunded`](Self::Refunded). Cancellation before the opening tx is
139/// broadcast results in [`Canceled`](Self::Canceled).
140#[derive(Debug, Clone, PartialEq, Eq, strum::Display, strum::IntoStaticStr)]
141pub enum SwapOutStatus {
142    /// Server is waiting for the user to pay the held `claim_invoice`.
143    AwaitClaimInvoicePay,
144    /// Invoice paid; the server is broadcasting the destination opening tx.
145    /// No actionable outpoint yet - wait for
146    /// [`AwaitPreimageRevealed`](Self::AwaitPreimageRevealed).
147    OpeningTxBroadcast,
148    /// Opening tx broadcast at `outpoint`; the user claims once it has the
149    /// required confirmations, either by revealing the preimage off-chain for a
150    /// cooperative keypath claim (via
151    /// [`HttpClient::reveal_preimage`](crate::client::HttpClient::reveal_preimage),
152    /// reaching [`Claimed`](Self::Claimed)) or unilaterally on-chain via
153    /// [`SwapOut::claim_by_preimage`].
154    AwaitPreimageRevealed { outpoint: Outpoint },
155    /// The user did not claim in time; the server is refunding the destination
156    /// output after the CSV timeout. The user can still race a unilateral
157    /// [`SwapOut::claim_by_preimage`] until the refund confirms.
158    AwaitRefundTxConfirmation { outpoint: Outpoint },
159    /// Terminal: the preimage was revealed and the held invoice settled. The
160    /// server reveals its `refund_privkey`; if the preimage was revealed
161    /// off-chain, finish the cooperative claim via [`SwapOut::claim_by_keypath`].
162    Claimed {
163        outpoint: Outpoint,
164        refund_privkey: SecretKey,
165    },
166    /// Terminal: canceled before the opening tx was broadcast. `error_code` is
167    /// the machine-readable reason. No on-chain funds at risk; the held invoice is
168    /// failed back over Lightning.
169    Canceled {
170        error_code: ErrorType,
171        cancel_message: String,
172    },
173    /// Terminal: the destination output was refunded to the server after the
174    /// CSV timeout and the held invoice failed back.
175    Refunded { outpoint: Outpoint },
176}
177
178impl SwapOutStatus {
179    /// Opening outpoint, if the server has broadcast the deposit in the current
180    /// state.
181    pub fn outpoint(&self) -> Option<&Outpoint> {
182        match self {
183            Self::AwaitClaimInvoicePay | Self::OpeningTxBroadcast | Self::Canceled { .. } => None,
184            Self::AwaitPreimageRevealed { outpoint }
185            | Self::AwaitRefundTxConfirmation { outpoint }
186            | Self::Claimed { outpoint, .. }
187            | Self::Refunded { outpoint } => Some(outpoint),
188        }
189    }
190
191    pub fn is_terminal(&self) -> bool {
192        matches!(
193            self,
194            Self::Claimed { .. } | Self::Canceled { .. } | Self::Refunded { .. }
195        )
196    }
197}
198
199/// The user is the claim side of a swap-out, so these build claims only: a
200/// unilateral one that reveals the preimage on chain, and a cooperative one once
201/// the server reveals its refund key. The per-swap keys and the preimage come
202/// from the context's wallet, so a caller never handles a private key.
203impl SwapOut {
204    /// The address the server's opening transaction pays.
205    pub fn address<W: SwapWallet>(&self, ctx: &SwapContext<W>) -> Result<String, SwapOutError> {
206        Ok(self.script(ctx)?.address())
207    }
208
209    /// Checks the server's opening transaction and returns the output to claim.
210    ///
211    /// Call this before releasing the preimage, whether by
212    /// [`claim_by_preimage`](Self::claim_by_preimage) or by
213    /// [`HttpClient::reveal_preimage`](crate::client::HttpClient::reveal_preimage).
214    /// The opening does not exist until the held invoice is paid, so paying is
215    /// not the moment this guards; the preimage is, because releasing it settles
216    /// the invoice whatever the opening turned out to be.
217    ///
218    /// The server funds this output, so its report is not enough on its own: a
219    /// dust value under the right script would otherwise pass unnoticed. This
220    /// checks the transaction against the outpoint the swap recorded, the output
221    /// against the agreed script and asset, its value against `receive_amount`,
222    /// and the server's fee against the limit the user accepted. That anchors
223    /// trust in the chain rather than in what the server says.
224    pub fn verify_opening<W: SwapWallet>(
225        &self,
226        ctx: &SwapContext<W>,
227        tx_hex: &str,
228    ) -> Result<SwapUtxo, SwapOutError> {
229        let outpoint = self.status.outpoint().ok_or(SwapOutError::NoOutpoint)?;
230        let tx = Tx::decode(self.chain, tx_hex)?;
231        if tx.txid() != outpoint.txid {
232            return Err(SwapOutError::UnexpectedTxid {
233                expected: outpoint.txid.clone(),
234                found: tx.txid(),
235            });
236        }
237        let (prevout, value) = self.script(ctx)?.output_at(&tx, outpoint.vout)?;
238        if value != self.receive_amount {
239            return Err(SwapOutError::AmountMismatch {
240                expected_sat: self.receive_amount,
241                found_sat: value,
242            });
243        }
244        Ok(prevout)
245    }
246
247    /// Claims `utxos` by revealing the preimage on chain, which is also how the
248    /// server learns it and settles the held invoice. This is the fallback when
249    /// the server does not cooperate. `destination` defaults to the swap's
250    /// `claim_address`. Returns the signed transaction as hex.
251    pub fn claim_by_preimage<W: SwapWallet>(
252        &self,
253        ctx: &SwapContext<W>,
254        utxos: &[SwapUtxo],
255        destination: Option<&str>,
256        fee_rate: f64,
257    ) -> Result<String, SwapOutError> {
258        let claim_privkey = self.claim_privkey(ctx)?;
259        let preimage = ctx
260            .wallet()
261            .preimage(&self.swap_id)
262            .map_err(|error| SwapOutError::Wallet(error.to_string()))?;
263        Ok(self.script(ctx)?.claim_by_preimage(
264            utxos,
265            claim_privkey,
266            preimage,
267            &self.destination(destination),
268            fee_rate,
269        )?)
270    }
271
272    /// Claims `utxos` cooperatively, once the server has revealed its refund key
273    /// in exchange for the preimage. Cheaper than the preimage path and it keeps
274    /// the preimage off chain. `destination` defaults to the swap's
275    /// `claim_address`. Returns the signed transaction as hex.
276    pub fn claim_by_keypath<W: SwapWallet>(
277        &self,
278        ctx: &SwapContext<W>,
279        utxos: &[SwapUtxo],
280        destination: Option<&str>,
281        fee_rate: f64,
282    ) -> Result<String, SwapOutError> {
283        let SwapOutStatus::Claimed { refund_privkey, .. } = &self.status else {
284            return Err(SwapOutError::KeypathClaimUnavailable);
285        };
286        let claim_privkey = self.claim_privkey(ctx)?;
287        Ok(self.script(ctx)?.keypath(
288            utxos,
289            claim_privkey,
290            *refund_privkey,
291            &self.destination(destination),
292            fee_rate,
293        )?)
294    }
295
296    /// The value of `utxo` in sats. Bitcoin states it on chain; a Liquid value
297    /// is blinded, and this unblinds it with the swap's own key.
298    pub fn value<W: SwapWallet>(
299        &self,
300        ctx: &SwapContext<W>,
301        utxo: &SwapUtxo,
302    ) -> Result<u64, SwapOutError> {
303        self.script(ctx)?
304            .value(utxo)
305            .ok_or(SwapOutError::UnknownOutput)
306    }
307
308    /// The terms the opening script is built from.
309    pub fn terms(&self) -> Terms {
310        Terms {
311            swap_id: self.swap_id,
312            side: Side::Dest,
313            chain: self.chain,
314            asset: self.asset.clone(),
315            csv: self.csv,
316            payment_hash: self.payment_hash,
317            server_pubkey: self.refund_pubkey,
318            server_blinding_pubkey: self.service_blinding_pubkey,
319        }
320    }
321
322    fn script<W: SwapWallet>(&self, ctx: &SwapContext<W>) -> Result<SwapScript, SwapOutError> {
323        Ok(ctx.script(&self.terms())?)
324    }
325
326    /// The user's key in the swap script.
327    fn claim_privkey<W: SwapWallet>(
328        &self,
329        ctx: &SwapContext<W>,
330    ) -> Result<SecretKey, SwapOutError> {
331        ctx.wallet()
332            .dest_claim_privkey(&self.swap_id)
333            .map_err(|error| SwapOutError::Wallet(error.to_string()))
334    }
335
336    fn destination(&self, destination: Option<&str>) -> String {
337        destination.unwrap_or(&self.claim_address).to_string()
338    }
339}
340
341impl TryFrom<SwapState> for SwapOut {
342    type Error = SwapOutError;
343
344    fn try_from(s: SwapState) -> Result<Self, SwapOutError> {
345        if s.swap_type != SwapType::SwapOut {
346            return Err(SwapOutError::UnexpectedSwapType(s.swap_type));
347        }
348
349        let status: Status = s
350            .status
351            .parse()
352            .map_err(|_| SwapOutError::UnknownStatus(s.status.clone()))?;
353
354        let status = match status {
355            Status::AwaitClaimInvoicePay => SwapOutStatus::AwaitClaimInvoicePay,
356            Status::OpeningTxBroadcast => SwapOutStatus::OpeningTxBroadcast,
357            Status::AwaitPreimageRevealed => SwapOutStatus::AwaitPreimageRevealed {
358                outpoint: dest_outpoint(&s)?,
359            },
360            Status::AwaitRefundTxConfirmation => SwapOutStatus::AwaitRefundTxConfirmation {
361                outpoint: dest_outpoint(&s)?,
362            },
363            Status::Claimed => SwapOutStatus::Claimed {
364                outpoint: dest_outpoint(&s)?,
365                refund_privkey: codec::parse_privkey(&require(
366                    s.dest_refund_privkey.clone(),
367                    "dest_refund_privkey",
368                )?)?,
369            },
370            Status::Canceled => {
371                let raw = require(s.error_type.clone(), "error_type")?;
372                SwapOutStatus::Canceled {
373                    error_code: raw
374                        .parse()
375                        .map_err(|_| SwapOutError::UnknownErrorType(raw.clone()))?,
376                    cancel_message: require(s.cancel_message.clone(), "cancel_message")?,
377                }
378            }
379            Status::Refunded => SwapOutStatus::Refunded {
380                outpoint: dest_outpoint(&s)?,
381            },
382        };
383        let user_blinding_pubkey = s
384            .dest_user_blinding_pubkey
385            .as_deref()
386            .map(codec::parse_pubkey)
387            .transpose()?;
388        let service_blinding_pubkey = s
389            .dest_service_blinding_pubkey
390            .as_deref()
391            .map(codec::parse_pubkey)
392            .transpose()?;
393
394        Ok(SwapOut {
395            swap_id: s.swap_id,
396            protocol_version: s.protocol_version,
397            chain: require(s.dest_chain, "dest_chain")?,
398            asset: require(s.dest_asset, "dest_asset")?,
399            receive_amount: s.receive_amount,
400            send_amount: s.send_amount,
401            base_fee: s.base_fee,
402            service_fee: s.service_fee,
403            payment_hash: codec::parse_hash32(&s.payment_hash)?,
404            csv: require(s.dest_csv, "dest_csv")?,
405            claim_pubkey: codec::parse_pubkey(&require(s.dest_claim_pubkey, "dest_claim_pubkey")?)?,
406            refund_pubkey: codec::parse_pubkey(&require(
407                s.dest_refund_pubkey,
408                "dest_refund_pubkey",
409            )?)?,
410            user_blinding_pubkey,
411            service_blinding_pubkey,
412            claim_address: require(s.user_claim_address, "user_claim_address")?,
413            claim_invoice: require(s.claim_invoice, "claim_invoice")?,
414            created_at: s.created_at,
415            transitioned_at: s.transitioned_at,
416            status,
417        })
418    }
419}
420
421fn require<T>(opt: Option<T>, field: &'static str) -> Result<T, SwapOutError> {
422    opt.ok_or(SwapOutError::MissingField(field))
423}
424
425fn dest_outpoint(s: &SwapState) -> Result<Outpoint, SwapOutError> {
426    Ok(codec::parse_outpoint(
427        require(s.dest_txid.as_deref(), "dest_txid")?,
428        require(s.dest_vout, "dest_vout")?,
429    )?)
430}
431
432#[cfg(test)]
433mod tests {
434    use anyswap_core::{
435        secp,
436        transaction::liquid::decode_tx_hex,
437        types::{Chain, SwapNetwork},
438    };
439    use bitcoin::hashes::{Hash as _, sha256};
440
441    use super::*;
442    use crate::{
443        utxo::fixtures::{confidential_tx, genesis_hash, liquid_asset, liquid_destination},
444        wallet::Wallet,
445    };
446
447    const SERVICE_SIGNING_KEY: [u8; 32] = [21; 32];
448    const SERVICE_BLINDING_KEY: [u8; 32] = [22; 32];
449
450    fn context() -> SwapContext<Wallet> {
451        let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
452        SwapContext::new(wallet)
453            .with_liquid(&genesis_hash(), &liquid_asset())
454            .unwrap()
455    }
456
457    /// A Liquid swap-out whose opening is recorded at `outpoint`.
458    fn swap_out(ctx: &SwapContext<Wallet>, status: SwapOutStatus) -> SwapOut {
459        let swap_id = Uuid::from_u128(12);
460        let service_signing = SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap();
461        let service_blinding = SecretKey::from_slice(&SERVICE_BLINDING_KEY).unwrap();
462        let preimage = ctx.wallet().preimage(&swap_id).unwrap();
463        SwapOut {
464            swap_id,
465            protocol_version: "2.0.0".into(),
466            chain: Chain::Liquid,
467            asset: liquid_asset(),
468            receive_amount: 100_000,
469            send_amount: 101_000,
470            base_fee: 1_000,
471            service_fee: 0,
472            payment_hash: sha256::Hash::hash(&preimage).to_byte_array(),
473            csv: 144,
474            claim_pubkey: ctx.wallet().dest_claim_pubkey(&swap_id).unwrap(),
475            refund_pubkey: service_signing.public_key(secp()),
476            user_blinding_pubkey: Some(ctx.wallet().dest_blinding_pubkey(&swap_id).unwrap()),
477            service_blinding_pubkey: Some(service_blinding.public_key(secp())),
478            claim_address: liquid_destination(),
479            claim_invoice: "lnbcrt1".into(),
480            created_at: 1,
481            transitioned_at: 1,
482            status,
483        }
484    }
485
486    fn awaiting(txid: &str) -> SwapOutStatus {
487        SwapOutStatus::AwaitPreimageRevealed {
488            outpoint: Outpoint::new(txid, 0),
489        }
490    }
491
492    /// Builds the opening the server would fund, then re-points the swap at it.
493    fn opening(ctx: &SwapContext<Wallet>, value: u64) -> (SwapOut, String) {
494        let swap = swap_out(ctx, awaiting("00"));
495        let script = swap.script(ctx).unwrap();
496        let tx_hex = confidential_tx(script.liquid().unwrap(), &[value]);
497        let txid = decode_tx_hex(&tx_hex).unwrap().txid().to_string();
498        (swap_out(ctx, awaiting(&txid)), tx_hex)
499    }
500
501    #[test]
502    fn verify_opening_returns_the_output_to_claim() {
503        let ctx = context();
504        let (swap, tx_hex) = opening(&ctx, 100_000);
505
506        let utxo = swap.verify_opening(&ctx, &tx_hex).unwrap();
507
508        assert_eq!(swap.value(&ctx, &utxo).unwrap(), 100_000);
509        assert_eq!(utxo.vout, 0);
510        let claim = swap.claim_by_preimage(&ctx, &[utxo], None, 1.0).unwrap();
511        assert_eq!(decode_tx_hex(&claim).unwrap().input.len(), 1);
512    }
513
514    #[test]
515    fn verify_opening_rejects_a_short_payout() {
516        let ctx = context();
517        let (swap, tx_hex) = opening(&ctx, 99_999);
518
519        assert!(matches!(
520            swap.verify_opening(&ctx, &tx_hex),
521            Err(SwapOutError::AmountMismatch {
522                expected_sat: 100_000,
523                found_sat: 99_999,
524            })
525        ));
526    }
527
528    #[test]
529    fn verify_opening_rejects_a_transaction_the_swap_did_not_record() {
530        let ctx = context();
531        let (_, tx_hex) = opening(&ctx, 100_000);
532        let swap = swap_out(&ctx, awaiting("11".repeat(32).as_str()));
533
534        assert!(matches!(
535            swap.verify_opening(&ctx, &tx_hex),
536            Err(SwapOutError::UnexpectedTxid { .. })
537        ));
538    }
539
540    #[test]
541    fn a_keypath_claim_needs_the_revealed_refund_key() {
542        let ctx = context();
543        let (swap, tx_hex) = opening(&ctx, 100_000);
544        let utxo = swap.verify_opening(&ctx, &tx_hex).unwrap();
545
546        assert!(matches!(
547            swap.claim_by_keypath(&ctx, std::slice::from_ref(&utxo), None, 1.0),
548            Err(SwapOutError::KeypathClaimUnavailable)
549        ));
550
551        let claimed = SwapOut {
552            status: SwapOutStatus::Claimed {
553                outpoint: utxo.outpoint(),
554                refund_privkey: SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap(),
555            },
556            ..swap
557        };
558        let tx_hex = claimed.claim_by_keypath(&ctx, &[utxo], None, 1.0).unwrap();
559        assert_eq!(decode_tx_hex(&tx_hex).unwrap().input.len(), 1);
560    }
561}