1pub 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#[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 pub claim_pubkey: PublicKey,
114 pub refund_pubkey: PublicKey,
116 pub user_blinding_pubkey: Option<PublicKey>,
118 pub service_blinding_pubkey: Option<PublicKey>,
120 pub claim_address: String,
122 pub claim_invoice: String,
124 pub created_at: u64,
125 pub transitioned_at: u64,
126 pub status: SwapOutStatus,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, strum::Display, strum::IntoStaticStr)]
141pub enum SwapOutStatus {
142 AwaitClaimInvoicePay,
144 OpeningTxBroadcast,
148 AwaitPreimageRevealed { outpoint: Outpoint },
155 AwaitRefundTxConfirmation { outpoint: Outpoint },
159 Claimed {
163 outpoint: Outpoint,
164 refund_privkey: SecretKey,
165 },
166 Canceled {
170 error_code: ErrorType,
171 cancel_message: String,
172 },
173 Refunded { outpoint: Outpoint },
176}
177
178impl SwapOutStatus {
179 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
199impl SwapOut {
204 pub fn address<W: SwapWallet>(&self, ctx: &SwapContext<W>) -> Result<String, SwapOutError> {
206 Ok(self.script(ctx)?.address())
207 }
208
209 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 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 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 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 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 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 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 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}