Skip to main content

anyswap_sdk/
swap_in.rs

1//! Typed client-side view of a swap-in.
2//!
3//! [`SwapIn`] is the parsed form of the wire [`SwapState`]: hex fields become
4//! keys and outpoints, and per-status fields move into [`SwapInStatus`] variants
5//! so the consumer sees exactly what is available in each state. Created via
6//! `TryFrom<SwapState>` on the polling response.
7
8pub use anyswap_core::types::{ErrorType, SwapInStatus as Status};
9use anyswap_core::{
10    api::SwapState,
11    invoice::{self, InvoiceError},
12    transaction::TxError,
13    types::{Chain, Outpoint, SwapType},
14    utils::sha256_payment_hash,
15};
16use bitcoin::secp256k1::{PublicKey, SecretKey};
17use thiserror::Error;
18use uuid::Uuid;
19
20use crate::{
21    codec::{self, CodecError},
22    context::SwapContext,
23    swap_script::{ScriptError, Side, SwapScript, Terms, Tx},
24    utxo::{SwapUtxo, UtxoError},
25    wallet::SwapWallet,
26};
27
28#[derive(Debug, Error)]
29pub enum SwapInError {
30    #[error("expected swap type SwapIn, got {0}")]
31    UnexpectedSwapType(SwapType),
32
33    #[error("unknown status: {0}")]
34    UnknownStatus(String),
35
36    #[error("unknown error type: {0}")]
37    UnknownErrorType(String),
38
39    #[error("missing field: {0}")]
40    MissingField(&'static str),
41
42    #[error("a keypath refund needs the claim key the server reveals when it cancels")]
43    KeypathRefundUnavailable,
44
45    #[error("this client is not set up for Liquid; add the Liquid parameters to the context")]
46    LiquidNotConfigured,
47
48    #[error("the swap trades asset {found}, but this client is set up for {expected}")]
49    UnexpectedAsset { expected: String, found: String },
50
51    #[error("wallet: {0}")]
52    Wallet(String),
53
54    #[error("invalid swap script: {0}")]
55    Script(String),
56
57    #[error("the output does not belong to this swap")]
58    UnknownOutput,
59
60    #[error("the server has not reported a preimage in this status")]
61    NoPreimage,
62
63    #[error(transparent)]
64    Invoice(#[from] InvoiceError),
65
66    #[error("the preimage hashes to {found}, but the claim invoice's payment hash is {expected}")]
67    UnexpectedPaymentHash { expected: String, found: String },
68
69    #[error(transparent)]
70    Utxo(#[from] UtxoError),
71
72    #[error(transparent)]
73    Tx(#[from] TxError),
74
75    #[error(transparent)]
76    Codec(#[from] CodecError),
77}
78
79impl From<ScriptError> for SwapInError {
80    fn from(e: ScriptError) -> Self {
81        match e {
82            ScriptError::LiquidNotConfigured => Self::LiquidNotConfigured,
83            ScriptError::UnexpectedAsset { expected, found } => {
84                Self::UnexpectedAsset { expected, found }
85            }
86            ScriptError::MissingField(field) => Self::MissingField(field),
87            ScriptError::Wallet(e) => Self::Wallet(e),
88            ScriptError::Script(e) => Self::Script(e),
89        }
90    }
91}
92
93/// A swap-in: the user locks on-chain funds into the swap script and the server
94/// pays the user's `claim_invoice` once the opening transaction confirms. The
95/// user holds the refund key, the server holds the claim key.
96#[derive(Debug, Clone)]
97pub struct SwapIn {
98    pub swap_id: Uuid,
99    pub protocol_version: String,
100    pub chain: Chain,
101    pub asset: String,
102    pub receive_amount: u64,
103    pub send_amount: u64,
104    pub base_fee: u64,
105    pub service_fee: u64,
106    pub payment_hash: [u8; 32],
107    pub csv: u32,
108    /// User's pubkey in the swap script (refund side).
109    pub refund_pubkey: PublicKey,
110    /// Server's pubkey in the swap script (claim side).
111    pub claim_pubkey: PublicKey,
112    /// User's ECDH blinding pubkey for the Liquid opening output.
113    pub user_blinding_pubkey: Option<PublicKey>,
114    /// Server's ECDH blinding pubkey for the Liquid opening output.
115    pub service_blinding_pubkey: Option<PublicKey>,
116    pub claim_invoice: String,
117    pub refund_address: Option<String>,
118    pub created_at: u64,
119    pub transitioned_at: u64,
120    pub status: SwapInStatus,
121}
122
123/// Server-reported swap-in lifecycle state with the fields available in it.
124///
125/// Normal flow: [`AwaitOpeningTxConfirmation`](Self::AwaitOpeningTxConfirmation) ->
126/// [`PayInvoice`](Self::PayInvoice) ->
127/// [`AwaitClaimKeyRevealMessage`](Self::AwaitClaimKeyRevealMessage) ->
128/// [`AwaitClaimTxConfirmation`](Self::AwaitClaimTxConfirmation) ->
129/// [`Claimed`](Self::Claimed).
130///
131/// Cancellation at any point before a successful claim results in
132/// [`Canceled`](Self::Canceled), where the server reveals its claim privkey so
133/// the user can refund a deposit without waiting out the CSV.
134#[derive(Debug, Clone, PartialEq, Eq, strum::Display, strum::IntoStaticStr)]
135pub enum SwapInStatus {
136    /// Server is watching the swap address for the opening tx and its
137    /// confirmations. No server-provided outpoint yet: the client tracks its own
138    /// deposit by scanning the swap address until the swap advances.
139    AwaitOpeningTxConfirmation,
140    /// Opening tx confirmed; server is paying the claim invoice.
141    PayInvoice { outpoint: Outpoint },
142    /// Invoice paid. `preimage` is the server's evidence of that; check it with
143    /// [`SwapIn::verify_payment`] before revealing the refund key. Server awaits
144    /// that key (sent via
145    /// [`HttpClient::reveal_claim`](crate::client::HttpClient::reveal_claim))
146    /// for a cooperative keypath claim; otherwise it claims via the preimage
147    /// script path.
148    AwaitClaimKeyRevealMessage {
149        outpoint: Outpoint,
150        preimage: [u8; 32],
151    },
152    /// Claim tx broadcast; awaiting on-chain confirmation.
153    AwaitClaimTxConfirmation {
154        outpoint: Outpoint,
155        preimage: [u8; 32],
156    },
157    /// Terminal: claim tx confirmed. Swap finished successfully.
158    Claimed {
159        outpoint: Outpoint,
160        preimage: [u8; 32],
161    },
162    /// Terminal: canceled before a successful claim. `error_code` is the
163    /// machine-readable reason. The server reveals its claim privkey; the user
164    /// recovers any deposit by re-deriving the swap address, finding the UTXOs on
165    /// chain, and refunding them (keypath, or CSV).
166    Canceled {
167        error_code: ErrorType,
168        claim_privkey: SecretKey,
169        cancel_message: String,
170    },
171}
172
173impl SwapInStatus {
174    /// Opening outpoint, once the server has recorded the confirmed deposit.
175    /// `Canceled` never carries it; the user re-derives the address to find their
176    /// deposit on chain.
177    pub fn outpoint(&self) -> Option<&Outpoint> {
178        match self {
179            Self::AwaitOpeningTxConfirmation | Self::Canceled { .. } => None,
180            Self::PayInvoice { outpoint }
181            | Self::AwaitClaimKeyRevealMessage { outpoint, .. }
182            | Self::AwaitClaimTxConfirmation { outpoint, .. }
183            | Self::Claimed { outpoint, .. } => Some(outpoint),
184        }
185    }
186
187    /// Payment preimage, present from the moment the server paid the claim
188    /// invoice.
189    pub fn preimage(&self) -> Option<&[u8; 32]> {
190        match self {
191            Self::AwaitClaimKeyRevealMessage { preimage, .. }
192            | Self::AwaitClaimTxConfirmation { preimage, .. }
193            | Self::Claimed { preimage, .. } => Some(preimage),
194            _ => None,
195        }
196    }
197
198    pub fn is_terminal(&self) -> bool {
199        matches!(self, Self::Claimed { .. } | Self::Canceled { .. })
200    }
201}
202
203/// The user is the refund side of a swap-in, so these build refunds only: a
204/// cooperative one once the server reveals its claim key, and the unilateral CSV
205/// one otherwise. The per-swap keys come from the context's wallet, so a caller
206/// never handles a private key.
207impl SwapIn {
208    /// Checks the reported preimage against the payment hash in
209    /// [`Self::claim_invoice`], which only paying it yields. Call before
210    /// [`HttpClient::reveal_claim`](crate::client::HttpClient::reveal_claim).
211    /// The invoice is the server's echo of the one sent in `create_swap`;
212    /// confirm they match separately.
213    pub fn verify_payment(&self) -> Result<(), SwapInError> {
214        let expected = invoice::payment_hash(&self.claim_invoice)?;
215        let preimage = self.status.preimage().ok_or(SwapInError::NoPreimage)?;
216        let found = sha256_payment_hash(preimage);
217        if found != expected {
218            return Err(SwapInError::UnexpectedPaymentHash {
219                expected: hex::encode(expected),
220                found: hex::encode(found),
221            });
222        }
223        Ok(())
224    }
225
226    /// The deposit address. The user sends `send_amount` here to open the swap.
227    pub fn address<W: SwapWallet>(&self, ctx: &SwapContext<W>) -> Result<String, SwapInError> {
228        Ok(self.script(ctx)?.address())
229    }
230
231    /// Every output of `tx_hex` paying the deposit address: the deposit itself,
232    /// and any strays sent to the same address.
233    ///
234    /// On Liquid an output that does not unblind with this swap's key belongs
235    /// to someone else and is skipped. One that holds another asset is skipped
236    /// too: it is the user's, but no builder here spends it.
237    pub fn outputs<W: SwapWallet>(
238        &self,
239        ctx: &SwapContext<W>,
240        tx_hex: &str,
241    ) -> Result<Vec<SwapUtxo>, SwapInError> {
242        let tx = Tx::decode(self.chain, tx_hex)?;
243        Ok(self.script(ctx)?.outputs(&tx))
244    }
245
246    /// Splits `prevouts` into the ones worth refunding at `fee_rate` and the
247    /// ones that would cost more to spend than they return. Run strays through
248    /// this before refunding, and show the second set to the user. Needs the
249    /// context because Liquid values only exist blinded inside the outputs.
250    pub fn partition_economical<W: SwapWallet>(
251        &self,
252        ctx: &SwapContext<W>,
253        utxos: &[SwapUtxo],
254        fee_rate: f64,
255    ) -> Result<(Vec<SwapUtxo>, Vec<SwapUtxo>), SwapInError> {
256        Ok(self.script(ctx)?.economical(utxos, fee_rate)?)
257    }
258
259    /// The value of `utxo` in sats. Bitcoin states it on chain; a Liquid value
260    /// is blinded, and this unblinds it with the swap's own key.
261    pub fn value<W: SwapWallet>(
262        &self,
263        ctx: &SwapContext<W>,
264        utxo: &SwapUtxo,
265    ) -> Result<u64, SwapInError> {
266        self.script(ctx)?
267            .value(utxo)
268            .ok_or(SwapInError::UnknownOutput)
269    }
270
271    /// Refunds `utxos` cooperatively, spendable right away. Needs the claim key
272    /// the server reveals when it cancels the swap. Returns the signed
273    /// transaction as hex.
274    pub fn refund_by_keypath<W: SwapWallet>(
275        &self,
276        ctx: &SwapContext<W>,
277        utxos: &[SwapUtxo],
278        destination: &str,
279        fee_rate: f64,
280    ) -> Result<String, SwapInError> {
281        let SwapInStatus::Canceled { claim_privkey, .. } = &self.status else {
282            return Err(SwapInError::KeypathRefundUnavailable);
283        };
284        let refund_privkey = self.refund_privkey(ctx)?;
285        Ok(self.script(ctx)?.keypath(
286            utxos,
287            *claim_privkey,
288            refund_privkey,
289            destination,
290            fee_rate,
291        )?)
292    }
293
294    /// Refunds `utxos` unilaterally, valid once every input has aged past the
295    /// swap's CSV timeout. Needs only the user's own key, so it works even when
296    /// the server does not answer. Returns the signed transaction as hex.
297    pub fn refund_by_csv<W: SwapWallet>(
298        &self,
299        ctx: &SwapContext<W>,
300        utxos: &[SwapUtxo],
301        destination: &str,
302        fee_rate: f64,
303    ) -> Result<String, SwapInError> {
304        let refund_privkey = self.refund_privkey(ctx)?;
305        Ok(self
306            .script(ctx)?
307            .refund_by_csv(utxos, refund_privkey, destination, fee_rate)?)
308    }
309
310    /// The terms the deposit script is built from.
311    pub fn terms(&self) -> Terms {
312        Terms {
313            swap_id: self.swap_id,
314            side: Side::Source,
315            chain: self.chain,
316            asset: self.asset.clone(),
317            csv: self.csv,
318            payment_hash: self.payment_hash,
319            server_pubkey: self.claim_pubkey,
320            server_blinding_pubkey: self.service_blinding_pubkey,
321        }
322    }
323
324    fn script<W: SwapWallet>(&self, ctx: &SwapContext<W>) -> Result<SwapScript, SwapInError> {
325        Ok(ctx.script(&self.terms())?)
326    }
327
328    /// The user's key in the swap script.
329    fn refund_privkey<W: SwapWallet>(
330        &self,
331        ctx: &SwapContext<W>,
332    ) -> Result<SecretKey, SwapInError> {
333        ctx.wallet()
334            .source_refund_privkey(&self.swap_id)
335            .map_err(|error| SwapInError::Wallet(error.to_string()))
336    }
337}
338
339impl TryFrom<SwapState> for SwapIn {
340    type Error = SwapInError;
341
342    fn try_from(s: SwapState) -> Result<Self, SwapInError> {
343        if s.swap_type != SwapType::SwapIn {
344            return Err(SwapInError::UnexpectedSwapType(s.swap_type));
345        }
346
347        let status: Status = s
348            .status
349            .parse()
350            .map_err(|_| SwapInError::UnknownStatus(s.status.clone()))?;
351
352        let status = match status {
353            Status::AwaitOpeningTxConfirmation => SwapInStatus::AwaitOpeningTxConfirmation,
354            Status::PayInvoice => SwapInStatus::PayInvoice {
355                outpoint: source_outpoint(&s)?,
356            },
357            Status::AwaitClaimKeyRevealMessage => SwapInStatus::AwaitClaimKeyRevealMessage {
358                outpoint: source_outpoint(&s)?,
359                preimage: preimage(&s)?,
360            },
361            Status::AwaitClaimTxConfirmation => SwapInStatus::AwaitClaimTxConfirmation {
362                outpoint: source_outpoint(&s)?,
363                preimage: preimage(&s)?,
364            },
365            Status::Claimed => SwapInStatus::Claimed {
366                outpoint: source_outpoint(&s)?,
367                preimage: preimage(&s)?,
368            },
369            Status::Canceled => {
370                let raw = require(s.error_type.clone(), "error_type")?;
371                SwapInStatus::Canceled {
372                    error_code: raw
373                        .parse()
374                        .map_err(|_| SwapInError::UnknownErrorType(raw.clone()))?,
375                    claim_privkey: codec::parse_privkey(&require(
376                        s.source_claim_privkey.clone(),
377                        "source_claim_privkey",
378                    )?)?,
379                    cancel_message: require(s.cancel_message.clone(), "cancel_message")?,
380                }
381            }
382        };
383        let user_blinding_pubkey = s
384            .source_user_blinding_pubkey
385            .as_deref()
386            .map(codec::parse_pubkey)
387            .transpose()?;
388        let service_blinding_pubkey = s
389            .source_service_blinding_pubkey
390            .as_deref()
391            .map(codec::parse_pubkey)
392            .transpose()?;
393
394        Ok(SwapIn {
395            swap_id: s.swap_id,
396            protocol_version: s.protocol_version,
397            chain: require(s.source_chain, "source_chain")?,
398            asset: require(s.source_asset, "source_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.source_csv, "source_csv")?,
405            refund_pubkey: codec::parse_pubkey(&require(
406                s.source_refund_pubkey,
407                "source_refund_pubkey",
408            )?)?,
409            claim_pubkey: codec::parse_pubkey(&require(
410                s.source_claim_pubkey,
411                "source_claim_pubkey",
412            )?)?,
413            user_blinding_pubkey,
414            service_blinding_pubkey,
415            claim_invoice: require(s.claim_invoice, "claim_invoice")?,
416            refund_address: s.user_refund_address,
417            created_at: s.created_at,
418            transitioned_at: s.transitioned_at,
419            status,
420        })
421    }
422}
423
424fn require<T>(opt: Option<T>, field: &'static str) -> Result<T, SwapInError> {
425    opt.ok_or(SwapInError::MissingField(field))
426}
427
428fn source_outpoint(s: &SwapState) -> Result<Outpoint, SwapInError> {
429    Ok(codec::parse_outpoint(
430        require(s.source_txid.as_deref(), "source_txid")?,
431        require(s.source_vout, "source_vout")?,
432    )?)
433}
434
435fn preimage(s: &SwapState) -> Result<[u8; 32], SwapInError> {
436    Ok(codec::parse_hash32(require(
437        s.preimage.as_deref(),
438        "preimage",
439    )?)?)
440}
441
442#[cfg(test)]
443mod tests {
444    use anyswap_core::{
445        script::{BitcoinSwapScript, LiquidSwapScript},
446        secp,
447        types::{ErrorType, NATIVE_ASSET, SwapNetwork},
448    };
449    use bitcoin::hashes::{Hash as _, sha256};
450
451    use super::*;
452    use crate::{
453        swap_script::SpendPath,
454        utxo::fixtures::{confidential_tx, genesis_hash, liquid_asset, liquid_destination},
455        wallet::Wallet,
456    };
457
458    const SERVICE_SIGNING_KEY: [u8; 32] = [21; 32];
459    const SERVICE_BLINDING_KEY: [u8; 32] = [22; 32];
460    const BITCOIN_DESTINATION: &str = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080";
461
462    fn context() -> SwapContext<Wallet> {
463        let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
464        SwapContext::new(wallet)
465            .with_liquid(&genesis_hash(), &liquid_asset())
466            .unwrap()
467    }
468
469    fn swap_in(ctx: &SwapContext<Wallet>, chain: Chain, status: SwapInStatus) -> SwapIn {
470        let swap_id = Uuid::from_u128(11);
471        let service_signing = SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap();
472        let service_blinding = SecretKey::from_slice(&SERVICE_BLINDING_KEY).unwrap();
473        let preimage = ctx.wallet().preimage(&swap_id).unwrap();
474        SwapIn {
475            swap_id,
476            protocol_version: "2.0.0".into(),
477            chain,
478            asset: match chain {
479                Chain::Bitcoin => NATIVE_ASSET.to_string(),
480                Chain::Liquid => liquid_asset(),
481            },
482            receive_amount: 100_000,
483            send_amount: 101_000,
484            base_fee: 1_000,
485            service_fee: 0,
486            payment_hash: sha256::Hash::hash(&preimage).to_byte_array(),
487            csv: 144,
488            refund_pubkey: ctx.wallet().source_refund_pubkey(&swap_id).unwrap(),
489            claim_pubkey: service_signing.public_key(secp()),
490            user_blinding_pubkey: Some(ctx.wallet().source_blinding_pubkey(&swap_id).unwrap()),
491            service_blinding_pubkey: Some(service_blinding.public_key(secp())),
492            claim_invoice: "lnbcrt1".into(),
493            refund_address: None,
494            created_at: 1,
495            transitioned_at: 1,
496            status,
497        }
498    }
499
500    fn canceled() -> SwapInStatus {
501        SwapInStatus::Canceled {
502            error_code: ErrorType::UserCanceled,
503            claim_privkey: SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap(),
504            cancel_message: "canceled".into(),
505        }
506    }
507
508    #[test]
509    fn liquid_refunds_the_deposit_and_a_stray_together() {
510        let ctx = context();
511        let swap = swap_in(&ctx, Chain::Liquid, canceled());
512        let opening_tx = confidential_tx(&liquid_script(&swap, &ctx), &[101_000, 40_000]);
513
514        let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
515
516        assert_eq!(utxos.len(), 2);
517        assert_eq!(swap.value(&ctx, &utxos[0]).unwrap(), 101_000);
518        assert_eq!(swap.value(&ctx, &utxos[1]).unwrap(), 40_000);
519        let destination = liquid_destination();
520        for tx_hex in [
521            swap.refund_by_csv(&ctx, &utxos, &destination, 1.0).unwrap(),
522            swap.refund_by_keypath(&ctx, &utxos, &destination, 1.0)
523                .unwrap(),
524        ] {
525            let tx = anyswap_core::transaction::liquid::decode_tx_hex(&tx_hex).unwrap();
526            assert_eq!(tx.input.len(), 2);
527        }
528    }
529
530    #[test]
531    fn liquid_skips_outputs_of_another_swap() {
532        let ctx = context();
533        let swap = swap_in(&ctx, Chain::Liquid, canceled());
534        let other = SwapIn {
535            csv: 288,
536            ..swap_in(&ctx, Chain::Liquid, canceled())
537        };
538        let other_tx = confidential_tx(&liquid_script(&other, &ctx), &[101_000]);
539
540        assert!(swap.outputs(&ctx, &other_tx).unwrap().is_empty());
541    }
542
543    #[test]
544    fn another_swaps_output_has_no_value_here() {
545        let ctx = context();
546        let swap = swap_in(&ctx, Chain::Liquid, canceled());
547        // A different swap id derives a different blinding key, so its outputs
548        // do not unblind with this swap's.
549        let other = SwapIn {
550            swap_id: Uuid::from_u128(99),
551            ..swap_in(&ctx, Chain::Liquid, canceled())
552        };
553        let other_tx = confidential_tx(&liquid_script(&other, &ctx), &[101_000]);
554        let theirs = other.outputs(&ctx, &other_tx).unwrap().remove(0);
555
556        assert!(matches!(
557            swap.value(&ctx, &theirs),
558            Err(SwapInError::UnknownOutput)
559        ));
560    }
561
562    #[test]
563    fn refunds_a_utxo_the_caller_supplied() {
564        let ctx = context();
565        let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
566        let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
567        let txid = anyswap_core::transaction::bitcoin::decode_tx_hex(&opening_tx)
568            .unwrap()
569            .compute_txid()
570            .to_string();
571        // Not from outputs(): the values an indexer reported, as a caller with
572        // their own chain source would pass them.
573        let utxos = vec![SwapUtxo::bitcoin(txid, 0, 100_000)];
574
575        let tx_hex = swap
576            .refund_by_csv(
577                &ctx,
578                &utxos,
579                "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080",
580                1.0,
581            )
582            .unwrap();
583
584        let tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
585        assert_eq!(tx.input.len(), 1);
586        assert_eq!(swap.value(&ctx, &utxos[0]).unwrap(), 100_000);
587    }
588
589    #[test]
590    fn a_keypath_refund_needs_the_revealed_claim_key() {
591        let ctx = context();
592        let swap = swap_in(
593            &ctx,
594            Chain::Liquid,
595            SwapInStatus::AwaitOpeningTxConfirmation,
596        );
597
598        assert!(matches!(
599            swap.refund_by_keypath(&ctx, &[], &liquid_destination(), 1.0),
600            Err(SwapInError::KeypathRefundUnavailable)
601        ));
602    }
603
604    #[test]
605    fn a_liquid_swap_needs_a_context_set_up_for_liquid() {
606        let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
607        let bitcoin_only = SwapContext::new(wallet);
608        let swap = swap_in(&context(), Chain::Liquid, canceled());
609
610        assert!(matches!(
611            swap.address(&bitcoin_only),
612            Err(SwapInError::LiquidNotConfigured)
613        ));
614    }
615
616    #[test]
617    fn a_swap_trading_another_asset_is_refused() {
618        let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
619        let ctx = SwapContext::new(wallet)
620            .with_liquid(&genesis_hash(), &"11".repeat(32))
621            .unwrap();
622        let swap = swap_in(&ctx, Chain::Liquid, canceled());
623
624        assert!(matches!(
625            swap.address(&ctx),
626            Err(SwapInError::UnexpectedAsset { .. })
627        ));
628    }
629
630    #[test]
631    fn partition_drops_utxos_that_cannot_pay_their_own_fee() {
632        let ctx = context();
633        let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
634        let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000, 1_480]);
635
636        let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
637        // A Bitcoin CSV refund is 138 vB, so at 10 sat/vB an input must be
638        // worth more than its 1_380 sat fee and a 546 sat output.
639        let (economical, dust) = swap.partition_economical(&ctx, &utxos, 10.0).unwrap();
640
641        assert_eq!(economical.len(), 1);
642        assert_eq!(swap.value(&ctx, &economical[0]).unwrap(), 100_000);
643        assert_eq!(dust.len(), 1);
644        assert_eq!(swap.value(&ctx, &dust[0]).unwrap(), 1_480);
645    }
646
647    #[test]
648    fn partition_reports_a_bad_fee_rate_instead_of_calling_everything_dust() {
649        let ctx = context();
650        let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
651        let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
652        let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
653
654        for rate in [f64::NAN, -1.0] {
655            assert!(swap.partition_economical(&ctx, &utxos, rate).is_err());
656        }
657    }
658
659    #[test]
660    fn bitcoin_refunds_by_csv() {
661        let ctx = context();
662        let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
663        let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
664        let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
665
666        let tx_hex = swap
667            .refund_by_csv(
668                &ctx,
669                &utxos,
670                "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080",
671                1.0,
672            )
673            .unwrap();
674
675        let tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
676        assert_eq!(tx.input.len(), 1);
677        assert_eq!(tx.input[0].sequence.to_consensus_u32(), swap.csv);
678    }
679
680    #[test]
681    fn reads_each_spend_path_from_the_witness() {
682        let ctx = context();
683        let server = SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap();
684        for chain in [Chain::Bitcoin, Chain::Liquid] {
685            let swap = swap_in(&ctx, chain, canceled());
686            let script = swap.script(&ctx).unwrap();
687            let (opening_tx, destination) = match chain {
688                Chain::Bitcoin => (
689                    bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]),
690                    BITCOIN_DESTINATION.to_string(),
691                ),
692                Chain::Liquid => (
693                    confidential_tx(&liquid_script(&swap, &ctx), &[100_000]),
694                    liquid_destination(),
695                ),
696            };
697            let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
698            let preimage = ctx.wallet().preimage(&swap.swap_id).unwrap();
699            let path = |tx_hex: String| script.spend_path(&Tx::decode(chain, &tx_hex).unwrap(), 0);
700
701            let keypath = swap.refund_by_keypath(&ctx, &utxos, &destination, 1.0);
702            let refund = swap.refund_by_csv(&ctx, &utxos, &destination, 1.0);
703            let claim = script.claim_by_preimage(&utxos, server, preimage, &destination, 1.0);
704            assert_eq!(path(keypath.unwrap()), Some(SpendPath::Keypath));
705            assert_eq!(path(refund.unwrap()), Some(SpendPath::Csv));
706            assert_eq!(path(claim.unwrap()), Some(SpendPath::Preimage(preimage)));
707        }
708    }
709
710    #[test]
711    fn reads_the_spend_path_past_an_annex() {
712        let ctx = context();
713        let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
714        let script = swap.script(&ctx).unwrap();
715        let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
716        let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
717
718        let keypath = swap.refund_by_keypath(&ctx, &utxos, BITCOIN_DESTINATION, 1.0);
719        let refund = swap.refund_by_csv(&ctx, &utxos, BITCOIN_DESTINATION, 1.0);
720        for (tx_hex, expected) in [
721            (keypath.unwrap(), SpendPath::Keypath),
722            (refund.unwrap(), SpendPath::Csv),
723        ] {
724            let mut tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
725            tx.input[0].witness.push([0x50, 1]);
726            assert_eq!(script.spend_path(&Tx::Bitcoin(tx), 0), Some(expected));
727        }
728    }
729
730    #[test]
731    fn reads_no_spend_path_from_a_witness_the_script_does_not_open() {
732        let ctx = context();
733        let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
734        let script = swap.script(&ctx).unwrap();
735        let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
736        let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
737        let tx_hex = swap
738            .refund_by_keypath(&ctx, &utxos, BITCOIN_DESTINATION, 1.0)
739            .unwrap();
740
741        let mut tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
742        tx.input[0].witness.push([1]);
743        let tx = Tx::Bitcoin(tx);
744        assert_eq!(script.spend_path(&tx, 0), None);
745        assert_eq!(script.spend_path(&tx, 1), None);
746    }
747
748    /// The swap's Liquid script, for building the opening a counterparty funds.
749    fn liquid_script(swap: &SwapIn, ctx: &SwapContext<Wallet>) -> LiquidSwapScript {
750        swap.script(ctx).unwrap().liquid().unwrap().clone()
751    }
752
753    fn bitcoin_script(swap: &SwapIn, ctx: &SwapContext<Wallet>) -> BitcoinSwapScript {
754        swap.script(ctx).unwrap().bitcoin().unwrap().clone()
755    }
756
757    /// A transaction paying `values` to a Bitcoin swap script.
758    fn bitcoin_tx(script: &BitcoinSwapScript, values: &[u64]) -> String {
759        let tx = bitcoin::Transaction {
760            version: bitcoin::transaction::Version::TWO,
761            lock_time: bitcoin::absolute::LockTime::ZERO,
762            input: vec![],
763            output: values
764                .iter()
765                .map(|value| bitcoin::TxOut {
766                    value: bitcoin::Amount::from_sat(*value),
767                    script_pubkey: script.script_pubkey(),
768                })
769                .collect(),
770        };
771        anyswap_core::transaction::bitcoin::encode_tx_hex(&tx)
772    }
773}