use std::{cmp::Reverse, collections::HashMap};
use anyswap_core::{
api::SwapState,
secp,
transaction::TxError,
types::{Outpoint, SwapType},
utils::sha256_payment_hash,
};
use bitcoin::secp256k1::SecretKey;
use super::{
Registered,
config::Config,
error::DriverError,
record::{FundState, Height, ServerCancel, SwapRecord, Timestamp},
rt::bounded,
server::ServerClient,
};
use crate::{
swap_in::{SwapIn, SwapInStatus},
swap_out::{SwapOut, SwapOutStatus},
swap_script::{Side, SpendPath, SwapScript, Terms, Tx},
utxo::{self, SwapUtxo},
};
pub(crate) const FUNDS_PER_PASS: usize = 8;
#[derive(Clone, Debug)]
pub struct Observed {
pub now: Timestamp,
pub tip: Height,
pub fee_rate: f64,
pub outputs: Vec<Output>,
pub server: Option<Server>,
}
#[derive(Clone, Debug)]
pub struct Output {
pub utxo: SwapUtxo,
pub value: u64,
pub height: Option<Height>,
pub spend: Option<Spender>,
}
#[derive(Clone, Debug)]
pub struct Spender {
pub txid: String,
pub height: Option<Height>,
pub path: SpendPath,
pub fee: Option<u64>,
}
#[derive(Clone, Debug)]
pub struct Server {
pub label: String,
pub cancel: Option<ServerCancel>,
pub status: Option<Report>,
pub key: Option<SecretKey>,
pub preimage: Option<[u8; 32]>,
}
#[derive(Clone, Debug)]
pub enum Report {
In(SwapInStatus),
Out(SwapOutStatus),
}
impl Observed {
pub fn confs(&self, height: Option<Height>) -> u32 {
confs(self.tip, height)
}
pub fn split(&self, amount: u64) -> (Option<&Output>, Vec<&Output>) {
let opening = opening(&self.outputs, amount);
let opening_outpoint = opening.map(|opening| opening.utxo.outpoint());
let strays = self
.outputs
.iter()
.filter(|output| Some(output.utxo.outpoint()) != opening_outpoint)
.collect();
(opening, strays)
}
pub fn server_privkey(&self) -> Option<SecretKey> {
self.server.as_ref()?.key
}
pub fn preimage(&self) -> Option<[u8; 32]> {
self.server
.as_ref()
.and_then(|server| server.preimage)
.or_else(|| {
self.outputs
.iter()
.find_map(|output| match output.spend.as_ref()?.path {
SpendPath::Preimage(preimage) => Some(preimage),
SpendPath::Keypath | SpendPath::Csv => None,
})
})
}
}
pub(crate) fn driven(record: &SwapRecord) -> Result<Terms, DriverError> {
match record.swap_type()? {
SwapType::SwapIn => record.terms(Side::Source),
SwapType::SwapOut => record.terms(Side::Dest),
other => Err(DriverError::Unsupported(other)),
}
}
pub(crate) async fn observe(
record: &SwapRecord,
terms: &Terms,
script: &SwapScript,
chain: &Registered,
client: &dyn ServerClient,
config: &Config,
) -> Result<Observed, DriverError> {
let tip = chain.client.height().await?;
let fee_rate = chain
.fee_rate(config.fee_conf_target, config.fee_rate_cache_ttl)
.await?;
let history = chain.client.address_history(&script.address()).await?;
let txs = history
.into_iter()
.map(|entry| Tx::decode(terms.chain, &entry.hex).map(|tx| (entry, tx)))
.collect::<Result<Vec<_>, _>>()?;
let mut outputs: Vec<Output> = txs
.iter()
.flat_map(|(entry, tx)| {
script.outputs(tx).into_iter().map(|utxo| Output {
value: script.value(&utxo).unwrap_or_default(),
utxo,
height: entry.height,
spend: None,
})
})
.collect();
let values: HashMap<Outpoint, u64> = outputs
.iter()
.map(|output| (output.utxo.outpoint(), output.value))
.collect();
for (entry, tx) in &txs {
for (vin, prev) in tx.inputs().into_iter().enumerate() {
if let Some(output) = outputs
.iter_mut()
.find(|output| output.utxo.outpoint() == prev)
{
output.spend = Some(spender(script, tx, vin as u32, entry.height, &values)?);
}
}
}
let amount = match terms.side {
Side::Source => record.send_amount,
Side::Dest => record.receive_amount,
};
let opening = opening(&outputs, amount).map(|output| output.utxo.outpoint());
let refund_fee = utxo::refund_fee(terms.chain, fee_rate.min(chain.config.max_fee_rate));
let (openings, mut strays): (Vec<_>, Vec<_>) = outputs
.iter_mut()
.filter(|output| output.spend.is_none())
.partition(|output| opening == Some(output.utxo.outpoint()));
strays.retain(|output| stray_read(record, terms, tip, refund_fee, output));
strays.sort_by_key(|output| (!refunding(record, output), Reverse(output.value)));
strays.truncate(FUNDS_PER_PASS);
for output in openings {
output.spend = spend_of(terms, script, chain, output, &values).await?;
}
for output in strays {
if let Ok(spend) = spend_of(terms, script, chain, output, &values).await {
output.spend = spend;
}
}
outputs.sort_by(|a, b| {
let key = |output: &Output| {
(
output.height.unwrap_or(Height::MAX),
output.utxo.txid.clone(),
output.utxo.vout,
)
};
key(a).cmp(&key(b))
});
let server = bounded(config.server_timeout, client.get_swap(record.swap_id))
.await
.ok()
.map(|state| poll(terms, state));
Ok(Observed {
now: anyswap_core::utils::now(),
tip,
fee_rate,
outputs,
server,
})
}
fn opening(outputs: &[Output], amount: u64) -> Option<&Output> {
outputs
.iter()
.filter(|output| output.value == amount)
.min_by_key(|output| {
let height = output.height.unwrap_or(Height::MAX);
(height, output.utxo.txid.clone(), output.utxo.vout)
})
}
fn confs(tip: Height, height: Option<Height>) -> u32 {
height.map_or(0, |height| tip.saturating_sub(height) + 1)
}
fn stray_read(
record: &SwapRecord,
terms: &Terms,
tip: Height,
refund_fee: Option<u64>,
output: &Output,
) -> bool {
let refundable = refund_fee.is_some_and(|fee| output.value > fee)
&& !record.locked(terms.csv, confs(tip, output.height));
terms.side == Side::Source && (refundable || refunding(record, output))
}
fn refunding(record: &SwapRecord, output: &Output) -> bool {
record.funds.iter().any(|fund| {
fund.outpoint == output.utxo.outpoint() && matches!(fund.state, FundState::Refunding { .. })
})
}
async fn spend_of(
terms: &Terms,
script: &SwapScript,
chain: &Registered,
output: &Output,
values: &HashMap<Outpoint, u64>,
) -> Result<Option<Spender>, DriverError> {
let outpoint = output.utxo.outpoint();
let Some(entry) = chain.client.outspend(&outpoint.txid, outpoint.vout).await? else {
return Ok(None);
};
let tx = Tx::decode(terms.chain, &entry.hex)?;
let Some(vin) = tx.inputs().iter().position(|prev| *prev == outpoint) else {
return Ok(None);
};
Ok(Some(spender(
script,
&tx,
vin as u32,
entry.height,
values,
)?))
}
fn spender(
script: &SwapScript,
tx: &Tx,
vin: u32,
height: Option<Height>,
values: &HashMap<Outpoint, u64>,
) -> Result<Spender, TxError> {
let path = script.spend_path(tx, vin).ok_or_else(|| {
TxError::Decode(format!("input {vin} of {} opens no swap path", tx.txid()))
})?;
Ok(Spender {
txid: tx.txid(),
height,
path,
fee: tx.fee(|prev| values.get(prev).copied()),
})
}
pub(crate) fn poll(terms: &Terms, state: SwapState) -> Server {
let key = match terms.side {
Side::Source => state.source_claim_privkey.as_deref(),
Side::Dest => state.dest_refund_privkey.as_deref(),
}
.and_then(|hex| crate::codec::parse_privkey(hex).ok())
.filter(|key| key.public_key(secp()) == terms.server_pubkey);
let preimage = match terms.side {
Side::Source => state.preimage.as_deref(),
Side::Dest => None,
}
.and_then(|hex| crate::codec::parse_hash32(hex).ok())
.filter(|preimage| sha256_payment_hash(preimage) == terms.payment_hash);
let label = clip(state.status.clone(), 64);
let error_type = state.error_type.clone();
let cancel_message = state.cancel_message.clone().map(|text| clip(text, 256));
let status = match terms.side {
Side::Source => SwapIn::try_from(state)
.ok()
.map(|swap| Report::In(swap.status)),
Side::Dest => SwapOut::try_from(state)
.ok()
.map(|swap| Report::Out(swap.status)),
};
let canceled = matches!(
status,
Some(
Report::In(SwapInStatus::Canceled { .. }) | Report::Out(SwapOutStatus::Canceled { .. })
)
);
let cancel = canceled.then_some(ServerCancel {
error_type,
cancel_message,
});
Server {
label,
cancel,
status,
key,
preimage,
}
}
fn clip(mut text: String, max: usize) -> String {
text.truncate(text.floor_char_boundary(max));
text
}