Skip to main content

ark_core/
unilateral_exit.rs

1use crate::anchor_output;
2use crate::server;
3use crate::BoardingOutput;
4use crate::Error;
5use crate::ErrorContext;
6use crate::VTXO_INPUT_INDEX;
7use bitcoin::absolute::LockTime;
8use bitcoin::hashes::Hash;
9use bitcoin::hex::DisplayHex;
10use bitcoin::key::Secp256k1;
11use bitcoin::opcodes::all::*;
12use bitcoin::psbt;
13use bitcoin::secp256k1;
14use bitcoin::secp256k1::schnorr;
15use bitcoin::sighash::Prevouts;
16use bitcoin::sighash::SighashCache;
17use bitcoin::taproot;
18use bitcoin::transaction;
19use bitcoin::Address;
20use bitcoin::Amount;
21use bitcoin::OutPoint;
22use bitcoin::Psbt;
23use bitcoin::ScriptBuf;
24use bitcoin::Sequence;
25use bitcoin::TapLeafHash;
26use bitcoin::TapSighashType;
27use bitcoin::Transaction;
28use bitcoin::TxIn;
29use bitcoin::TxOut;
30use bitcoin::Txid;
31use bitcoin::Weight;
32use bitcoin::Witness;
33use bitcoin::XOnlyPublicKey;
34use std::collections::HashMap;
35use std::collections::HashSet;
36
37/// A UTXO that could have become a VTXO with the help of the Ark server, but is now unilaterally
38/// spendable by the original owner.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub struct OnChainInput {
41    /// The information needed to spend the UTXO, besides the amount.
42    boarding_output: BoardingOutput,
43    /// The amount of coins locked in the UTXO.
44    amount: Amount,
45    /// The location of this UTXO in the blockchain.
46    outpoint: OutPoint,
47}
48
49impl OnChainInput {
50    pub fn new(boarding_output: BoardingOutput, amount: Amount, outpoint: OutPoint) -> Self {
51        Self {
52            boarding_output,
53            amount,
54            outpoint,
55        }
56    }
57
58    pub fn previous_output(&self) -> TxOut {
59        TxOut {
60            value: self.amount,
61            script_pubkey: self.boarding_output.script_pubkey(),
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub struct VtxoInput {
68    outpoint: OutPoint,
69    sequence: Sequence,
70    witness_utxo: TxOut,
71    /// Where the VTXO would end up on the blockchain if it were to become a UTXO.
72    spend_info: (ScriptBuf, taproot::ControlBlock),
73}
74
75impl VtxoInput {
76    pub fn new(
77        outpoint: OutPoint,
78        sequence: Sequence,
79        witness_utxo: TxOut,
80        spend_info: (ScriptBuf, taproot::ControlBlock),
81    ) -> Self {
82        Self {
83            outpoint,
84            sequence,
85            witness_utxo,
86            spend_info,
87        }
88    }
89
90    pub fn previous_output(&self) -> TxOut {
91        self.witness_utxo.clone()
92    }
93}
94
95/// Build a transaction that spends boarding outputs and VTXOs to an _on-chain_ `to_address`. Any
96/// coins left over after covering the `to_amount` are sent to an on-chain change address.
97///
98/// All these outputs are spent unilaterally i.e. without the collaboration of the Ark server.
99///
100/// To be able to spend a boarding output, we must wait for the exit delay to pass.
101///
102/// To be able to spend a VTXO, the VTXO itself must be published on-chain, and then we must wait
103/// for the exit delay to pass.
104pub fn create_unilateral_exit_transaction<S>(
105    to_address: Address,
106    to_amount: Amount,
107    change_address: Address,
108    onchain_inputs: &[OnChainInput],
109    vtxo_inputs: &[VtxoInput],
110    sign_fn: S,
111) -> Result<Transaction, Error>
112where
113    S: Fn(
114        &mut psbt::Input,
115        secp256k1::Message,
116    ) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, Error>,
117{
118    if onchain_inputs.is_empty() && vtxo_inputs.is_empty() {
119        return Err(Error::transaction(
120            "cannot create transaction without inputs",
121        ));
122    }
123
124    let secp = Secp256k1::new();
125
126    let mut output = vec![TxOut {
127        value: to_amount,
128        script_pubkey: to_address.script_pubkey(),
129    }];
130
131    let total_amount: Amount = onchain_inputs
132        .iter()
133        .map(|o| o.amount)
134        .chain(vtxo_inputs.iter().map(|v| v.witness_utxo.value))
135        .sum();
136
137    let change_amount = total_amount.checked_sub(to_amount).ok_or_else(|| {
138        Error::transaction(format!(
139            "cannot cover to_amount ({to_amount}) with total input amount ({total_amount})"
140        ))
141    })?;
142
143    if change_amount > Amount::ZERO {
144        output.push(TxOut {
145            value: change_amount,
146            script_pubkey: change_address.script_pubkey(),
147        });
148    }
149
150    let input = {
151        let onchain_inputs = onchain_inputs.iter().map(|o| TxIn {
152            previous_output: o.outpoint,
153            sequence: o.boarding_output.exit_delay(),
154            ..Default::default()
155        });
156
157        let vtxo_inputs = vtxo_inputs.iter().map(|v| TxIn {
158            previous_output: v.outpoint,
159            sequence: v.sequence,
160            ..Default::default()
161        });
162
163        onchain_inputs.chain(vtxo_inputs).collect::<Vec<_>>()
164    };
165
166    let mut psbt = Psbt::from_unsigned_tx(Transaction {
167        version: transaction::Version::TWO,
168        lock_time: LockTime::ZERO,
169        input,
170        output,
171    })
172    .map_err(Error::transaction)?;
173
174    // Add a `witness_utxo` for every transaction input.
175    for (i, input) in psbt.inputs.iter_mut().enumerate() {
176        let outpoint = psbt.unsigned_tx.input[i].previous_output;
177
178        for onchain_input in onchain_inputs {
179            if onchain_input.outpoint == outpoint {
180                input.witness_utxo = Some(TxOut {
181                    value: onchain_input.amount,
182                    script_pubkey: onchain_input.boarding_output.address().script_pubkey(),
183                });
184
185                let (script, cb) = onchain_input.boarding_output.exit_spend_info();
186                let leaf_version = cb.leaf_version;
187                input.tap_scripts.insert(cb, (script, leaf_version));
188            }
189        }
190
191        for vtxo_input in vtxo_inputs.iter() {
192            if vtxo_input.outpoint == outpoint {
193                input.witness_utxo = Some(TxOut {
194                    value: vtxo_input.witness_utxo.value,
195                    script_pubkey: vtxo_input.witness_utxo.script_pubkey.clone(),
196                });
197
198                let (script, cb) = vtxo_input.spend_info.clone();
199                let leaf_version = cb.leaf_version;
200                input.tap_scripts.insert(cb, (script, leaf_version));
201            }
202        }
203    }
204
205    // Collect all `witness_utxo` entries.
206    let prevouts = psbt
207        .inputs
208        .iter()
209        .filter_map(|i| i.witness_utxo.clone())
210        .collect::<Vec<_>>();
211
212    // Sign each input.
213    for (i, input) in psbt.inputs.iter_mut().enumerate() {
214        let (exit_control_block, (exit_script, leaf_version)) = input
215            .tap_scripts
216            .pop_first()
217            .ok_or_else(|| Error::ad_hoc(format!("no exit script found for input {i}")))?;
218
219        input.witness_script = Some(exit_script.clone());
220
221        let leaf_hash = TapLeafHash::from_script(&exit_script, leaf_version);
222
223        let tap_sighash = SighashCache::new(&psbt.unsigned_tx)
224            .taproot_script_spend_signature_hash(
225                i,
226                &Prevouts::All(&prevouts),
227                leaf_hash,
228                TapSighashType::Default,
229            )
230            .map_err(Error::crypto)?;
231
232        let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
233
234        let sigs = sign_fn(input, msg)?;
235
236        let mut witness = Vec::new();
237        for (sig, pk) in sigs.iter() {
238            secp.verify_schnorr(sig, &msg, pk)
239                .map_err(Error::crypto)
240                .with_context(|| format!("failed to verify own signature for input {i}"))?;
241
242            witness.push(&sig[..]);
243        }
244
245        witness.push(exit_script.as_bytes());
246
247        let control_block = exit_control_block.serialize();
248        witness.push(control_block.as_slice());
249
250        let witness = Witness::from_slice(&witness);
251
252        input.final_script_witness = Some(witness);
253    }
254
255    let tx = psbt.clone().extract_tx().map_err(Error::transaction)?;
256
257    tracing::debug!(
258        ?onchain_inputs,
259        ?vtxo_inputs,
260        raw_tx = %bitcoin::consensus::serialize(&tx).as_hex(),
261        "Built transaction sending inputs to on-chain address"
262    );
263
264    Ok(tx)
265}
266
267/// Build the unilateral exit tree of TXIDs for a VTXO from a [`server::VtxoChains`].
268pub fn build_unilateral_exit_tree_txids(
269    vtxo_chains: &server::VtxoChains,
270    // The TXID of the VTXO we want to commit on-chain.
271    ark_txid: Txid,
272) -> Result<Vec<Vec<Txid>>, Error> {
273    // Create a hash-map for quick lookups: TXID -> `VtxoChain`.
274    let mut chain_map: HashMap<Txid, &server::VtxoChain> = HashMap::new();
275    for vtxo_chain in &vtxo_chains.inner {
276        chain_map.insert(vtxo_chain.txid, vtxo_chain);
277    }
278
279    /// Find all the paths from a virtual transaction to the root commitment transaction,
280    /// recursively.
281    fn find_paths_to_commitment(
282        current_txid: Txid,
283        chain_map: &HashMap<Txid, &server::VtxoChain>,
284        current_path: &mut Vec<Txid>,
285        all_paths: &mut Vec<Vec<Txid>>,
286        visited: &mut HashSet<Txid>,
287    ) -> Result<(), Error> {
288        // Safety check to prevent an infinite loop.
289        if current_path.len() > 1_000 {
290            return Err(Error::ad_hoc(
291                "chain traversal exceeded maximum depth of 1000",
292            ));
293        }
294
295        // Safety check to reject cycles.
296        if visited.contains(&current_txid) {
297            return Err(Error::ad_hoc("chain traversal led to cycle"));
298        }
299        visited.insert(current_txid);
300
301        // Add current TXID to path.
302        current_path.push(current_txid);
303
304        // Look through parent transactions to continue building up the chain(s).
305        let chain = chain_map.get(&current_txid).ok_or_else(|| {
306            Error::ad_hoc(format!("could not find VtxoChain for TXID: {current_txid}",))
307        })?;
308        // Check if any of the transactions spent by this virtual TX are the commitment transaction.
309        let mut reached_commitment = false;
310
311        for &parent_txid in &chain.spends {
312            // Look up the parent transaction's chain to get its type
313            let parent_chain = chain_map.get(&parent_txid).ok_or_else(|| {
314                Error::ad_hoc(format!(
315                    "could not find VtxoChain for parent TXID: {parent_txid}",
316                ))
317            })?;
318
319            match parent_chain.tx_type {
320                server::ChainedTxType::Commitment => {
321                    // We've reached our destination.
322                    all_paths.push(current_path.clone());
323
324                    reached_commitment = true;
325                }
326                server::ChainedTxType::Ark
327                | server::ChainedTxType::Checkpoint
328                | server::ChainedTxType::Tree => {
329                    // Continue traversing virtual transactions up the tree.
330                    find_paths_to_commitment(
331                        parent_txid,
332                        chain_map,
333                        current_path,
334                        all_paths,
335                        visited,
336                    )?;
337                }
338                server::ChainedTxType::Unspecified => {
339                    tracing::warn!(
340                        txid = %parent_txid,
341                        "Found unspecified TX type when walking up virtual TX tree. \
342                         Treating it like a virtual TX"
343                    );
344
345                    // Continue traversing virtual transactions up the tree.
346                    find_paths_to_commitment(
347                        parent_txid,
348                        chain_map,
349                        current_path,
350                        all_paths,
351                        visited,
352                    )?;
353                }
354            }
355        }
356
357        if !reached_commitment && chain.spends.is_empty() {
358            return Err(Error::ad_hoc(format!(
359                "dead end reached at TXID {current_txid} with no commitment transaction"
360            )));
361        }
362
363        visited.remove(&current_txid);
364        current_path.pop();
365        Ok(())
366    }
367
368    let mut all_paths = Vec::new();
369    let mut current_path = Vec::new();
370    let mut visited = HashSet::new();
371
372    find_paths_to_commitment(
373        ark_txid,
374        &chain_map,
375        &mut current_path,
376        &mut all_paths,
377        &mut visited,
378    )?;
379
380    if all_paths.is_empty() {
381        return Err(Error::ad_hoc(format!(
382            "no paths found from Ark TX {ark_txid} to commitment transaction",
383        )));
384    }
385
386    // Reverse each path so they go from root commitment TX to VTXO.
387    let all_paths: Vec<Vec<Txid>> = all_paths
388        .into_iter()
389        .map(|mut path| {
390            path.reverse();
391            path
392        })
393        .collect();
394
395    Ok(all_paths)
396}
397
398/// The full path from commitment transaction to VTXO. The entire path will need to be published
399/// on-chain to execute a unilateral exit with this VTXO.
400///
401/// We use the word "tree" because a VTXO may come from more than one path i.e. if its corresponding
402/// Ark transaction has more than one input!
403pub struct UnilateralExitTree {
404    /// The commitment transactions from which this VTXO comes from.
405    ///
406    /// A pre-confirmed VTXO can have ancestors from more than one batch, hence the list.
407    commitment_txids: Vec<Txid>,
408    /// The chains of virtual transactions that lead to a VTXO.
409    ///
410    /// Virtual TXs in a branch are ordered by distance to the root commitment transaction, with
411    /// virtual TXs closest to it appearing first.
412    inner: Vec<Vec<Psbt>>,
413}
414
415impl UnilateralExitTree {
416    pub fn new(commitment_txids: Vec<Txid>, virtual_tx_tree: Vec<Vec<Psbt>>) -> Self {
417        Self {
418            commitment_txids,
419            inner: virtual_tx_tree,
420        }
421    }
422
423    pub fn inner(&self) -> &Vec<Vec<Psbt>> {
424        &self.inner
425    }
426
427    pub fn commitment_txids(&self) -> &[Txid] {
428        &self.commitment_txids
429    }
430}
431
432/// Sign all the transactions needed to commit a VTXO on-chain.
433pub fn sign_unilateral_exit_tree(
434    unilateral_exit_tree: &UnilateralExitTree,
435    commitment_txs: &[Transaction],
436) -> Result<Vec<Vec<Transaction>>, Error> {
437    let mut signed_virtual_tx_branches = Vec::new();
438    for unilateral_exit_branch in unilateral_exit_tree.inner.iter() {
439        let mut signed_unilateral_exit_branch = Vec::new();
440        for virtual_tx in unilateral_exit_branch.iter() {
441            let txid = virtual_tx.unsigned_tx.compute_txid();
442            let mut psbt = virtual_tx.clone();
443
444            let vtxo_previous_output = psbt.unsigned_tx.input[VTXO_INPUT_INDEX].previous_output;
445
446            let witness_utxo = {
447                unilateral_exit_branch
448                    .iter()
449                    .map(|p| &p.unsigned_tx)
450                    .chain(commitment_txs.iter())
451                    .find_map(|other_psbt| {
452                        (other_psbt.compute_txid() == vtxo_previous_output.txid).then_some(
453                            other_psbt.output[vtxo_previous_output.vout as usize].clone(),
454                        )
455                    })
456            }
457            .expect("witness UTXO in path");
458
459            psbt.inputs[VTXO_INPUT_INDEX].witness_utxo = Some(witness_utxo);
460
461            if let Some(tap_key_sig) = psbt.inputs[VTXO_INPUT_INDEX].tap_key_sig {
462                tracing::debug!(%txid, "Signing key spend for confirmed VTXO");
463
464                psbt.inputs[VTXO_INPUT_INDEX].final_script_witness =
465                    Some(Witness::p2tr_key_spend(&tap_key_sig));
466            } else if !psbt.inputs[VTXO_INPUT_INDEX].tap_script_sigs.is_empty() {
467                tracing::debug!(%txid, "Signing script spend for pre-confirmed VTXO");
468
469                // We always take the first script.
470                let tap_script = psbt.inputs[VTXO_INPUT_INDEX].tap_scripts.iter().next();
471                let tap_script_sigs = &psbt.inputs[VTXO_INPUT_INDEX].tap_script_sigs;
472
473                let (control_block, (script, _)) = tap_script.ok_or_else(|| {
474                    Error::transaction(format!("missing tapscripts in virtual TX {txid}"))
475                })?;
476
477                // Extract pubkeys from the 2-of-2 multisig script to determine signature order.
478                let (pk_0, pk_1) = extract_pubkeys_from_2of2_script(script)?;
479
480                // Compute the TapLeafHash for the script to look up signatures.
481                let leaf_hash = TapLeafHash::from_script(script, control_block.leaf_version);
482
483                // Look up signatures in the correct order based on the pubkeys in the script.
484                let sig_0 = tap_script_sigs.get(&(pk_0, leaf_hash)).ok_or_else(|| {
485                    Error::transaction(format!(
486                        "missing signature for first pubkey {} in virtual TX {txid}",
487                        pk_0
488                    ))
489                })?;
490                let sig_1 = tap_script_sigs.get(&(pk_1, leaf_hash)).ok_or_else(|| {
491                    Error::transaction(format!(
492                        "missing signature for second pubkey {} in virtual TX {txid}",
493                        pk_1
494                    ))
495                })?;
496
497                // Construct witness: [sig_1, sig_0, script, control_block].
498                let mut witness = Witness::new();
499                witness.push(sig_1.to_vec());
500                witness.push(sig_0.to_vec());
501                witness.push(script.as_bytes());
502                witness.push(control_block.serialize());
503
504                psbt.inputs[VTXO_INPUT_INDEX].final_script_witness = Some(witness);
505            } else {
506                return Err(Error::transaction(format!(
507                    "missing taproot key spend or script spend data in virtual TX {txid}"
508                )));
509            };
510
511            let tx = psbt.clone().extract_tx().map_err(Error::transaction)?;
512
513            signed_unilateral_exit_branch.push(tx);
514        }
515        signed_virtual_tx_branches.push(signed_unilateral_exit_branch);
516    }
517
518    Ok(signed_virtual_tx_branches)
519}
520
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct SelectedUtxo {
523    pub outpoint: OutPoint,
524    pub amount: Amount,
525    pub address: Address,
526}
527
528#[derive(Debug, Clone)]
529pub struct UtxoCoinSelection {
530    pub selected_utxos: Vec<SelectedUtxo>,
531    pub total_selected: Amount,
532    pub change_amount: Amount,
533}
534
535/// Build an anchor transaction by spending a 0-value P2A output and adding another output to cover
536/// the transaction fees.
537pub fn build_anchor_tx<F>(
538    bumpable_tx: &Transaction,
539    change_address: Address,
540    fee_rate: f64,
541    select_coins_fn: F,
542) -> Result<Psbt, Error>
543where
544    F: FnOnce(Amount) -> Result<UtxoCoinSelection, Error>,
545{
546    let anchor = find_anchor_outpoint(bumpable_tx)?;
547
548    // Estimate for the size of the bump transaction.
549    const P2TR_KEYSPEND_INPUT_WEIGHT: u64 = 57 * 4 + 64; // 292 weight units
550    const NESTED_P2WSH_INPUT_WEIGHT: u64 = 91 * 4 + 3 * 4; // 376 weight units
551    const P2TR_OUTPUT_WEIGHT: u64 = 43 * 4; // 172 weight units
552
553    // We assume only one UTXO will be selected to have a correct estimate.
554    let estimated_weight = Weight::from_wu(
555        NESTED_P2WSH_INPUT_WEIGHT + P2TR_KEYSPEND_INPUT_WEIGHT + P2TR_OUTPUT_WEIGHT,
556    );
557
558    let child_vsize = estimated_weight.to_vbytes_ceil();
559    let package_size = child_vsize + bumpable_tx.weight().to_vbytes_ceil();
560
561    let fee = Amount::from_sat((package_size as f64 * fee_rate).ceil() as u64);
562
563    // Use dependency to select coins to cover the fee.
564    let UtxoCoinSelection {
565        selected_utxos,
566        total_selected,
567        change_amount,
568    } = select_coins_fn(fee)?;
569
570    if total_selected < fee {
571        return Err(Error::coin_select(format!(
572            "insufficient coins selected to cover {fee} fee"
573        )));
574    }
575
576    // Build inputs and outputs.
577    let mut inputs = vec![anchor];
578    let mut sequences = vec![Sequence::MAX];
579
580    for utxo in selected_utxos.iter() {
581        inputs.push(utxo.outpoint);
582        sequences.push(Sequence::MAX);
583    }
584
585    let outputs = vec![TxOut {
586        value: change_amount,
587        script_pubkey: change_address.script_pubkey(),
588    }];
589
590    // Create PSBT.
591    let mut psbt = Psbt::from_unsigned_tx(Transaction {
592        version: transaction::Version::non_standard(3),
593        lock_time: LockTime::ZERO,
594        input: inputs
595            .iter()
596            .zip(sequences.iter())
597            .map(|(outpoint, sequence)| TxIn {
598                previous_output: *outpoint,
599                script_sig: ScriptBuf::new(),
600                sequence: *sequence,
601                witness: Witness::new(),
602            })
603            .collect(),
604        output: outputs,
605    })
606    .map_err(|e| Error::transaction(format!("Failed to create PSBT: {e}")))?;
607
608    // Set witness UTXO for anchor input (first input). The anchor input does not need signing,
609    // hence the empty witness.
610    psbt.inputs[0].witness_utxo = Some(anchor_output());
611    psbt.inputs[0].final_script_witness = Some(Witness::new());
612
613    // Set witness UTXO for the additional inputs (probably just one).
614    for i in 1..psbt.inputs.len() {
615        if let Some(utxo) = selected_utxos.get(i - 1) {
616            psbt.inputs[i].witness_utxo = Some(TxOut {
617                value: utxo.amount,
618                script_pubkey: utxo.address.script_pubkey(),
619            });
620        }
621    }
622
623    Ok(psbt)
624}
625
626fn find_anchor_outpoint(tx: &Transaction) -> Result<OutPoint, Error> {
627    let anchor_output_template = anchor_output();
628
629    for (index, output) in tx.output.iter().enumerate() {
630        if output == &anchor_output_template {
631            return Ok(OutPoint {
632                txid: tx.compute_txid(),
633                vout: index as u32,
634            });
635        }
636    }
637
638    Err(Error::transaction("anchor output not found in transaction"))
639}
640
641/// Extract the two [`XOnlyPublicKey`]s from a 2-of-2 multisig tapscript.
642///
643/// The script format is: <pk_0> CHECKSIGVERIFY <pk_1> CHECKSIG
644fn extract_pubkeys_from_2of2_script(
645    script: &ScriptBuf,
646) -> Result<(XOnlyPublicKey, XOnlyPublicKey), Error> {
647    let bytes = script.as_bytes();
648
649    // Expected format: [0x20] [32 bytes pk_0] [CHECKSIGVERIFY] [0x20] [32 bytes pk_1] [CHECKSIG]
650    // Minimum length: 1 + 32 + 1 + 1 + 32 + 1 = 68 bytes
651    if bytes.len() < 68 {
652        return Err(Error::transaction(format!(
653            "script too short to be 2-of-2 multisig: {} bytes",
654            bytes.len()
655        )));
656    }
657
658    // Check first push is 32 bytes
659    if bytes[0] != 0x20 {
660        return Err(Error::transaction(format!(
661            "expected OP_PUSHBYTES_32 (0x20) at position 0, got 0x{:02x}",
662            bytes[0]
663        )));
664    }
665
666    // Extract first pubkey (bytes 1-32)
667    let pk_0_bytes: [u8; 32] = bytes[1..33]
668        .try_into()
669        .map_err(|_| Error::transaction("failed to extract first pubkey bytes"))?;
670    let pk_0 = XOnlyPublicKey::from_slice(&pk_0_bytes)
671        .map_err(|e| Error::transaction(format!("invalid first pubkey: {e}")))?;
672
673    // Check CHECKSIGVERIFY at position 33
674    if bytes[33] != OP_CHECKSIGVERIFY.to_u8() {
675        return Err(Error::transaction(format!(
676            "expected OP_CHECKSIGVERIFY (0xad) at position 33, got 0x{:02x}",
677            bytes[33]
678        )));
679    }
680
681    // Check second push is 32 bytes at position 34
682    if bytes[34] != 0x20 {
683        return Err(Error::transaction(format!(
684            "expected OP_PUSHBYTES_32 (0x20) at position 34, got 0x{:02x}",
685            bytes[34]
686        )));
687    }
688
689    // Extract second pubkey (bytes 35-66)
690    let pk_1_bytes: [u8; 32] = bytes[35..67]
691        .try_into()
692        .map_err(|_| Error::transaction("failed to extract second pubkey bytes"))?;
693    let pk_1 = XOnlyPublicKey::from_slice(&pk_1_bytes)
694        .map_err(|e| Error::transaction(format!("invalid second pubkey: {e}")))?;
695
696    // Check CHECKSIG at position 67
697    if bytes[67] != OP_CHECKSIG.to_u8() {
698        return Err(Error::transaction(format!(
699            "expected OP_CHECKSIG (0xac) at position 67, got 0x{:02x}",
700            bytes[67]
701        )));
702    }
703
704    Ok((pk_0, pk_1))
705}