use crate::anchor_output;
use crate::script::extract_checksig_pubkeys;
use crate::server;
use crate::BoardingOutput;
use crate::Error;
use crate::ErrorContext;
use crate::VTXO_CONDITION_KEY;
use crate::VTXO_INPUT_INDEX;
use bitcoin::absolute::LockTime;
use bitcoin::consensus::Decodable;
use bitcoin::hashes::Hash;
use bitcoin::hex::DisplayHex;
use bitcoin::key::Secp256k1;
use bitcoin::psbt;
use bitcoin::secp256k1;
use bitcoin::secp256k1::schnorr;
use bitcoin::sighash::Prevouts;
use bitcoin::sighash::SighashCache;
use bitcoin::taproot;
use bitcoin::transaction;
use bitcoin::Address;
use bitcoin::Amount;
use bitcoin::OutPoint;
use bitcoin::Psbt;
use bitcoin::ScriptBuf;
use bitcoin::Sequence;
use bitcoin::TapLeafHash;
use bitcoin::TapSighashType;
use bitcoin::Transaction;
use bitcoin::TxIn;
use bitcoin::TxOut;
use bitcoin::Txid;
use bitcoin::VarInt;
use bitcoin::Weight;
use bitcoin::Witness;
use bitcoin::XOnlyPublicKey;
use std::collections::HashMap;
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct OnChainInput {
boarding_output: BoardingOutput,
amount: Amount,
outpoint: OutPoint,
}
impl OnChainInput {
pub fn new(boarding_output: BoardingOutput, amount: Amount, outpoint: OutPoint) -> Self {
Self {
boarding_output,
amount,
outpoint,
}
}
pub fn previous_output(&self) -> TxOut {
TxOut {
value: self.amount,
script_pubkey: self.boarding_output.script_pubkey(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct VtxoInput {
outpoint: OutPoint,
sequence: Sequence,
witness_utxo: TxOut,
spend_info: (ScriptBuf, taproot::ControlBlock),
}
impl VtxoInput {
pub fn new(
outpoint: OutPoint,
sequence: Sequence,
witness_utxo: TxOut,
spend_info: (ScriptBuf, taproot::ControlBlock),
) -> Self {
Self {
outpoint,
sequence,
witness_utxo,
spend_info,
}
}
pub fn previous_output(&self) -> TxOut {
self.witness_utxo.clone()
}
}
pub fn create_unilateral_exit_transaction<S>(
to_address: Address,
to_amount: Amount,
change_address: Address,
onchain_inputs: &[OnChainInput],
vtxo_inputs: &[VtxoInput],
sign_fn: S,
) -> Result<Transaction, Error>
where
S: Fn(
&mut psbt::Input,
secp256k1::Message,
) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, Error>,
{
if onchain_inputs.is_empty() && vtxo_inputs.is_empty() {
return Err(Error::transaction(
"cannot create transaction without inputs",
));
}
let secp = Secp256k1::new();
let mut output = vec![TxOut {
value: to_amount,
script_pubkey: to_address.script_pubkey(),
}];
let total_amount: Amount = onchain_inputs
.iter()
.map(|o| o.amount)
.chain(vtxo_inputs.iter().map(|v| v.witness_utxo.value))
.sum();
let change_amount = total_amount.checked_sub(to_amount).ok_or_else(|| {
Error::transaction(format!(
"cannot cover to_amount ({to_amount}) with total input amount ({total_amount})"
))
})?;
if change_amount > Amount::ZERO {
output.push(TxOut {
value: change_amount,
script_pubkey: change_address.script_pubkey(),
});
}
let input = {
let onchain_inputs = onchain_inputs.iter().map(|o| TxIn {
previous_output: o.outpoint,
sequence: o.boarding_output.exit_delay(),
..Default::default()
});
let vtxo_inputs = vtxo_inputs.iter().map(|v| TxIn {
previous_output: v.outpoint,
sequence: v.sequence,
..Default::default()
});
onchain_inputs.chain(vtxo_inputs).collect::<Vec<_>>()
};
let mut psbt = Psbt::from_unsigned_tx(Transaction {
version: transaction::Version::TWO,
lock_time: LockTime::ZERO,
input,
output,
})
.map_err(Error::transaction)?;
for (i, input) in psbt.inputs.iter_mut().enumerate() {
let outpoint = psbt.unsigned_tx.input[i].previous_output;
for onchain_input in onchain_inputs {
if onchain_input.outpoint == outpoint {
input.witness_utxo = Some(TxOut {
value: onchain_input.amount,
script_pubkey: onchain_input.boarding_output.address().script_pubkey(),
});
let (script, cb) = onchain_input.boarding_output.exit_spend_info();
let leaf_version = cb.leaf_version;
input.tap_scripts.insert(cb, (script, leaf_version));
}
}
for vtxo_input in vtxo_inputs.iter() {
if vtxo_input.outpoint == outpoint {
input.witness_utxo = Some(TxOut {
value: vtxo_input.witness_utxo.value,
script_pubkey: vtxo_input.witness_utxo.script_pubkey.clone(),
});
let (script, cb) = vtxo_input.spend_info.clone();
let leaf_version = cb.leaf_version;
input.tap_scripts.insert(cb, (script, leaf_version));
}
}
}
let prevouts = psbt
.inputs
.iter()
.filter_map(|i| i.witness_utxo.clone())
.collect::<Vec<_>>();
for (i, input) in psbt.inputs.iter_mut().enumerate() {
let (exit_control_block, (exit_script, leaf_version)) = input
.tap_scripts
.pop_first()
.ok_or_else(|| Error::ad_hoc(format!("no exit script found for input {i}")))?;
input.witness_script = Some(exit_script.clone());
let leaf_hash = TapLeafHash::from_script(&exit_script, leaf_version);
let tap_sighash = SighashCache::new(&psbt.unsigned_tx)
.taproot_script_spend_signature_hash(
i,
&Prevouts::All(&prevouts),
leaf_hash,
TapSighashType::Default,
)
.map_err(Error::crypto)?;
let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
let sigs = sign_fn(input, msg)?;
let mut witness = Vec::new();
for (sig, pk) in sigs.iter() {
secp.verify_schnorr(sig, &msg, pk)
.map_err(Error::crypto)
.with_context(|| format!("failed to verify own signature for input {i}"))?;
witness.push(&sig[..]);
}
witness.push(exit_script.as_bytes());
let control_block = exit_control_block.serialize();
witness.push(control_block.as_slice());
let witness = Witness::from_slice(&witness);
input.final_script_witness = Some(witness);
}
let tx = psbt.clone().extract_tx().map_err(Error::transaction)?;
tracing::debug!(
?onchain_inputs,
?vtxo_inputs,
raw_tx = %bitcoin::consensus::serialize(&tx).as_hex(),
"Built transaction sending inputs to on-chain address"
);
Ok(tx)
}
pub fn build_unilateral_exit_tree_txids(
vtxo_chains: &server::VtxoChains,
ark_txid: Txid,
) -> Result<Vec<Vec<Txid>>, Error> {
let chain_map = vtxo_chains
.inner
.iter()
.map(|vtxo_chain| (vtxo_chain.txid, vtxo_chain))
.collect::<HashMap<_, _>>();
fn visit_virtual_ancestors(
current_txid: Txid,
chain_map: &HashMap<Txid, &server::VtxoChain>,
visiting: &mut HashSet<Txid>,
visited: &mut HashSet<Txid>,
sorted: &mut Vec<Txid>,
) -> Result<bool, Error> {
if visited.contains(¤t_txid) {
return Ok(true);
}
if !visiting.insert(current_txid) {
return Err(Error::ad_hoc("chain traversal led to cycle"));
}
let chain = chain_map.get(¤t_txid).ok_or_else(|| {
Error::ad_hoc(format!("could not find VtxoChain for TXID: {current_txid}"))
})?;
if chain.spends.is_empty() {
return Err(Error::ad_hoc(format!(
"dead end reached at TXID {current_txid} with no commitment transaction"
)));
}
let mut reached_commitment = false;
for &parent_txid in &chain.spends {
let parent_chain = chain_map.get(&parent_txid).ok_or_else(|| {
Error::ad_hoc(format!(
"could not find VtxoChain for parent TXID: {parent_txid}",
))
})?;
match parent_chain.tx_type {
server::ChainedTxType::Commitment => {
reached_commitment = true;
}
server::ChainedTxType::Ark
| server::ChainedTxType::Checkpoint
| server::ChainedTxType::Tree => {
reached_commitment |=
visit_virtual_ancestors(parent_txid, chain_map, visiting, visited, sorted)?;
}
server::ChainedTxType::Unspecified => {
tracing::warn!(
txid = %parent_txid,
"Found unspecified TX type when walking up virtual TX tree. \
Treating it like a virtual TX"
);
reached_commitment |=
visit_virtual_ancestors(parent_txid, chain_map, visiting, visited, sorted)?;
}
}
}
visiting.remove(¤t_txid);
visited.insert(current_txid);
sorted.push(current_txid);
Ok(reached_commitment)
}
let mut visiting = HashSet::new();
let mut visited = HashSet::new();
let mut sorted = Vec::new();
if !visit_virtual_ancestors(
ark_txid,
&chain_map,
&mut visiting,
&mut visited,
&mut sorted,
)? {
return Err(Error::ad_hoc(format!(
"no path found from Ark TX {ark_txid} to commitment transaction",
)));
}
Ok(vec![sorted])
}
#[cfg(test)]
mod tests {
use super::*;
fn txid(n: u8) -> Txid {
Txid::from_byte_array([n; 32])
}
fn chain(
txid: Txid,
tx_type: server::ChainedTxType,
spends: impl Into<Vec<Txid>>,
) -> server::VtxoChain {
server::VtxoChain {
txid,
tx_type,
spends: spends.into(),
expires_at: 0,
}
}
fn exit_branch(chains: Vec<server::VtxoChain>, ark_txid: Txid) -> Vec<Txid> {
build_unilateral_exit_tree_txids(&server::VtxoChains { inner: chains }, ark_txid)
.expect("valid unilateral exit branch")
.pop()
.expect("one topological branch")
}
#[test]
fn condition_witness_elements_decode_encoded_witness() {
let elements = vec![
b"preimage".to_vec(),
Vec::new(),
vec![0; 253],
vec![1, 2, 3, 4],
];
let mut input = psbt::Input::default();
input.unknown.insert(
psbt::raw::Key {
type_value: 222,
key: VTXO_CONDITION_KEY.to_vec(),
},
crate::intent::encode_witness(&elements),
);
assert_eq!(condition_witness_elements(&input).unwrap(), elements);
}
#[test]
fn unilateral_exit_txids_for_linear_chain_are_parent_first() {
let commitment = txid(1);
let tree = txid(2);
let ark = txid(3);
let branch = exit_branch(
vec![
chain(commitment, server::ChainedTxType::Commitment, []),
chain(tree, server::ChainedTxType::Tree, [commitment]),
chain(ark, server::ChainedTxType::Ark, [tree]),
],
ark,
);
assert_eq!(branch, vec![tree, ark]);
}
#[test]
fn unilateral_exit_txids_deduplicate_merged_ancestor_dag() {
let commitment = txid(1);
let left = txid(2);
let right = txid(3);
let merge = txid(4);
let ark = txid(5);
let branch = exit_branch(
vec![
chain(commitment, server::ChainedTxType::Commitment, []),
chain(left, server::ChainedTxType::Tree, [commitment]),
chain(right, server::ChainedTxType::Tree, [commitment]),
chain(merge, server::ChainedTxType::Checkpoint, [left, right]),
chain(ark, server::ChainedTxType::Ark, [merge]),
],
ark,
);
assert_eq!(branch, vec![left, right, merge, ark]);
}
#[test]
fn unilateral_exit_txids_avoid_exponential_path_enumeration() {
let commitment = txid(1);
let a1 = txid(2);
let b1 = txid(3);
let m1 = txid(4);
let a2 = txid(5);
let b2 = txid(6);
let m2 = txid(7);
let ark = txid(8);
let branch = exit_branch(
vec![
chain(commitment, server::ChainedTxType::Commitment, []),
chain(a1, server::ChainedTxType::Tree, [commitment]),
chain(b1, server::ChainedTxType::Tree, [commitment]),
chain(m1, server::ChainedTxType::Checkpoint, [a1, b1]),
chain(a2, server::ChainedTxType::Tree, [m1]),
chain(b2, server::ChainedTxType::Tree, [m1]),
chain(m2, server::ChainedTxType::Checkpoint, [a2, b2]),
chain(ark, server::ChainedTxType::Ark, [m2]),
],
ark,
);
assert_eq!(branch, vec![a1, b1, m1, a2, b2, m2, ark]);
}
#[test]
fn unilateral_exit_txids_reject_cycles() {
let a = txid(1);
let b = txid(2);
let err = build_unilateral_exit_tree_txids(
&server::VtxoChains {
inner: vec![
chain(a, server::ChainedTxType::Ark, [b]),
chain(b, server::ChainedTxType::Checkpoint, [a]),
],
},
a,
)
.expect_err("cycle should be rejected");
assert!(err.to_string().contains("cycle"));
}
}
pub struct UnilateralExitTree {
commitment_txids: Vec<Txid>,
inner: Vec<Vec<Psbt>>,
}
impl UnilateralExitTree {
pub fn new(commitment_txids: Vec<Txid>, virtual_tx_tree: Vec<Vec<Psbt>>) -> Self {
Self {
commitment_txids,
inner: virtual_tx_tree,
}
}
pub fn inner(&self) -> &Vec<Vec<Psbt>> {
&self.inner
}
pub fn commitment_txids(&self) -> &[Txid] {
&self.commitment_txids
}
}
pub fn finalize_virtual_tx_input(
mut psbt: Psbt,
input_index: usize,
witness_utxo: TxOut,
) -> Result<Transaction, Error> {
let input = psbt
.inputs
.get_mut(input_index)
.ok_or_else(|| Error::transaction(format!("missing PSBT input {input_index}")))?;
input.witness_utxo = Some(witness_utxo);
let txid = psbt.unsigned_tx.compute_txid();
if let Some(tap_key_sig) = input.tap_key_sig {
tracing::debug!(%txid, "Finalizing batch-tree internal node key spend");
input.final_script_witness = Some(Witness::p2tr_key_spend(&tap_key_sig));
} else {
tracing::debug!(%txid, "Finalizing VTXO script spend");
input.final_script_witness = Some(finalize_taproot_script_spend_witness(input)?);
}
psbt.extract_tx().map_err(Error::transaction)
}
pub fn finalize_taproot_script_spend_witness(input: &psbt::Input) -> Result<Witness, Error> {
for (control_block, (script, leaf_version)) in input.tap_scripts.iter() {
let leaf_hash = TapLeafHash::from_script(script, *leaf_version);
let pubkeys = extract_checksig_pubkeys(script);
if pubkeys.is_empty() {
continue;
}
let signatures = pubkeys
.iter()
.map(|pk| {
input
.tap_script_sigs
.get(&(*pk, leaf_hash))
.map(|sig| sig.to_vec())
})
.collect::<Option<Vec<_>>>();
let Some(signatures) = signatures else {
continue;
};
let mut witness = Witness::new();
for signature in signatures.into_iter().rev() {
witness.push(signature);
}
for element in condition_witness_elements(input)? {
witness.push(element);
}
witness.push(script.as_bytes());
witness.push(control_block.serialize());
return Ok(witness);
}
Err(Error::transaction(
"no satisfiable taproot script-spend leaf found in PSBT input",
))
}
fn condition_witness_elements(input: &psbt::Input) -> Result<Vec<Vec<u8>>, Error> {
let condition_key = psbt::raw::Key {
type_value: 222,
key: VTXO_CONDITION_KEY.to_vec(),
};
let Some(condition_data) = input.unknown.get(&condition_key) else {
return Ok(Vec::new());
};
let mut cursor = std::io::Cursor::new(condition_data);
let element_count = VarInt::consensus_decode(&mut cursor)
.map_err(|e| Error::transaction(format!("failed to decode condition count: {e}")))?
.0;
let count_end = usize::try_from(cursor.position())
.map_err(|_| Error::transaction("condition cursor position overflow"))?;
let remaining_after_count = condition_data.len().saturating_sub(count_end);
let element_count = usize::try_from(element_count)
.map_err(|_| Error::transaction("condition witness element count overflow"))?;
if element_count > remaining_after_count {
return Err(Error::transaction(format!(
"condition witness element count {element_count} exceeds remaining buffer size {remaining_after_count}"
)));
}
let mut elements = Vec::with_capacity(element_count);
for _ in 0..element_count {
let element_len = VarInt::consensus_decode(&mut cursor)
.map_err(|e| Error::transaction(format!("failed to decode condition length: {e}")))?
.0;
let element_len = usize::try_from(element_len)
.map_err(|_| Error::transaction("condition witness element length overflow"))?;
let start = usize::try_from(cursor.position())
.map_err(|_| Error::transaction("condition cursor position overflow"))?;
let end = start
.checked_add(element_len)
.ok_or_else(|| Error::transaction("condition witness element end overflow"))?;
if condition_data.len() < end {
return Err(Error::transaction(format!(
"condition witness element too short: expected {element_len} bytes, got {}",
condition_data.len().saturating_sub(start)
)));
}
elements.push(condition_data[start..end].to_vec());
cursor.set_position(end as u64);
}
Ok(elements)
}
pub fn finalize_unilateral_exit_tree(
unilateral_exit_tree: &UnilateralExitTree,
commitment_txs: &[Transaction],
) -> Result<Vec<Vec<Transaction>>, Error> {
let mut finalized_virtual_tx_branches = Vec::new();
for unilateral_exit_branch in unilateral_exit_tree.inner.iter() {
let mut finalized_unilateral_exit_branch = Vec::new();
for virtual_tx in unilateral_exit_branch.iter() {
let psbt = virtual_tx.clone();
let virtual_tx_previous_output =
psbt.unsigned_tx.input[VTXO_INPUT_INDEX].previous_output;
let witness_utxo = {
unilateral_exit_branch
.iter()
.map(|p| &p.unsigned_tx)
.chain(commitment_txs.iter())
.find_map(|other_psbt| {
(other_psbt.compute_txid() == virtual_tx_previous_output.txid).then_some(
other_psbt.output[virtual_tx_previous_output.vout as usize].clone(),
)
})
}
.ok_or_else(|| {
Error::ad_hoc(format!(
"no witness UTXO found for virtual TX outpoint {virtual_tx_previous_output}"
))
})?;
let tx = finalize_virtual_tx_input(psbt, VTXO_INPUT_INDEX, witness_utxo)?;
finalized_unilateral_exit_branch.push(tx);
}
finalized_virtual_tx_branches.push(finalized_unilateral_exit_branch);
}
Ok(finalized_virtual_tx_branches)
}
#[deprecated(note = "use finalize_unilateral_exit_tree")]
pub fn sign_unilateral_exit_tree(
unilateral_exit_tree: &UnilateralExitTree,
commitment_txs: &[Transaction],
) -> Result<Vec<Vec<Transaction>>, Error> {
finalize_unilateral_exit_tree(unilateral_exit_tree, commitment_txs)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedUtxo {
pub outpoint: OutPoint,
pub amount: Amount,
pub address: Address,
}
#[derive(Debug, Clone)]
pub struct UtxoCoinSelection {
pub selected_utxos: Vec<SelectedUtxo>,
pub total_selected: Amount,
pub change_amount: Amount,
}
pub fn build_anchor_tx<F>(
bumpable_tx: &Transaction,
change_address: Address,
fee_rate: f64,
select_coins_fn: F,
) -> Result<Psbt, Error>
where
F: FnOnce(Amount) -> Result<UtxoCoinSelection, Error>,
{
let anchor = find_anchor_outpoint(bumpable_tx)?;
const P2TR_KEYSPEND_INPUT_WEIGHT: u64 = 57 * 4 + 64; const NESTED_P2WSH_INPUT_WEIGHT: u64 = 91 * 4 + 3 * 4; const P2TR_OUTPUT_WEIGHT: u64 = 43 * 4;
let estimated_weight = Weight::from_wu(
NESTED_P2WSH_INPUT_WEIGHT + P2TR_KEYSPEND_INPUT_WEIGHT + P2TR_OUTPUT_WEIGHT,
);
let child_vsize = estimated_weight.to_vbytes_ceil();
let package_size = child_vsize + bumpable_tx.weight().to_vbytes_ceil();
let fee = Amount::from_sat((package_size as f64 * fee_rate).ceil() as u64);
let UtxoCoinSelection {
selected_utxos,
total_selected,
change_amount,
} = select_coins_fn(fee)?;
if total_selected < fee {
return Err(Error::coin_select(format!(
"insufficient coins selected to cover {fee} fee"
)));
}
let mut inputs = vec![anchor];
let mut sequences = vec![Sequence::MAX];
for utxo in selected_utxos.iter() {
inputs.push(utxo.outpoint);
sequences.push(Sequence::MAX);
}
let outputs = vec![TxOut {
value: change_amount,
script_pubkey: change_address.script_pubkey(),
}];
let mut psbt = Psbt::from_unsigned_tx(Transaction {
version: transaction::Version::non_standard(3),
lock_time: LockTime::ZERO,
input: inputs
.iter()
.zip(sequences.iter())
.map(|(outpoint, sequence)| TxIn {
previous_output: *outpoint,
script_sig: ScriptBuf::new(),
sequence: *sequence,
witness: Witness::new(),
})
.collect(),
output: outputs,
})
.map_err(|e| Error::transaction(format!("Failed to create PSBT: {e}")))?;
psbt.inputs[0].witness_utxo = Some(anchor_output());
psbt.inputs[0].final_script_witness = Some(Witness::new());
for i in 1..psbt.inputs.len() {
if let Some(utxo) = selected_utxos.get(i - 1) {
psbt.inputs[i].witness_utxo = Some(TxOut {
value: utxo.amount,
script_pubkey: utxo.address.script_pubkey(),
});
}
}
Ok(psbt)
}
fn find_anchor_outpoint(tx: &Transaction) -> Result<OutPoint, Error> {
let anchor_output_template = anchor_output();
for (index, output) in tx.output.iter().enumerate() {
if output == &anchor_output_template {
return Ok(OutPoint {
txid: tx.compute_txid(),
vout: index as u32,
});
}
}
Err(Error::transaction("anchor output not found in transaction"))
}