Skip to main content

ark_core/
batch.rs

1use crate::anchor_output;
2use crate::asset::packet;
3use crate::conversions::from_musig_xonly;
4use crate::conversions::to_musig_pk;
5use crate::intent;
6use crate::intent::Intent;
7use crate::server::NoncePks;
8use crate::server::PartialSigTree;
9use crate::server::TreeTxNoncePks;
10use crate::tree_tx_output_script::TreeTxOutputScript;
11use crate::BoardingOutput;
12use crate::Error;
13use crate::ErrorContext;
14use crate::TxGraph;
15use crate::VTXO_COSIGNER_PSBT_KEY;
16use crate::VTXO_INPUT_INDEX;
17use bitcoin::absolute::LockTime;
18use bitcoin::hashes::Hash;
19use bitcoin::key::Keypair;
20use bitcoin::key::Secp256k1;
21use bitcoin::psbt;
22use bitcoin::secp256k1;
23use bitcoin::secp256k1::schnorr;
24use bitcoin::secp256k1::PublicKey;
25use bitcoin::sighash::Prevouts;
26use bitcoin::sighash::SighashCache;
27use bitcoin::taproot;
28use bitcoin::transaction;
29use bitcoin::Address;
30use bitcoin::Amount;
31use bitcoin::OutPoint;
32use bitcoin::Psbt;
33use bitcoin::TapLeafHash;
34use bitcoin::TapSighashType;
35use bitcoin::Transaction;
36use bitcoin::TxIn;
37use bitcoin::TxOut;
38use bitcoin::Txid;
39use bitcoin::XOnlyPublicKey;
40use musig::musig;
41use rand::CryptoRng;
42use rand::Rng;
43use std::collections::BTreeMap;
44use std::collections::HashMap;
45
46/// A UTXO that is primed to become a VTXO. Alternatively, the owner of this UTXO may decide to
47/// spend it into a vanilla UTXO.
48///
49/// Only UTXOs with a particular script (involving an Ark server) can become VTXOs.
50#[derive(Debug, Clone)]
51pub struct OnChainInput {
52    /// The information needed to spend the UTXO.
53    boarding_output: BoardingOutput,
54    /// The amount of coins locked in the UTXO.
55    amount: Amount,
56    /// The location of this UTXO in the blockchain.
57    outpoint: OutPoint,
58}
59
60impl OnChainInput {
61    pub fn new(boarding_output: BoardingOutput, amount: Amount, outpoint: OutPoint) -> Self {
62        Self {
63            boarding_output,
64            amount,
65            outpoint,
66        }
67    }
68
69    pub fn boarding_output(&self) -> &BoardingOutput {
70        &self.boarding_output
71    }
72
73    pub fn amount(&self) -> Amount {
74        self.amount
75    }
76
77    pub fn outpoint(&self) -> OutPoint {
78        self.outpoint
79    }
80}
81
82/// A nonce key pair per tree transaction output that we are a part of in the batch.
83///
84/// The [`musig::SecretNonce`] element of the tuple is an [`Option`] because it cannot be cloned or
85/// copied. When we are ready to sign a tree transaction, we call the method `take_sk` to move out
86/// of the [`Option`].
87#[allow(clippy::type_complexity)]
88pub struct NonceKps(HashMap<Txid, (Option<musig::SecretNonce>, musig::PublicNonce)>);
89
90impl NonceKps {
91    /// Take ownership of the [`musig::SecretNonce`] for the transaction identified by `txid`.
92    ///
93    /// The caller must take ownership because the [`musig::SecretNonce`] ensures that it can only
94    /// be used once, to avoid nonce reuse.
95    pub fn take_sk(&mut self, txid: &Txid) -> Option<musig::SecretNonce> {
96        self.0.get_mut(txid).and_then(|(sec, _)| sec.take())
97    }
98
99    /// Convert into [`NoncePks`].
100    pub fn to_nonce_pks(&self) -> NoncePks {
101        let nonce_pks = self
102            .0
103            .iter()
104            .map(|(txid, (_, pub_nonce))| (*txid, *pub_nonce))
105            .collect::<HashMap<_, _>>();
106
107        NoncePks::new(nonce_pks)
108    }
109}
110
111/// Generate a nonce key pair for each tree transaction output that we are a part of in the batch.
112pub fn generate_nonce_tree<R>(
113    rng: &mut R,
114    batch_tree_tx_graph: &TxGraph,
115    own_cosigner_pk: PublicKey,
116    commitment_tx: &Psbt,
117) -> Result<NonceKps, Error>
118where
119    R: Rng + CryptoRng,
120{
121    let batch_tree_tx_map = batch_tree_tx_graph.as_map();
122
123    let nonce_tree = batch_tree_tx_map
124        .iter()
125        .map(|(txid, tx)| {
126            let cosigner_pks = extract_cosigner_pks_from_vtxo_psbt(tx)?;
127
128            if !cosigner_pks.contains(&own_cosigner_pk) {
129                return Err(Error::crypto(format!(
130                    "cosigner PKs does not contain {own_cosigner_pk} for tree TX {txid}"
131                )));
132            }
133
134            let session_id = musig::SessionSecretRand::assume_unique_per_nonce_gen(rng.r#gen());
135            let extra_rand = rng.r#gen();
136
137            let msg = tree_tx_sighash(tx, &batch_tree_tx_map, commitment_tx)?;
138
139            let key_agg_cache = {
140                let cosigner_pks = cosigner_pks
141                    .iter()
142                    .map(|pk| to_musig_pk(*pk))
143                    .collect::<Vec<_>>();
144                musig::KeyAggCache::new(&cosigner_pks.iter().collect::<Vec<_>>())
145            };
146
147            let (nonce, pub_nonce) =
148                key_agg_cache.nonce_gen(session_id, to_musig_pk(own_cosigner_pk), &msg, extra_rand);
149
150            Ok((*txid, (Some(nonce), pub_nonce)))
151        })
152        .collect::<Result<HashMap<_, _>, _>>()?;
153
154    Ok(NonceKps(nonce_tree))
155}
156
157fn tree_tx_sighash(
158    // The tree PSBT to be signed.
159    psbt: &Psbt,
160    // The entire tree TX set for this batch, to look for the previous output.
161    tx_map: &HashMap<Txid, &Psbt>,
162    // The commitment transaction, in case it contains the previous output.
163    commitment_tx: &Psbt,
164) -> Result<[u8; 32], Error> {
165    let tx = &psbt.unsigned_tx;
166
167    // We expect a single input to a VTXO.
168    let previous_output = tx.input[VTXO_INPUT_INDEX].previous_output;
169
170    let parent_tx = tx_map
171        .get(&previous_output.txid)
172        .or_else(|| {
173            (previous_output.txid == commitment_tx.unsigned_tx.compute_txid())
174                .then_some(&commitment_tx)
175        })
176        .ok_or_else(|| {
177            Error::crypto(format!(
178                "parent transaction {} not found for tree TX {}",
179                previous_output.txid,
180                tx.compute_txid()
181            ))
182        })?;
183    let previous_output = parent_tx
184        .unsigned_tx
185        .output
186        .get(previous_output.vout as usize)
187        .ok_or_else(|| {
188            Error::crypto(format!(
189                "previous output {} not found for tree TX {}",
190                previous_output,
191                tx.compute_txid()
192            ))
193        })?;
194
195    let prevouts = [previous_output];
196    let prevouts = Prevouts::All(&prevouts);
197
198    // Here we are generating a key spend sighash, because batch tree outputs are signed by parties
199    // with VTXOs in this new batch. We use a musig key spend to efficiently coordinate with all the
200    // parties.
201    let tap_sighash = SighashCache::new(tx)
202        .taproot_key_spend_signature_hash(VTXO_INPUT_INDEX, &prevouts, TapSighashType::Default)
203        .map_err(Error::crypto)?;
204
205    Ok(tap_sighash.to_raw_hash().to_byte_array())
206}
207
208/// Compute the aggregated nonce public key for a transaction in the VTXO tree.
209///
210/// The [`TreeTxNoncePks`] holds the public nonces of all the cosigners of this transaction.
211pub fn aggregate_nonces(tree_tx_nonce_pks: TreeTxNoncePks) -> musig::AggregatedNonce {
212    let pks = tree_tx_nonce_pks.to_pks();
213    let ref_pks = pks.iter().collect::<Vec<_>>();
214    musig::AggregatedNonce::new(&ref_pks)
215}
216
217/// Use `own_cosigner_kp` to sign each batch tree transaction output that we are a part, using
218/// `our_nonce_kps` to provide our share of each aggregate nonce.
219pub fn sign_batch_tree_tx(
220    tree_txid: Txid,
221    vtxo_tree_expiry: bitcoin::Sequence,
222    server_pk: XOnlyPublicKey,
223    own_cosigner_kp: &Keypair,
224    agg_nonce_pk: musig::AggregatedNonce,
225    batch_tree_tx_graph: &TxGraph,
226    commitment_psbt: &Psbt,
227    // This holds all the nonce KPs we generated earlier. We need to mutate it to be able to _move_
228    // the secret nonce out of it before signing.
229    our_nonce_kps: &mut NonceKps,
230) -> Result<PartialSigTree, Error> {
231    let own_cosigner_pk = own_cosigner_kp.public_key();
232
233    let internal_node_script = TreeTxOutputScript::new(vtxo_tree_expiry, server_pk);
234
235    let secp = Secp256k1::new();
236
237    let own_cosigner_kp = ::musig::Keypair::from_seckey_byte_array(own_cosigner_kp.secret_bytes())
238        .map_err(|e| Error::ad_hoc(format!("invalid keypair: {e}")))?;
239
240    let batch_tree_tx_map = batch_tree_tx_graph.as_map();
241
242    let psbt = batch_tree_tx_map
243        .get(&tree_txid)
244        .ok_or_else(|| Error::ad_hoc(format!("TXID {tree_txid} not found in batch tree map")))?;
245
246    let mut cosigner_pks = extract_cosigner_pks_from_vtxo_psbt(psbt)?;
247    cosigner_pks.sort_by_key(|k| k.serialize());
248
249    if !cosigner_pks.contains(&own_cosigner_pk) {
250        return Err(Error::ad_hoc(
251            "own cosigner PK not found among tree transaction cosigner PKs",
252        ));
253    }
254
255    tracing::debug!(%tree_txid, "Generating partial signature");
256
257    let mut key_agg_cache = {
258        let cosigner_pks = cosigner_pks
259            .iter()
260            .map(|pk| to_musig_pk(*pk))
261            .collect::<Vec<_>>();
262        musig::KeyAggCache::new(&cosigner_pks.iter().collect::<Vec<_>>())
263    };
264
265    let sweep_tap_tree =
266        internal_node_script.sweep_spend_leaf(&secp, from_musig_xonly(key_agg_cache.agg_pk()));
267
268    let tweak = ::musig::Scalar::from(
269        ::musig::SecretKey::from_secret_bytes(*sweep_tap_tree.tap_tweak().as_byte_array())
270            .map_err(|e| Error::ad_hoc(format!("invalid tweak: {e}")))?,
271    );
272
273    key_agg_cache
274        .pubkey_xonly_tweak_add(&tweak)
275        .map_err(Error::crypto)?;
276
277    let msg = tree_tx_sighash(psbt, &batch_tree_tx_map, commitment_psbt)?;
278
279    let nonce_sk = our_nonce_kps
280        .take_sk(&tree_txid)
281        .ok_or_else(|| Error::crypto(format!("missing nonce for tree TX {tree_txid}")))?;
282
283    let sig = musig::Session::new(&key_agg_cache, agg_nonce_pk, &msg).partial_sign(
284        nonce_sk,
285        &own_cosigner_kp,
286        &key_agg_cache,
287    );
288
289    let partial_sig_tree = HashMap::from_iter([(tree_txid, sig)]);
290
291    Ok(PartialSigTree(partial_sig_tree))
292}
293
294/// Build and sign a forfeit transaction per [`VtxoInput`] to be used in an upcoming commitment
295/// transaction.
296pub fn create_and_sign_forfeit_txs<S>(
297    mut sign_fn: S,
298    vtxo_inputs: &[intent::Input],
299    connectors_leaves: &[&Psbt],
300    server_forfeit_address: &Address,
301    // As defined by the server.
302    dust: Amount,
303) -> Result<Vec<Psbt>, Error>
304where
305    S: FnMut(
306        &mut psbt::Input,
307        secp256k1::Message,
308    ) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, Error>,
309{
310    const FORFEIT_TX_CONNECTOR_INDEX: usize = 0;
311    const FORFEIT_TX_VTXO_INDEX: usize = 1;
312
313    let secp = Secp256k1::new();
314
315    let connector_amount = dust;
316
317    let connector_index = derive_vtxo_connector_map(vtxo_inputs, connectors_leaves, dust)?;
318
319    let mut signed_forfeit_psbts = Vec::new();
320    for vtxo_input in vtxo_inputs.iter() {
321        if vtxo_input.amount() < dust || vtxo_input.is_swept() {
322            // Sub-dust VTXOs don't need to be forfeited.
323            continue;
324        }
325
326        let outpoint = vtxo_input.outpoint();
327
328        let connector_outpoint = connector_index.get(&outpoint).ok_or_else(|| {
329            Error::ad_hoc(format!(
330                "connector outpoint missing for virtual TX outpoint {outpoint}"
331            ))
332        })?;
333
334        let connector_psbt = connectors_leaves
335            .iter()
336            .find(|l| l.unsigned_tx.compute_txid() == connector_outpoint.txid)
337            .ok_or_else(|| {
338                Error::ad_hoc(format!(
339                    "connector PSBT missing for virtual TX outpoint {outpoint}"
340                ))
341            })?;
342
343        let connector_output = connector_psbt
344            .unsigned_tx
345            .output
346            .get(connector_outpoint.vout as usize)
347            .ok_or_else(|| {
348                Error::ad_hoc(format!(
349                    "connector output missing for virtual TX outpoint {outpoint}"
350                ))
351            })?;
352
353        let forfeit_output = TxOut {
354            value: vtxo_input.amount() + connector_amount,
355            script_pubkey: server_forfeit_address.script_pubkey(),
356        };
357
358        let mut forfeit_psbt = Psbt::from_unsigned_tx(Transaction {
359            version: transaction::Version::non_standard(3),
360            lock_time: LockTime::ZERO,
361            input: vec![
362                TxIn {
363                    previous_output: *connector_outpoint,
364                    ..Default::default()
365                },
366                TxIn {
367                    previous_output: outpoint,
368                    ..Default::default()
369                },
370            ],
371            output: vec![forfeit_output.clone(), anchor_output()],
372        })
373        .map_err(Error::transaction)?;
374
375        forfeit_psbt.inputs[FORFEIT_TX_CONNECTOR_INDEX].witness_utxo =
376            Some(connector_output.clone());
377
378        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_utxo = Some(TxOut {
379            value: vtxo_input.amount(),
380            script_pubkey: vtxo_input.script_pubkey().clone(),
381        });
382
383        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].sighash_type =
384            Some(TapSighashType::Default.into());
385
386        let (forfeit_script, forfeit_control_block) = vtxo_input.spend_info();
387
388        let leaf_version = forfeit_control_block.leaf_version;
389        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX]
390            .tap_scripts
391            .insert(
392                forfeit_control_block.clone(),
393                (forfeit_script.clone(), leaf_version),
394            );
395        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_script = Some(forfeit_script.clone());
396
397        let prevouts = forfeit_psbt
398            .inputs
399            .iter()
400            .filter_map(|i| i.witness_utxo.clone())
401            .collect::<Vec<_>>();
402        let prevouts = Prevouts::All(&prevouts);
403
404        let leaf_hash = TapLeafHash::from_script(forfeit_script, leaf_version);
405
406        let tap_sighash = SighashCache::new(&forfeit_psbt.unsigned_tx)
407            .taproot_script_spend_signature_hash(
408                FORFEIT_TX_VTXO_INDEX,
409                &prevouts,
410                leaf_hash,
411                TapSighashType::Default,
412            )
413            .map_err(Error::crypto)?;
414
415        let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
416
417        let sigs = sign_fn(&mut forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX], msg)?;
418
419        for (sig, pk) in sigs {
420            secp.verify_schnorr(&sig, &msg, &pk)
421                .map_err(Error::crypto)
422                .context("failed to verify own forfeit signature")?;
423
424            let sig = taproot::Signature {
425                signature: sig,
426                sighash_type: TapSighashType::Default,
427            };
428
429            forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX]
430                .tap_script_sigs
431                .insert((pk, leaf_hash), sig);
432        }
433
434        signed_forfeit_psbts.push(forfeit_psbt.clone());
435    }
436
437    Ok(signed_forfeit_psbts)
438}
439
440/// Sign every input of the `commitment_psbt` which is in the provided `onchain_inputs` list.
441pub fn sign_commitment_psbt<F>(
442    sign_for_pk_fn: F,
443    commitment_psbt: &mut Psbt,
444    onchain_inputs: &[OnChainInput],
445) -> Result<(), Error>
446where
447    F: Fn(&XOnlyPublicKey, &secp256k1::Message) -> Result<schnorr::Signature, Error>,
448{
449    let secp = Secp256k1::new();
450
451    let prevouts = commitment_psbt
452        .inputs
453        .iter()
454        .filter_map(|i| i.witness_utxo.clone())
455        .collect::<Vec<_>>();
456
457    // Sign commitment transaction inputs that belong to us. For every output we are settling, we
458    // look through the commitment transaction inputs to find a matching input.
459    for OnChainInput {
460        boarding_output,
461        outpoint: boarding_outpoint,
462        ..
463    } in onchain_inputs.iter()
464    {
465        let (forfeit_script, forfeit_control_block) = boarding_output.forfeit_spend_info();
466
467        for (i, input) in commitment_psbt.inputs.iter_mut().enumerate() {
468            let previous_outpoint = commitment_psbt.unsigned_tx.input[i].previous_output;
469
470            if previous_outpoint == *boarding_outpoint {
471                // In the case of a boarding output, we are actually using a
472                // script spend path.
473
474                let leaf_version = forfeit_control_block.leaf_version;
475                input.tap_scripts = BTreeMap::from_iter([(
476                    forfeit_control_block.clone(),
477                    (forfeit_script.clone(), leaf_version),
478                )]);
479
480                let prevouts = Prevouts::All(&prevouts);
481
482                let leaf_hash = TapLeafHash::from_script(&forfeit_script, leaf_version);
483
484                let tap_sighash = SighashCache::new(&commitment_psbt.unsigned_tx)
485                    .taproot_script_spend_signature_hash(
486                        i,
487                        &prevouts,
488                        leaf_hash,
489                        TapSighashType::Default,
490                    )
491                    .map_err(Error::crypto)?;
492
493                let msg =
494                    secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
495                let pk = boarding_output.owner_pk();
496
497                let sig = sign_for_pk_fn(&pk, &msg)?;
498
499                secp.verify_schnorr(&sig, &msg, &pk)
500                    .map_err(Error::crypto)
501                    .context("failed to verify own commitment TX signature")?;
502
503                let sig = taproot::Signature {
504                    signature: sig,
505                    sighash_type: TapSighashType::Default,
506                };
507
508                input.tap_script_sigs.insert((pk, leaf_hash), sig);
509            }
510        }
511    }
512
513    Ok(())
514}
515
516/// Build a map between VTXOs and their corresponding connector outputs.
517fn derive_vtxo_connector_map(
518    vtxo_inputs: &[intent::Input],
519    connectors_leaves: &[&Psbt],
520    dust: Amount,
521) -> Result<HashMap<OutPoint, OutPoint>, Error> {
522    // Collect all connector outpoints (non-anchor outputs).
523    let mut connector_outpoints = Vec::new();
524    for psbt in connectors_leaves.iter() {
525        for (vout, output) in psbt.unsigned_tx.output.iter().enumerate() {
526            // Skip anchor outputs.
527            if output.value == Amount::ZERO {
528                continue;
529            }
530            connector_outpoints.push(OutPoint {
531                txid: psbt.unsigned_tx.compute_txid(),
532                vout: vout as u32,
533            });
534        }
535    }
536
537    // Sort connector outpoints for deterministic ordering
538    connector_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout)));
539
540    // Get virtual TX outpoints that need forfeiting (excluding sub-dust and swept).
541    let mut virtual_tx_outpoints = vtxo_inputs
542        .iter()
543        .filter_map(|vtxo_input| {
544            ((vtxo_input.amount() >= dust) && !vtxo_input.is_swept())
545                .then_some(vtxo_input.outpoint())
546        })
547        .collect::<Vec<_>>();
548
549    // Sort virtual TX outpoints for deterministic ordering.
550    virtual_tx_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout)));
551
552    if connector_outpoints.len() < virtual_tx_outpoints.len() {
553        return Err(Error::ad_hoc(format!(
554            "mismatch between VTXO count ({}) and connector count ({})",
555            virtual_tx_outpoints.len(),
556            connector_outpoints.len()
557        )));
558    }
559
560    // Create mapping by position.
561    let mut map = HashMap::new();
562    for (virtual_tx_outpoint, connector_outpoint) in
563        virtual_tx_outpoints.iter().zip(connector_outpoints.iter())
564    {
565        map.insert(*virtual_tx_outpoint, *connector_outpoint);
566    }
567
568    Ok(map)
569}
570
571fn extract_cosigner_pks_from_vtxo_psbt(psbt: &Psbt) -> Result<Vec<PublicKey>, Error> {
572    let vtxo_input = &psbt.inputs[VTXO_INPUT_INDEX];
573
574    let mut cosigner_pks = Vec::new();
575    for (key, pk) in vtxo_input.unknown.iter() {
576        if key.key.starts_with(&VTXO_COSIGNER_PSBT_KEY) {
577            cosigner_pks.push(
578                bitcoin::PublicKey::from_slice(pk)
579                    .map_err(Error::crypto)
580                    .context("invalid PK")?
581                    .inner,
582            );
583        }
584    }
585    Ok(cosigner_pks)
586}
587
588/// A delegate contains all the information necessary for another party to settle VTXOs on behalf of
589/// the owner.
590///
591/// The owner pre-signs the intent and forfeit transactions, allowing another party to complete the
592/// settlement at a later time.
593#[derive(Debug, Clone)]
594pub struct Delegate {
595    pub intent: Intent,
596    /// Partial forfeit transactions signed with SIGHASH_ALL | ANYONECANPAY.
597    pub forfeit_psbts: Vec<Psbt>,
598    /// The cosigner public key of the party who will execute the settlement as the delegate.
599    pub delegate_cosigner_pk: PublicKey,
600}
601
602/// Prepare unsigned intent and forfeit PSBTs for delegate.
603///
604/// This is step 1 of the delegate flow. Bob can prepare these PSBTs and send them to Alice for
605/// signing.
606///
607/// # Arguments
608///
609/// * `intent_inputs` - VTXO inputs to be settled
610/// * `outputs` - Desired outputs (typically back to the owner's address)
611/// * `delegate_cosigner_pk` - Public keys of cosigner who will participate in the settlement
612/// * `server_forfeit_address` - Address where forfeits are sent
613/// * `dust` - Dust amount for connectors
614///
615/// # Returns
616///
617/// A [`Delegate`] struct containing unsigned PSBTs ready for signing.
618pub fn prepare_delegate_psbts(
619    intent_inputs: Vec<intent::Input>,
620    outputs: Vec<intent::Output>,
621    delegate_cosigner_pk: PublicKey,
622    server_forfeit_address: &Address,
623    dust: Amount,
624) -> Result<Delegate, Error> {
625    prepare_delegate_psbts_at(
626        intent_inputs,
627        outputs,
628        delegate_cosigner_pk,
629        server_forfeit_address,
630        dust,
631        None,
632    )
633}
634
635/// Like [`prepare_delegate_psbts`], but with an explicit `valid_at` timestamp.
636///
637/// When delegating to a third-party service, `valid_at` is set to the time at which the delegator
638/// should execute the renewal (e.g. 90% through the VTXO's lifetime). In this case `expire_at` is
639/// set to `0` (no expiry), since the delegator holds the intent until `valid_at` arrives.
640///
641/// If `valid_at` is `None`, the current time is used and the intent expires in 2 minutes (same as
642/// [`prepare_delegate_psbts`]).
643pub fn prepare_delegate_psbts_at(
644    intent_inputs: Vec<intent::Input>,
645    outputs: Vec<intent::Output>,
646    delegate_cosigner_pk: PublicKey,
647    server_forfeit_address: &Address,
648    dust: Amount,
649    valid_at: Option<u64>,
650) -> Result<Delegate, Error> {
651    // Create intent message
652    let now = std::time::SystemTime::now();
653    let now = now
654        .duration_since(std::time::UNIX_EPOCH)
655        .map_err(Error::ad_hoc)
656        .context("failed to compute now timestamp")?;
657    let now = now.as_secs();
658
659    // When valid_at is explicit (delegator flow), the intent is held for future use — no expiry.
660    // When valid_at is None (P2P flow), expire after 2 minutes.
661    let (valid_at, expire_at) = match valid_at {
662        Some(vat) => (vat, 0),
663        None => (now, now + (2 * 60)),
664    };
665
666    let onchain_output_indexes = outputs
667        .iter()
668        .enumerate()
669        .filter_map(|(idx, output)| match output {
670            intent::Output::Onchain(_) => Some(idx),
671            intent::Output::Offchain(_) | intent::Output::AssetPacket(_) => None,
672        })
673        .collect();
674
675    let intent_message = intent::IntentMessage::Register {
676        onchain_output_indexes,
677        valid_at,
678        expire_at,
679        own_cosigner_pks: vec![delegate_cosigner_pk],
680    };
681
682    // Build the intent PSBT (unsigned)
683    let (mut intent_psbt, _fake_input) =
684        intent::build_proof_psbt(&intent_message, &intent_inputs, &outputs)?;
685
686    // Sign the intent PSBT
687    for (i, proof_input) in intent_psbt.inputs.iter_mut().enumerate() {
688        if i == 0 {
689            let (script, control_block) = intent_inputs[0].spend_info().clone();
690
691            proof_input.tap_scripts =
692                BTreeMap::from_iter([(control_block, (script, taproot::LeafVersion::TapScript))]);
693        } else {
694            let (script, control_block) = intent_inputs[i - 1].spend_info().clone();
695
696            let tap_tree = intent::taptree::TapTree(intent_inputs[i - 1].tapscripts().to_vec());
697            let bytes = tap_tree
698                .encode()
699                .map_err(Error::ad_hoc)
700                .with_context(|| format!("failed to encode taptree for input {i}"))?;
701
702            proof_input.unknown.insert(
703                psbt::raw::Key {
704                    type_value: 222,
705                    key: crate::VTXO_TAPROOT_KEY.to_vec(),
706                },
707                bytes,
708            );
709            proof_input.tap_scripts =
710                BTreeMap::from_iter([(control_block, (script, taproot::LeafVersion::TapScript))]);
711        };
712    }
713
714    // Build unsigned forfeit PSBTs
715    let mut forfeit_psbts = Vec::new();
716    const FORFEIT_TX_VTXO_INDEX: usize = 0;
717
718    for intent_input in intent_inputs.iter() {
719        // Skip swept or sub-dust VTXOs - they cannot be forfeited.
720        if intent_input.is_swept() || intent_input.amount() < dust {
721            continue;
722        }
723
724        let vtxo_amount = intent_input.amount();
725        let virtual_tx_outpoint = intent_input.outpoint();
726        let connector_amount = dust;
727
728        // Create partial forfeit transaction with only the VTXO input
729        let forfeit_output = TxOut {
730            value: vtxo_amount + connector_amount,
731            script_pubkey: server_forfeit_address.script_pubkey(),
732        };
733
734        let mut forfeit_psbt = Psbt::from_unsigned_tx(Transaction {
735            version: transaction::Version::non_standard(3),
736            lock_time: LockTime::ZERO,
737            input: vec![TxIn {
738                previous_output: virtual_tx_outpoint,
739                ..Default::default()
740            }],
741            output: vec![forfeit_output, anchor_output()],
742        })
743        .map_err(|e| Error::ad_hoc(format!("failed to create forfeit PSBT: {e}")))?;
744
745        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_utxo = Some(TxOut {
746            value: vtxo_amount,
747            script_pubkey: intent_input.script_pubkey().clone(),
748        });
749
750        // Set sighash type to SIGHASH_ALL | ANYONECANPAY
751        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].sighash_type = Some(
752            psbt::PsbtSighashType::from(TapSighashType::AllPlusAnyoneCanPay),
753        );
754
755        let (forfeit_script, forfeit_control_block) = intent_input.spend_info();
756        let leaf_version = forfeit_control_block.leaf_version;
757        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX]
758            .tap_scripts
759            .insert(
760                forfeit_control_block.clone(),
761                (forfeit_script.clone(), leaf_version),
762            );
763
764        forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_script = Some(forfeit_script.clone());
765
766        forfeit_psbts.push(forfeit_psbt);
767    }
768
769    let intent = Intent::new(intent_psbt, intent_message);
770
771    Ok(Delegate {
772        intent,
773        forfeit_psbts,
774        delegate_cosigner_pk,
775    })
776}
777
778/// Complete the delegated forfeit transactions by adding connector inputs and finalizing them.
779pub fn create_asset_preservation_packet(
780    inputs: &[intent::Input],
781    outputs: &[intent::Output],
782) -> Result<Option<packet::Packet>, Error> {
783    const INTENT_PROOF_FAKE_INPUT_INDEX_OFFSET: u16 = 1;
784
785    let mut groups: Vec<packet::AssetGroup> = Vec::new();
786
787    let preserved_output_index =
788        outputs
789            .iter()
790            .enumerate()
791            .find_map(|(index, output)| match output {
792                intent::Output::Offchain(_) => Some(index as u16),
793                intent::Output::Onchain(_) | intent::Output::AssetPacket(_) => None,
794            });
795
796    for (input_index, input) in inputs.iter().enumerate() {
797        for asset in input.assets() {
798            if let Some(group) = groups
799                .iter_mut()
800                .find(|group| group.asset_id == Some(asset.asset_id))
801            {
802                group.inputs.push(packet::AssetInput {
803                    input_index: input_index as u16 + INTENT_PROOF_FAKE_INPUT_INDEX_OFFSET,
804                    amount: asset.amount,
805                });
806
807                if let Some(output) = group.outputs.first_mut() {
808                    output.amount = output.amount.checked_add(asset.amount).ok_or_else(|| {
809                        Error::ad_hoc("asset amount overflow while preserving assets in settlement")
810                    })?;
811                }
812            } else {
813                let mut asset_outputs = Vec::new();
814                match preserved_output_index {
815                    Some(output_index) => asset_outputs.push(packet::AssetOutput {
816                        output_index,
817                        amount: asset.amount,
818                    }),
819                    None => {
820                        return Err(Error::ad_hoc(
821                            "cannot preserve assets in settlement without an offchain output",
822                        ))
823                    }
824                }
825
826                groups.push(packet::AssetGroup {
827                    asset_id: Some(asset.asset_id),
828                    control_asset: None,
829                    metadata: None,
830                    inputs: vec![packet::AssetInput {
831                        input_index: input_index as u16 + INTENT_PROOF_FAKE_INPUT_INDEX_OFFSET,
832                        amount: asset.amount,
833                    }],
834                    outputs: asset_outputs,
835                });
836            }
837        }
838    }
839
840    if groups.is_empty() {
841        return Ok(None);
842    }
843
844    groups.sort_by_key(|group| {
845        let asset_id = group
846            .asset_id
847            .expect("asset-preservation groups always have asset ids");
848        (*asset_id.txid.as_byte_array(), asset_id.group_index)
849    });
850
851    Ok(Some(packet::Packet { groups }))
852}
853
854pub fn complete_delegate_forfeit_txs(
855    forfeit_psbts: &[Psbt],
856    connectors_leaves: &[&Psbt],
857) -> Result<Vec<Psbt>, Error> {
858    const FORFEIT_TX_CONNECTOR_INDEX: usize = 0;
859    const FORFEIT_TX_VTXO_INDEX: usize = 1;
860
861    let connector_index = derive_vtxo_connector_map_delegate(
862        forfeit_psbts
863            .iter()
864            .map(|psbt| psbt.unsigned_tx.input[0].previous_output)
865            .collect(),
866        connectors_leaves,
867    )?;
868
869    let mut completed_forfeit_psbts = Vec::new();
870
871    for forfeit_psbt in forfeit_psbts.iter() {
872        let virtual_tx_outpoint = forfeit_psbt.unsigned_tx.input[0].previous_output;
873
874        let connector_outpoint = connector_index.get(&virtual_tx_outpoint).ok_or_else(|| {
875            Error::ad_hoc(format!(
876                "connector outpoint missing for virtual TX outpoint {virtual_tx_outpoint}",
877            ))
878        })?;
879
880        let connector_psbt = connectors_leaves
881            .iter()
882            .find(|l| l.unsigned_tx.compute_txid() == connector_outpoint.txid)
883            .ok_or_else(|| {
884                Error::ad_hoc(format!(
885                    "connector PSBT missing for virtual TX outpoint {virtual_tx_outpoint}",
886                ))
887            })?;
888
889        let connector_output = connector_psbt
890            .unsigned_tx
891            .output
892            .get(connector_outpoint.vout as usize)
893            .ok_or_else(|| {
894                Error::ad_hoc(format!(
895                    "connector output missing for virtual TX outpoint {virtual_tx_outpoint}",
896                ))
897            })?;
898
899        // Add the connector input to the partial forfeit transaction
900        let mut completed_tx = forfeit_psbt.unsigned_tx.clone();
901        completed_tx.input.insert(
902            FORFEIT_TX_CONNECTOR_INDEX,
903            TxIn {
904                previous_output: *connector_outpoint,
905                ..Default::default()
906            },
907        );
908
909        let mut completed_psbt = Psbt::from_unsigned_tx(completed_tx)
910            .map_err(|e| Error::ad_hoc(format!("failed to create PSBT from unsigned tx: {e}")))?;
911
912        // Copy the VTXO input data from the partial PSBT
913        completed_psbt.inputs[FORFEIT_TX_VTXO_INDEX] = forfeit_psbt.inputs[0].clone();
914
915        // Add connector input data
916        completed_psbt.inputs[FORFEIT_TX_CONNECTOR_INDEX].witness_utxo =
917            Some(connector_output.clone());
918
919        // Copy outputs from partial PSBT
920        completed_psbt.outputs = forfeit_psbt.outputs.clone();
921
922        completed_forfeit_psbts.push(completed_psbt);
923    }
924
925    Ok(completed_forfeit_psbts)
926}
927
928/// Build a map between virtual TX outpoints and their corresponding connector outputs.
929fn derive_vtxo_connector_map_delegate(
930    mut virtual_tx_outpoints: Vec<OutPoint>,
931    connectors_leaves: &[&Psbt],
932) -> Result<HashMap<OutPoint, OutPoint>, Error> {
933    // Collect all connector outpoints (non-anchor outputs).
934    let mut connector_outpoints = Vec::new();
935    for psbt in connectors_leaves.iter() {
936        for (vout, output) in psbt.unsigned_tx.output.iter().enumerate() {
937            // Skip anchor outputs.
938            if output.value == Amount::ZERO {
939                continue;
940            }
941            connector_outpoints.push(OutPoint {
942                txid: psbt.unsigned_tx.compute_txid(),
943                vout: vout as u32,
944            });
945        }
946    }
947
948    // Sort connector outpoints for deterministic ordering
949    connector_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout)));
950
951    // Sort virtual TX outpoints for deterministic ordering.
952    virtual_tx_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout)));
953
954    // Ensure we have matching counts.
955    if connector_outpoints.len() < virtual_tx_outpoints.len() {
956        return Err(Error::ad_hoc(format!(
957            "mismatch between VTXO count ({}) and connector count ({})",
958            virtual_tx_outpoints.len(),
959            connector_outpoints.len()
960        )));
961    }
962
963    // Create mapping by position.
964    let mut map = HashMap::new();
965    for (virtual_tx_outpoint, connector_outpoint) in
966        virtual_tx_outpoints.iter().zip(connector_outpoints.iter())
967    {
968        map.insert(*virtual_tx_outpoint, *connector_outpoint);
969    }
970
971    Ok(map)
972}
973
974/// Sign delegate PSBTs.
975///
976/// # Errors
977///
978/// Returns an error if signing fails.
979pub fn sign_delegate_psbts<S>(
980    mut sign_fn: S,
981    intent_psbt: &mut Psbt,
982    forfeit_psbts: &mut [Psbt],
983) -> Result<(), Error>
984where
985    S: FnMut(
986        &mut psbt::Input,
987        secp256k1::Message,
988    ) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, Error>,
989{
990    let prevouts = intent_psbt
991        .inputs
992        .iter()
993        .filter_map(|i| i.witness_utxo.clone())
994        .collect::<Vec<_>>();
995
996    for (i, psbt_input) in intent_psbt.inputs.iter_mut().enumerate() {
997        let prevouts = Prevouts::All(&prevouts);
998
999        let (_, (script, leaf_version)) =
1000            psbt_input.tap_scripts.first_key_value().expect("a value");
1001
1002        let leaf_hash = TapLeafHash::from_script(script, *leaf_version);
1003
1004        let tap_sighash = SighashCache::new(&intent_psbt.unsigned_tx)
1005            .taproot_script_spend_signature_hash(i, &prevouts, leaf_hash, TapSighashType::Default)
1006            .map_err(Error::crypto)
1007            .with_context(|| format!("failed to compute sighash for intent input {i}"))?;
1008
1009        let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
1010
1011        let sigs =
1012            sign_fn(psbt_input, msg).with_context(|| format!("failed to sign intent input {i}"))?;
1013        for (sig, pk) in sigs {
1014            let sig = taproot::Signature {
1015                signature: sig,
1016                sighash_type: TapSighashType::Default,
1017            };
1018
1019            psbt_input.tap_script_sigs.insert((pk, leaf_hash), sig);
1020        }
1021    }
1022
1023    // Sign the forfeit PSBTs
1024    const FORFEIT_TX_VTXO_INDEX: usize = 0;
1025
1026    for forfeit_psbt in forfeit_psbts {
1027        let prevouts = forfeit_psbt
1028            .inputs
1029            .iter()
1030            .filter_map(|i| i.witness_utxo.clone())
1031            .collect::<Vec<_>>();
1032        let prevouts = Prevouts::All(&prevouts);
1033
1034        let psbt_input = forfeit_psbt
1035            .inputs
1036            .get_mut(FORFEIT_TX_VTXO_INDEX)
1037            .expect("input at index");
1038
1039        let (_, (forfeit_script, leaf_version)) =
1040            psbt_input.tap_scripts.first_key_value().expect("one entry");
1041
1042        let leaf_hash = TapLeafHash::from_script(forfeit_script, *leaf_version);
1043
1044        let tap_sighash = SighashCache::new(&forfeit_psbt.unsigned_tx)
1045            .taproot_script_spend_signature_hash(
1046                FORFEIT_TX_VTXO_INDEX,
1047                &prevouts,
1048                leaf_hash,
1049                TapSighashType::AllPlusAnyoneCanPay,
1050            )
1051            .map_err(|e| Error::ad_hoc(format!("failed to compute forfeit sighash: {e}")))?;
1052
1053        let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
1054
1055        let sigs =
1056            sign_fn(&mut forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX], msg).with_context(|| {
1057                format!(
1058                    "failed to sign forfeit PSBT {}",
1059                    forfeit_psbt.unsigned_tx.compute_txid()
1060                )
1061            })?;
1062
1063        for (sig, pk) in sigs {
1064            let sig = taproot::Signature {
1065                signature: sig,
1066                sighash_type: TapSighashType::AllPlusAnyoneCanPay,
1067            };
1068
1069            forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX]
1070                .tap_script_sigs
1071                .insert((pk, leaf_hash), sig);
1072        }
1073    }
1074
1075    Ok(())
1076}