pub use anyswap_core::types::{ErrorType, SwapInStatus as Status};
use anyswap_core::{
api::SwapState,
invoice::{self, InvoiceError},
transaction::TxError,
types::{Chain, Outpoint, SwapType},
utils::sha256_payment_hash,
};
use bitcoin::secp256k1::{PublicKey, SecretKey};
use thiserror::Error;
use uuid::Uuid;
use crate::{
codec::{self, CodecError},
context::SwapContext,
swap_script::{ScriptError, Side, SwapScript, Terms, Tx},
utxo::{SwapUtxo, UtxoError},
wallet::SwapWallet,
};
#[derive(Debug, Error)]
pub enum SwapInError {
#[error("expected swap type SwapIn, got {0}")]
UnexpectedSwapType(SwapType),
#[error("unknown status: {0}")]
UnknownStatus(String),
#[error("unknown error type: {0}")]
UnknownErrorType(String),
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("a keypath refund needs the claim key the server reveals when it cancels")]
KeypathRefundUnavailable,
#[error("this client is not set up for Liquid; add the Liquid parameters to the context")]
LiquidNotConfigured,
#[error("the swap trades asset {found}, but this client is set up for {expected}")]
UnexpectedAsset { expected: String, found: String },
#[error("wallet: {0}")]
Wallet(String),
#[error("invalid swap script: {0}")]
Script(String),
#[error("the output does not belong to this swap")]
UnknownOutput,
#[error("the server has not reported a preimage in this status")]
NoPreimage,
#[error(transparent)]
Invoice(#[from] InvoiceError),
#[error("the preimage hashes to {found}, but the claim invoice's payment hash is {expected}")]
UnexpectedPaymentHash { expected: String, found: String },
#[error(transparent)]
Utxo(#[from] UtxoError),
#[error(transparent)]
Tx(#[from] TxError),
#[error(transparent)]
Codec(#[from] CodecError),
}
impl From<ScriptError> for SwapInError {
fn from(e: ScriptError) -> Self {
match e {
ScriptError::LiquidNotConfigured => Self::LiquidNotConfigured,
ScriptError::UnexpectedAsset { expected, found } => {
Self::UnexpectedAsset { expected, found }
}
ScriptError::MissingField(field) => Self::MissingField(field),
ScriptError::Wallet(e) => Self::Wallet(e),
ScriptError::Script(e) => Self::Script(e),
}
}
}
#[derive(Debug, Clone)]
pub struct SwapIn {
pub swap_id: Uuid,
pub protocol_version: String,
pub chain: Chain,
pub asset: String,
pub receive_amount: u64,
pub send_amount: u64,
pub base_fee: u64,
pub service_fee: u64,
pub payment_hash: [u8; 32],
pub csv: u32,
pub refund_pubkey: PublicKey,
pub claim_pubkey: PublicKey,
pub user_blinding_pubkey: Option<PublicKey>,
pub service_blinding_pubkey: Option<PublicKey>,
pub claim_invoice: String,
pub refund_address: Option<String>,
pub created_at: u64,
pub transitioned_at: u64,
pub status: SwapInStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, strum::Display, strum::IntoStaticStr)]
pub enum SwapInStatus {
AwaitOpeningTxConfirmation,
PayInvoice { outpoint: Outpoint },
AwaitClaimKeyRevealMessage {
outpoint: Outpoint,
preimage: [u8; 32],
},
AwaitClaimTxConfirmation {
outpoint: Outpoint,
preimage: [u8; 32],
},
Claimed {
outpoint: Outpoint,
preimage: [u8; 32],
},
Canceled {
error_code: ErrorType,
claim_privkey: SecretKey,
cancel_message: String,
},
}
impl SwapInStatus {
pub fn outpoint(&self) -> Option<&Outpoint> {
match self {
Self::AwaitOpeningTxConfirmation | Self::Canceled { .. } => None,
Self::PayInvoice { outpoint }
| Self::AwaitClaimKeyRevealMessage { outpoint, .. }
| Self::AwaitClaimTxConfirmation { outpoint, .. }
| Self::Claimed { outpoint, .. } => Some(outpoint),
}
}
pub fn preimage(&self) -> Option<&[u8; 32]> {
match self {
Self::AwaitClaimKeyRevealMessage { preimage, .. }
| Self::AwaitClaimTxConfirmation { preimage, .. }
| Self::Claimed { preimage, .. } => Some(preimage),
_ => None,
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Claimed { .. } | Self::Canceled { .. })
}
}
impl SwapIn {
pub fn verify_payment(&self) -> Result<(), SwapInError> {
let expected = invoice::payment_hash(&self.claim_invoice)?;
let preimage = self.status.preimage().ok_or(SwapInError::NoPreimage)?;
let found = sha256_payment_hash(preimage);
if found != expected {
return Err(SwapInError::UnexpectedPaymentHash {
expected: hex::encode(expected),
found: hex::encode(found),
});
}
Ok(())
}
pub fn address<W: SwapWallet>(&self, ctx: &SwapContext<W>) -> Result<String, SwapInError> {
Ok(self.script(ctx)?.address())
}
pub fn outputs<W: SwapWallet>(
&self,
ctx: &SwapContext<W>,
tx_hex: &str,
) -> Result<Vec<SwapUtxo>, SwapInError> {
let tx = Tx::decode(self.chain, tx_hex)?;
Ok(self.script(ctx)?.outputs(&tx))
}
pub fn partition_economical<W: SwapWallet>(
&self,
ctx: &SwapContext<W>,
utxos: &[SwapUtxo],
fee_rate: f64,
) -> Result<(Vec<SwapUtxo>, Vec<SwapUtxo>), SwapInError> {
Ok(self.script(ctx)?.economical(utxos, fee_rate)?)
}
pub fn value<W: SwapWallet>(
&self,
ctx: &SwapContext<W>,
utxo: &SwapUtxo,
) -> Result<u64, SwapInError> {
self.script(ctx)?
.value(utxo)
.ok_or(SwapInError::UnknownOutput)
}
pub fn refund_by_keypath<W: SwapWallet>(
&self,
ctx: &SwapContext<W>,
utxos: &[SwapUtxo],
destination: &str,
fee_rate: f64,
) -> Result<String, SwapInError> {
let SwapInStatus::Canceled { claim_privkey, .. } = &self.status else {
return Err(SwapInError::KeypathRefundUnavailable);
};
let refund_privkey = self.refund_privkey(ctx)?;
Ok(self.script(ctx)?.keypath(
utxos,
*claim_privkey,
refund_privkey,
destination,
fee_rate,
)?)
}
pub fn refund_by_csv<W: SwapWallet>(
&self,
ctx: &SwapContext<W>,
utxos: &[SwapUtxo],
destination: &str,
fee_rate: f64,
) -> Result<String, SwapInError> {
let refund_privkey = self.refund_privkey(ctx)?;
Ok(self
.script(ctx)?
.refund_by_csv(utxos, refund_privkey, destination, fee_rate)?)
}
pub fn terms(&self) -> Terms {
Terms {
swap_id: self.swap_id,
side: Side::Source,
chain: self.chain,
asset: self.asset.clone(),
csv: self.csv,
payment_hash: self.payment_hash,
server_pubkey: self.claim_pubkey,
server_blinding_pubkey: self.service_blinding_pubkey,
}
}
fn script<W: SwapWallet>(&self, ctx: &SwapContext<W>) -> Result<SwapScript, SwapInError> {
Ok(ctx.script(&self.terms())?)
}
fn refund_privkey<W: SwapWallet>(
&self,
ctx: &SwapContext<W>,
) -> Result<SecretKey, SwapInError> {
ctx.wallet()
.source_refund_privkey(&self.swap_id)
.map_err(|error| SwapInError::Wallet(error.to_string()))
}
}
impl TryFrom<SwapState> for SwapIn {
type Error = SwapInError;
fn try_from(s: SwapState) -> Result<Self, SwapInError> {
if s.swap_type != SwapType::SwapIn {
return Err(SwapInError::UnexpectedSwapType(s.swap_type));
}
let status: Status = s
.status
.parse()
.map_err(|_| SwapInError::UnknownStatus(s.status.clone()))?;
let status = match status {
Status::AwaitOpeningTxConfirmation => SwapInStatus::AwaitOpeningTxConfirmation,
Status::PayInvoice => SwapInStatus::PayInvoice {
outpoint: source_outpoint(&s)?,
},
Status::AwaitClaimKeyRevealMessage => SwapInStatus::AwaitClaimKeyRevealMessage {
outpoint: source_outpoint(&s)?,
preimage: preimage(&s)?,
},
Status::AwaitClaimTxConfirmation => SwapInStatus::AwaitClaimTxConfirmation {
outpoint: source_outpoint(&s)?,
preimage: preimage(&s)?,
},
Status::Claimed => SwapInStatus::Claimed {
outpoint: source_outpoint(&s)?,
preimage: preimage(&s)?,
},
Status::Canceled => {
let raw = require(s.error_type.clone(), "error_type")?;
SwapInStatus::Canceled {
error_code: raw
.parse()
.map_err(|_| SwapInError::UnknownErrorType(raw.clone()))?,
claim_privkey: codec::parse_privkey(&require(
s.source_claim_privkey.clone(),
"source_claim_privkey",
)?)?,
cancel_message: require(s.cancel_message.clone(), "cancel_message")?,
}
}
};
let user_blinding_pubkey = s
.source_user_blinding_pubkey
.as_deref()
.map(codec::parse_pubkey)
.transpose()?;
let service_blinding_pubkey = s
.source_service_blinding_pubkey
.as_deref()
.map(codec::parse_pubkey)
.transpose()?;
Ok(SwapIn {
swap_id: s.swap_id,
protocol_version: s.protocol_version,
chain: require(s.source_chain, "source_chain")?,
asset: require(s.source_asset, "source_asset")?,
receive_amount: s.receive_amount,
send_amount: s.send_amount,
base_fee: s.base_fee,
service_fee: s.service_fee,
payment_hash: codec::parse_hash32(&s.payment_hash)?,
csv: require(s.source_csv, "source_csv")?,
refund_pubkey: codec::parse_pubkey(&require(
s.source_refund_pubkey,
"source_refund_pubkey",
)?)?,
claim_pubkey: codec::parse_pubkey(&require(
s.source_claim_pubkey,
"source_claim_pubkey",
)?)?,
user_blinding_pubkey,
service_blinding_pubkey,
claim_invoice: require(s.claim_invoice, "claim_invoice")?,
refund_address: s.user_refund_address,
created_at: s.created_at,
transitioned_at: s.transitioned_at,
status,
})
}
}
fn require<T>(opt: Option<T>, field: &'static str) -> Result<T, SwapInError> {
opt.ok_or(SwapInError::MissingField(field))
}
fn source_outpoint(s: &SwapState) -> Result<Outpoint, SwapInError> {
Ok(codec::parse_outpoint(
require(s.source_txid.as_deref(), "source_txid")?,
require(s.source_vout, "source_vout")?,
)?)
}
fn preimage(s: &SwapState) -> Result<[u8; 32], SwapInError> {
Ok(codec::parse_hash32(require(
s.preimage.as_deref(),
"preimage",
)?)?)
}
#[cfg(test)]
mod tests {
use anyswap_core::{
script::{BitcoinSwapScript, LiquidSwapScript},
secp,
types::{ErrorType, NATIVE_ASSET, SwapNetwork},
};
use bitcoin::hashes::{Hash as _, sha256};
use super::*;
use crate::{
swap_script::SpendPath,
utxo::fixtures::{confidential_tx, genesis_hash, liquid_asset, liquid_destination},
wallet::Wallet,
};
const SERVICE_SIGNING_KEY: [u8; 32] = [21; 32];
const SERVICE_BLINDING_KEY: [u8; 32] = [22; 32];
const BITCOIN_DESTINATION: &str = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080";
fn context() -> SwapContext<Wallet> {
let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
SwapContext::new(wallet)
.with_liquid(&genesis_hash(), &liquid_asset())
.unwrap()
}
fn swap_in(ctx: &SwapContext<Wallet>, chain: Chain, status: SwapInStatus) -> SwapIn {
let swap_id = Uuid::from_u128(11);
let service_signing = SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap();
let service_blinding = SecretKey::from_slice(&SERVICE_BLINDING_KEY).unwrap();
let preimage = ctx.wallet().preimage(&swap_id).unwrap();
SwapIn {
swap_id,
protocol_version: "2.0.0".into(),
chain,
asset: match chain {
Chain::Bitcoin => NATIVE_ASSET.to_string(),
Chain::Liquid => liquid_asset(),
},
receive_amount: 100_000,
send_amount: 101_000,
base_fee: 1_000,
service_fee: 0,
payment_hash: sha256::Hash::hash(&preimage).to_byte_array(),
csv: 144,
refund_pubkey: ctx.wallet().source_refund_pubkey(&swap_id).unwrap(),
claim_pubkey: service_signing.public_key(secp()),
user_blinding_pubkey: Some(ctx.wallet().source_blinding_pubkey(&swap_id).unwrap()),
service_blinding_pubkey: Some(service_blinding.public_key(secp())),
claim_invoice: "lnbcrt1".into(),
refund_address: None,
created_at: 1,
transitioned_at: 1,
status,
}
}
fn canceled() -> SwapInStatus {
SwapInStatus::Canceled {
error_code: ErrorType::UserCanceled,
claim_privkey: SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap(),
cancel_message: "canceled".into(),
}
}
#[test]
fn liquid_refunds_the_deposit_and_a_stray_together() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Liquid, canceled());
let opening_tx = confidential_tx(&liquid_script(&swap, &ctx), &[101_000, 40_000]);
let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
assert_eq!(utxos.len(), 2);
assert_eq!(swap.value(&ctx, &utxos[0]).unwrap(), 101_000);
assert_eq!(swap.value(&ctx, &utxos[1]).unwrap(), 40_000);
let destination = liquid_destination();
for tx_hex in [
swap.refund_by_csv(&ctx, &utxos, &destination, 1.0).unwrap(),
swap.refund_by_keypath(&ctx, &utxos, &destination, 1.0)
.unwrap(),
] {
let tx = anyswap_core::transaction::liquid::decode_tx_hex(&tx_hex).unwrap();
assert_eq!(tx.input.len(), 2);
}
}
#[test]
fn liquid_skips_outputs_of_another_swap() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Liquid, canceled());
let other = SwapIn {
csv: 288,
..swap_in(&ctx, Chain::Liquid, canceled())
};
let other_tx = confidential_tx(&liquid_script(&other, &ctx), &[101_000]);
assert!(swap.outputs(&ctx, &other_tx).unwrap().is_empty());
}
#[test]
fn another_swaps_output_has_no_value_here() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Liquid, canceled());
let other = SwapIn {
swap_id: Uuid::from_u128(99),
..swap_in(&ctx, Chain::Liquid, canceled())
};
let other_tx = confidential_tx(&liquid_script(&other, &ctx), &[101_000]);
let theirs = other.outputs(&ctx, &other_tx).unwrap().remove(0);
assert!(matches!(
swap.value(&ctx, &theirs),
Err(SwapInError::UnknownOutput)
));
}
#[test]
fn refunds_a_utxo_the_caller_supplied() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
let txid = anyswap_core::transaction::bitcoin::decode_tx_hex(&opening_tx)
.unwrap()
.compute_txid()
.to_string();
let utxos = vec![SwapUtxo::bitcoin(txid, 0, 100_000)];
let tx_hex = swap
.refund_by_csv(
&ctx,
&utxos,
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080",
1.0,
)
.unwrap();
let tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
assert_eq!(tx.input.len(), 1);
assert_eq!(swap.value(&ctx, &utxos[0]).unwrap(), 100_000);
}
#[test]
fn a_keypath_refund_needs_the_revealed_claim_key() {
let ctx = context();
let swap = swap_in(
&ctx,
Chain::Liquid,
SwapInStatus::AwaitOpeningTxConfirmation,
);
assert!(matches!(
swap.refund_by_keypath(&ctx, &[], &liquid_destination(), 1.0),
Err(SwapInError::KeypathRefundUnavailable)
));
}
#[test]
fn a_liquid_swap_needs_a_context_set_up_for_liquid() {
let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
let bitcoin_only = SwapContext::new(wallet);
let swap = swap_in(&context(), Chain::Liquid, canceled());
assert!(matches!(
swap.address(&bitcoin_only),
Err(SwapInError::LiquidNotConfigured)
));
}
#[test]
fn a_swap_trading_another_asset_is_refused() {
let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
let ctx = SwapContext::new(wallet)
.with_liquid(&genesis_hash(), &"11".repeat(32))
.unwrap();
let swap = swap_in(&ctx, Chain::Liquid, canceled());
assert!(matches!(
swap.address(&ctx),
Err(SwapInError::UnexpectedAsset { .. })
));
}
#[test]
fn partition_drops_utxos_that_cannot_pay_their_own_fee() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000, 500]);
let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
let (economical, dust) = swap.partition_economical(&ctx, &utxos, 10.0).unwrap();
assert_eq!(economical.len(), 1);
assert_eq!(swap.value(&ctx, &economical[0]).unwrap(), 100_000);
assert_eq!(dust.len(), 1);
assert_eq!(swap.value(&ctx, &dust[0]).unwrap(), 500);
}
#[test]
fn partition_reports_a_bad_fee_rate_instead_of_calling_everything_dust() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
for rate in [f64::NAN, -1.0] {
assert!(swap.partition_economical(&ctx, &utxos, rate).is_err());
}
}
#[test]
fn bitcoin_refunds_by_csv() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
let tx_hex = swap
.refund_by_csv(
&ctx,
&utxos,
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080",
1.0,
)
.unwrap();
let tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
assert_eq!(tx.input.len(), 1);
assert_eq!(tx.input[0].sequence.to_consensus_u32(), swap.csv);
}
#[test]
fn reads_each_spend_path_from_the_witness() {
let ctx = context();
let server = SecretKey::from_slice(&SERVICE_SIGNING_KEY).unwrap();
for chain in [Chain::Bitcoin, Chain::Liquid] {
let swap = swap_in(&ctx, chain, canceled());
let script = swap.script(&ctx).unwrap();
let (opening_tx, destination) = match chain {
Chain::Bitcoin => (
bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]),
BITCOIN_DESTINATION.to_string(),
),
Chain::Liquid => (
confidential_tx(&liquid_script(&swap, &ctx), &[100_000]),
liquid_destination(),
),
};
let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
let preimage = ctx.wallet().preimage(&swap.swap_id).unwrap();
let path = |tx_hex: String| script.spend_path(&Tx::decode(chain, &tx_hex).unwrap(), 0);
let keypath = swap.refund_by_keypath(&ctx, &utxos, &destination, 1.0);
let refund = swap.refund_by_csv(&ctx, &utxos, &destination, 1.0);
let claim = script.claim_by_preimage(&utxos, server, preimage, &destination, 1.0);
assert_eq!(path(keypath.unwrap()), Some(SpendPath::Keypath));
assert_eq!(path(refund.unwrap()), Some(SpendPath::Csv));
assert_eq!(path(claim.unwrap()), Some(SpendPath::Preimage(preimage)));
}
}
#[test]
fn reads_the_spend_path_past_an_annex() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
let script = swap.script(&ctx).unwrap();
let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
let keypath = swap.refund_by_keypath(&ctx, &utxos, BITCOIN_DESTINATION, 1.0);
let refund = swap.refund_by_csv(&ctx, &utxos, BITCOIN_DESTINATION, 1.0);
for (tx_hex, expected) in [
(keypath.unwrap(), SpendPath::Keypath),
(refund.unwrap(), SpendPath::Csv),
] {
let mut tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
tx.input[0].witness.push([0x50, 1]);
assert_eq!(script.spend_path(&Tx::Bitcoin(tx), 0), Some(expected));
}
}
#[test]
fn reads_no_spend_path_from_a_witness_the_script_does_not_open() {
let ctx = context();
let swap = swap_in(&ctx, Chain::Bitcoin, canceled());
let script = swap.script(&ctx).unwrap();
let opening_tx = bitcoin_tx(&bitcoin_script(&swap, &ctx), &[100_000]);
let utxos = swap.outputs(&ctx, &opening_tx).unwrap();
let tx_hex = swap
.refund_by_keypath(&ctx, &utxos, BITCOIN_DESTINATION, 1.0)
.unwrap();
let mut tx = anyswap_core::transaction::bitcoin::decode_tx_hex(&tx_hex).unwrap();
tx.input[0].witness.push([1]);
let tx = Tx::Bitcoin(tx);
assert_eq!(script.spend_path(&tx, 0), None);
assert_eq!(script.spend_path(&tx, 1), None);
}
fn liquid_script(swap: &SwapIn, ctx: &SwapContext<Wallet>) -> LiquidSwapScript {
swap.script(ctx).unwrap().liquid().unwrap().clone()
}
fn bitcoin_script(swap: &SwapIn, ctx: &SwapContext<Wallet>) -> BitcoinSwapScript {
swap.script(ctx).unwrap().bitcoin().unwrap().clone()
}
fn bitcoin_tx(script: &BitcoinSwapScript, values: &[u64]) -> String {
let tx = bitcoin::Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: vec![],
output: values
.iter()
.map(|value| bitcoin::TxOut {
value: bitcoin::Amount::from_sat(*value),
script_pubkey: script.script_pubkey(),
})
.collect(),
};
anyswap_core::transaction::bitcoin::encode_tx_hex(&tx)
}
}