use bitcoin::consensus::encode::serialize_hex;
use bitcoin::transaction::Version;
use bitcoin::{
absolute::LockTime, Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
};
use sidestr_core::address::{decode_address, script_to_address};
use sidestr_core::document::ChainDocument;
use sidestr_core::sighash::{key_path_sighash, rules_for, verify_taproot_key_path, SighashRules};
use crate::coins::{mature, Coin};
use crate::error::{Error, Result};
use crate::key::SpendSigner;
use crate::policy::{Intent, IntentKind, SpendPolicy};
use crate::select::{fee_bound, select};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub script: ScriptBuf,
pub note: Option<String>,
}
pub fn resolve_to(to: &str, hrp: &str) -> Result<Resolved> {
let to = to.trim();
if !to.is_empty() && to.len() % 2 == 0 && to.bytes().all(|b| b.is_ascii_hexdigit()) {
let script = ScriptBuf::from_hex(&to.to_ascii_lowercase())
.map_err(|_| Error::BadDestination(to.to_string()))?;
return Ok(Resolved { script, note: None });
}
let a = decode_address(to).ok_or_else(|| Error::BadDestination(to.to_string()))?;
let note = (a.hrp != hrp.to_ascii_lowercase()).then(|| {
format!(
"{}… carries prefix '{}', this chain's is '{hrp}' ({}); paying its script",
to.chars().take(12).collect::<String>(),
a.hrp,
script_to_address(&a.script, hrp).unwrap_or_default()
)
});
Ok(Resolved {
script: a.script,
note,
})
}
#[derive(Debug, Clone, Copy)]
pub struct SpendRequest<'a> {
pub chain: &'a ChainDocument,
pub coins: &'a [Coin],
pub tip_height: u32,
pub to: &'a str,
pub amount: u64,
pub fee: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spend {
pub tx: Transaction,
pub hex: String,
pub txid: Txid,
pub inputs: usize,
pub amount: u64,
pub fee: u64,
pub vsize: u64,
pub change: u64,
pub note: Option<String>,
}
pub fn build_spend(
req: &SpendRequest<'_>,
signer: &dyn SpendSigner,
policy: &dyn SpendPolicy,
) -> Result<Spend> {
let dest = resolve_to(req.to, &req.chain.address_prefix)?;
assemble(
req.chain,
req.coins,
req.tip_height,
req.amount,
req.fee,
signer,
policy,
Plan {
kind: IntentKind::Spend,
output_script: dest.script.clone(),
intent_script: dest.script,
note: dest.note,
},
)
}
pub(crate) struct Plan {
pub kind: IntentKind,
pub output_script: ScriptBuf,
pub intent_script: ScriptBuf,
pub note: Option<String>,
}
pub fn dust_threshold(script: &ScriptBuf) -> u64 {
TxOut::minimal_non_dust(script.clone()).value.to_sat()
}
pub(crate) fn sign_inputs(
tx: &mut Transaction,
prevouts: &[TxOut],
rules: SighashRules,
signer: &dyn SpendSigner,
) -> Result<()> {
if signer.signs_elsewhere() {
let placeholder = Witness::from_slice(&[[0u8; 65]]);
for i in &mut tx.input {
i.witness = placeholder.clone();
}
return Ok(());
}
for i in 0..tx.input.len() {
let (digest, hash_type) = key_path_sighash(tx, i, prevouts, rules)
.map_err(|e| Error::Signer(format!("sighash: {e}")))?;
let sig = signer.sign_key_path(&digest)?;
let mut item = sig.serialize().to_vec();
item.push(hash_type);
tx.input[i].witness = Witness::from_slice(&[item]);
}
for i in 0..tx.input.len() {
verify_taproot_key_path(tx, i, prevouts, rules)
.map_err(|e| Error::Signer(format!("input {i} does not verify after signing: {e}")))?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn assemble(
chain: &ChainDocument,
coins: &[Coin],
tip_height: u32,
amount: u64,
fee: Option<u64>,
signer: &dyn SpendSigner,
policy: &dyn SpendPolicy,
plan: Plan,
) -> Result<Spend> {
if amount == 0 {
return Err(Error::BadAmount);
}
let dust = dust_threshold(&plan.output_script);
if amount < dust {
return Err(Error::Dust {
value: amount,
min: dust,
script: plan.output_script.to_hex_string(),
});
}
let rules = rules_for(chain.parent()?.family);
let me = signer.script();
let rate = chain.min_fee_rate;
let picked = select(
&mature(coins, tip_height),
amount,
fee.unwrap_or(fee_bound(rate)),
)?;
let sum = picked.sum;
let inputs: Vec<TxIn> = picked
.picked
.iter()
.map(|c| TxIn {
previous_output: c.outpoint,
script_sig: ScriptBuf::new(),
sequence: Sequence(0xffff_fffd),
witness: Witness::new(),
})
.collect();
let change_dust = dust_threshold(&me);
let layout = |f: u64| -> Result<(Vec<TxOut>, u64, u64)> {
let change = sum
.checked_sub(amount)
.and_then(|r| r.checked_sub(f))
.ok_or(Error::InsufficientForFee { amount, fee: f })?;
let mut out = vec![TxOut {
value: Amount::from_sat(amount),
script_pubkey: plan.output_script.clone(),
}];
if change > 0 && change >= change_dust {
out.push(TxOut {
value: Amount::from_sat(change),
script_pubkey: me.clone(),
});
Ok((out, change, f))
} else {
Ok((out, 0, f + change))
}
};
let mut tx = Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: inputs,
output: layout(fee.unwrap_or(0))?.0,
};
let placeholder = Witness::from_slice(&[[0u8; 65]]);
for i in &mut tx.input {
i.witness = placeholder.clone();
}
let vsize = tx.weight().to_wu().div_ceil(4);
let min_fee = vsize * rate;
let (outputs, change, fee) = match fee {
None => layout(min_fee)?,
Some(f) if f < min_fee => {
return Err(Error::FeeBelowMinimum {
fee: f,
min: min_fee,
vsize,
rate,
})
}
Some(f) => layout(f)?,
};
tx.output = outputs;
policy
.permit(&Intent {
chain_id: &chain.id,
kind: plan.kind,
script: &plan.intent_script,
amount,
fee,
inputs: tx.input.len(),
})
.map_err(Error::Policy)?;
let prevouts: Vec<TxOut> = picked
.picked
.iter()
.map(|c| TxOut {
value: Amount::from_sat(c.value),
script_pubkey: me.clone(),
})
.collect();
sign_inputs(&mut tx, &prevouts, rules, signer)?;
let vsize = tx.weight().to_wu().div_ceil(4);
Ok(Spend {
hex: serialize_hex(&tx),
txid: tx.compute_txid(),
inputs: tx.input.len(),
amount,
fee,
vsize,
change,
note: plan.note,
tx,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_scripts_and_addresses() {
let r = resolve_to(&format!("5120{}", "AB".repeat(32)), "trl").unwrap();
assert_eq!(r.script.to_hex_string(), format!("5120{}", "ab".repeat(32)));
assert!(r.note.is_none());
let tb = "tb1pvts4e2zcrujj9zey3kadyfgh2xs93v8va8ae9ldhukpxy2n3848qyqurhc";
let r = resolve_to(tb, "trl").unwrap();
assert!(r.note.unwrap().contains("carries prefix 'tb'"));
assert!(resolve_to(tb, "tb").unwrap().note.is_none());
assert!(matches!(
resolve_to("trl1nope", "trl"),
Err(Error::BadDestination(_))
));
assert!(matches!(
resolve_to("abc", "trl"),
Err(Error::BadDestination(_))
));
assert_eq!(
dust_threshold(&ScriptBuf::from_hex(&format!("5120{}", "ab".repeat(32))).unwrap()),
330
);
assert_eq!(
dust_threshold(&ScriptBuf::from_hex("6a0461626364").unwrap()),
0
);
}
}