Skip to main content

ark_core/
send.rs

1use crate::anchor_output;
2use crate::asset;
3use crate::asset::packet::add_asset_packet_to_psbt;
4use crate::asset::AssetId;
5use crate::contract::SpendSelection;
6use crate::script::tr_script_pubkey;
7use crate::server;
8use crate::ArkAddress;
9use crate::Asset;
10use crate::Error;
11use crate::ErrorContext;
12use crate::UNSPENDABLE_KEY;
13use crate::VTXO_TAPROOT_KEY;
14use bitcoin::absolute::LockTime;
15use bitcoin::hashes::Hash;
16use bitcoin::key::PublicKey;
17use bitcoin::key::Secp256k1;
18use bitcoin::psbt;
19use bitcoin::secp256k1;
20use bitcoin::secp256k1::schnorr;
21use bitcoin::sighash::Prevouts;
22use bitcoin::sighash::SighashCache;
23use bitcoin::taproot;
24use bitcoin::taproot::ControlBlock;
25use bitcoin::taproot::LeafVersion;
26use bitcoin::taproot::TaprootBuilder;
27use bitcoin::taproot::TaprootSpendInfo;
28use bitcoin::transaction;
29use bitcoin::Amount;
30use bitcoin::OutPoint;
31use bitcoin::Psbt;
32use bitcoin::ScriptBuf;
33use bitcoin::TapLeafHash;
34use bitcoin::TapSighashType;
35use bitcoin::Transaction;
36use bitcoin::TxIn;
37use bitcoin::TxOut;
38use bitcoin::XOnlyPublicKey;
39use std::collections::BTreeMap;
40use std::collections::HashMap;
41use std::io;
42use std::io::Write;
43
44pub mod issue_asset;
45pub mod reissue_asset;
46
47pub use issue_asset::build_self_asset_issuance_transactions;
48pub use issue_asset::SelfAssetIssuanceTransactions;
49pub use reissue_asset::build_asset_reissuance_transactions;
50pub use reissue_asset::AssetReissuanceTransactions;
51
52/// A VTXO to be spent into a pre-confirmed VTXO.
53#[derive(Debug, Clone)]
54pub struct VtxoInput {
55    /// The script path that will be used to spend the [`Vtxo`].
56    ///
57    /// The very same spend path is also used when building the corresponding checkpoint output.
58    spend_script: ScriptBuf,
59    /// An optional locktime, only set if the `spend_script` uses `OP_CLTV`.
60    // TODO: Parse this information from the script instead.
61    locktime: Option<LockTime>,
62    control_block: ControlBlock,
63    /// All the scripts in the Taproot tree.
64    tapscripts: Vec<ScriptBuf>,
65    script_pubkey: ScriptBuf,
66    /// The amount of coins locked in the VTXO.
67    amount: Amount,
68    /// Where the VTXO would end up on the blockchain if it were to become a UTXO.
69    outpoint: OutPoint,
70    /// All the assets carried by this VTXO.
71    assets: Vec<Asset>,
72}
73
74impl VtxoInput {
75    pub fn new(
76        vtxo_spend_script: ScriptBuf,
77        locktime: Option<LockTime>,
78        control_block: ControlBlock,
79        tapscripts: Vec<ScriptBuf>,
80        script_pubkey: ScriptBuf,
81        amount: Amount,
82        outpoint: OutPoint,
83        assets: Vec<Asset>,
84    ) -> Self {
85        Self {
86            spend_script: vtxo_spend_script,
87            locktime,
88            control_block,
89            tapscripts,
90            script_pubkey,
91            amount,
92            outpoint,
93            assets,
94        }
95    }
96
97    pub fn new_with_spend_selection(
98        spend_selection: SpendSelection,
99        tapscripts: Vec<ScriptBuf>,
100        script_pubkey: ScriptBuf,
101        amount: Amount,
102        outpoint: OutPoint,
103        assets: Vec<Asset>,
104    ) -> Self {
105        let (spend_script, control_block) = spend_selection.spend_info();
106        Self::new(
107            spend_script,
108            spend_selection.locktime,
109            control_block,
110            tapscripts,
111            script_pubkey,
112            amount,
113            outpoint,
114            assets,
115        )
116    }
117
118    pub fn outpoint(&self) -> OutPoint {
119        self.outpoint
120    }
121
122    pub fn spend_info(&self) -> (&ScriptBuf, &ControlBlock) {
123        (&self.spend_script, &self.control_block)
124    }
125
126    pub fn script_pubkey(&self) -> ScriptBuf {
127        self.script_pubkey.clone()
128    }
129
130    pub fn amount(&self) -> Amount {
131        self.amount
132    }
133
134    pub fn assets(&self) -> &[Asset] {
135        &self.assets
136    }
137}
138
139/// A receiver for a generic offchain send with optional assets.
140#[derive(Debug, Clone)]
141pub struct SendReceiver {
142    pub address: ArkAddress,
143    pub amount: Amount,
144    pub assets: Vec<Asset>,
145}
146
147impl SendReceiver {
148    pub fn bitcoin(address: ArkAddress, amount: Amount) -> Self {
149        Self {
150            address,
151            amount,
152            assets: Vec::new(),
153        }
154    }
155}
156
157#[derive(Debug, Clone)]
158pub struct OffchainTransactions {
159    pub ark_tx: Psbt,
160    pub checkpoint_txs: Vec<Psbt>,
161}
162
163/// Build a transaction to send VTXOs to another [`ArkAddress`].
164pub(crate) fn btc_change_output_index(ark_tx: &Psbt, num_receiver_outputs: usize) -> Option<u16> {
165    (ark_tx.unsigned_tx.output.len() > num_receiver_outputs + 1)
166        .then_some((ark_tx.unsigned_tx.output.len() - 2) as u16)
167}
168
169/// Build unsigned offchain transactions for sending BTC to one or more receivers.
170///
171/// Receiver outputs are assigned in the same order as `receivers`, followed by an optional BTC
172/// change output and the final anchor output.
173///
174/// # Arguments
175///
176/// * `receivers` - Offchain recipients and the BTC amounts assigned to each transaction output. Any
177///   assets carried on [`SendReceiver`] values are ignored by this builder.
178/// * `change_address` - The sender's offchain change address, used if the transaction has BTC
179///   change
180/// * `vtxo_inputs` - The selected VTXO inputs to spend, together with any assets they already carry
181/// * `server_info` - Server configuration used to build the offchain transaction shape and dust
182///   output
183///
184/// # Returns
185///
186/// [`OffchainTransactions`] containing the unsigned Ark transaction and unsigned checkpoint
187/// transactions.
188///
189/// This function is intentionally packet-agnostic: it builds the BTC transaction skeleton only and
190/// does not attach an asset packet. Callers that need asset semantics should either add exactly
191/// one packet themselves or use [`build_asset_send_transactions`] for the generic asset-send flow.
192///
193/// # Errors
194///
195/// Returns an error if unsigned offchain transaction construction fails.
196pub fn build_offchain_transactions(
197    receivers: &[SendReceiver],
198    change_address: &ArkAddress,
199    vtxo_inputs: &[VtxoInput],
200    server_info: &server::Info,
201) -> Result<OffchainTransactions, Error> {
202    if vtxo_inputs.is_empty() {
203        return Err(Error::transaction(
204            "cannot build Ark transaction without inputs",
205        ));
206    }
207
208    let vtxo_min_amount = server_info.vtxo_min_amount.unwrap_or(Amount::ONE_SAT);
209    if receivers
210        .iter()
211        .any(|SendReceiver { amount, .. }| *amount < vtxo_min_amount)
212    {
213        return Err(Error::transaction(format!(
214            "output amount smaller than minimum of {vtxo_min_amount}"
215        )));
216    }
217
218    let checkpoint_script = &server_info.checkpoint_tapscript;
219
220    let mut checkpoint_data = Vec::new();
221    for vtxo_input in vtxo_inputs.iter() {
222        let (psbt, spend_info) = build_checkpoint_psbt(vtxo_input, checkpoint_script.clone())
223            .with_context(|| {
224                format!(
225                    "failed to build checkpoint psbt for input {:?}",
226                    vtxo_input.outpoint
227                )
228            })?;
229
230        checkpoint_data.push((psbt, spend_info));
231    }
232
233    let mut outputs = receivers
234        .iter()
235        .map(
236            |SendReceiver {
237                 address, amount, ..
238             }| {
239                if *amount >= server_info.dust {
240                    TxOut {
241                        value: *amount,
242                        script_pubkey: address.to_p2tr_script_pubkey(),
243                    }
244                } else {
245                    TxOut {
246                        value: *amount,
247                        script_pubkey: address.to_sub_dust_script_pubkey(),
248                    }
249                }
250            },
251        )
252        .collect::<Vec<_>>();
253
254    let total_input_amount: Amount = vtxo_inputs.iter().map(|v| v.amount).sum();
255    let total_output_amount: Amount = outputs.iter().map(|v| v.value).sum();
256
257    let change_amount = total_input_amount.checked_sub(total_output_amount).ok_or_else(|| {
258        Error::transaction(format!(
259            "cannot cover total output amount ({total_output_amount}) with total input amount ({total_input_amount})"
260        ))
261    })?;
262
263    if change_amount > Amount::ZERO {
264        if change_amount >= server_info.dust {
265            outputs.push(TxOut {
266                value: change_amount,
267                script_pubkey: change_address.to_p2tr_script_pubkey(),
268            })
269        } else {
270            outputs.push(TxOut {
271                value: change_amount,
272                script_pubkey: change_address.to_sub_dust_script_pubkey(),
273            })
274        }
275    }
276
277    outputs.push(anchor_output());
278
279    let timelocked_inputs = vtxo_inputs
280        .iter()
281        .filter_map(|x| x.locktime)
282        .collect::<Vec<_>>();
283
284    let highest_timelock = timelocked_inputs
285        .iter()
286        .try_fold(None, |acc, a| match (acc, a) {
287            (None, locktime) => Ok(Some(*locktime)),
288            (Some(a @ LockTime::Blocks(h1)), LockTime::Blocks(h2)) if h1 > *h2 => Ok(Some(a)),
289            (Some(LockTime::Blocks(_)), b @ LockTime::Blocks(_)) => Ok(Some(*b)),
290            (Some(a @ LockTime::Seconds(t1)), LockTime::Seconds(t2)) if t1 > *t2 => Ok(Some(a)),
291            (Some(LockTime::Seconds(_)), b @ LockTime::Seconds(_)) => Ok(Some(*b)),
292            _ => Err(Error::transaction("incompatible locktimes")),
293        })?;
294
295    let (lock_time, sequence) = match highest_timelock {
296        Some(timelock) => (timelock, bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF),
297        None => (LockTime::ZERO, bitcoin::Sequence::MAX),
298    };
299
300    let unsigned_ark_tx = Transaction {
301        version: transaction::Version::non_standard(3),
302        lock_time,
303        input: checkpoint_data
304            .iter()
305            .map(|(psbt, _)| TxIn {
306                previous_output: OutPoint {
307                    txid: psbt.unsigned_tx.compute_txid(),
308                    vout: 0,
309                },
310                script_sig: Default::default(),
311                sequence,
312                witness: Default::default(),
313            })
314            .collect(),
315        output: outputs,
316    };
317
318    let mut unsigned_ark_psbt =
319        Psbt::from_unsigned_tx(unsigned_ark_tx).map_err(Error::transaction)?;
320
321    for (i, (checkpoint_psbt, checkpoint_spend_info)) in checkpoint_data.iter().enumerate() {
322        // Set checkpoint output as `witness_utxo` field.
323
324        unsigned_ark_psbt.inputs[i].witness_utxo =
325            Some(checkpoint_psbt.unsigned_tx.output[0].clone());
326
327        // Set script to be used in `tap_scripts` field for spending the checkpoint output.
328
329        let vtxo_spend_script = &vtxo_inputs[i].spend_script;
330        let leaf_version = LeafVersion::TapScript;
331        let control_block = checkpoint_spend_info
332            .spend_info
333            .control_block(&(vtxo_spend_script.clone(), leaf_version))
334            .expect("control block for vtxo spend script");
335
336        unsigned_ark_psbt.inputs[i].tap_scripts =
337            BTreeMap::from_iter([(control_block, (vtxo_spend_script.clone(), leaf_version))]);
338
339        // Add _all_ the scripts in the Taproot tree to custom unknown field.
340
341        let mut bytes = Vec::new();
342
343        let spend_script = &vtxo_inputs[i].spend_script;
344        let scripts = [spend_script.clone(), checkpoint_script.clone()];
345
346        for script in scripts {
347            // Write the depth (always 1). TODO: Support more depth.
348            bytes.push(1);
349
350            // TODO: Support future leaf versions.
351            bytes.push(LeafVersion::TapScript.to_consensus());
352
353            let mut script_bytes = script.to_bytes();
354
355            write_compact_size_uint(&mut bytes, script_bytes.len() as u64)
356                .map_err(Error::transaction)?;
357
358            bytes.append(&mut script_bytes);
359        }
360
361        unsigned_ark_psbt.inputs[i].unknown.insert(
362            psbt::raw::Key {
363                type_value: 222,
364                key: VTXO_TAPROOT_KEY.to_vec(),
365            },
366            bytes,
367        );
368        unsigned_ark_psbt.inputs[i].witness_script = Some(spend_script.clone());
369    }
370
371    Ok(OffchainTransactions {
372        ark_tx: unsigned_ark_psbt,
373        checkpoint_txs: checkpoint_data.into_iter().map(|(psbt, _)| psbt).collect(),
374    })
375}
376
377#[derive(Debug, Clone)]
378struct CheckpointSpendInfo {
379    spend_info: TaprootSpendInfo,
380}
381
382impl CheckpointSpendInfo {
383    fn new(vtxo_input: &VtxoInput, checkpoint_exit_script: ScriptBuf) -> Self {
384        let secp = Secp256k1::new();
385
386        let unspendable_key: PublicKey = UNSPENDABLE_KEY.parse().expect("valid key");
387        let (unspendable_key, _) = unspendable_key.inner.x_only_public_key();
388
389        let vtxo_spend_script = &vtxo_input.spend_script;
390
391        let spend_info = TaprootBuilder::new()
392            .add_leaf(1, vtxo_spend_script.clone())
393            .expect("valid spend leaf")
394            .add_leaf(1, checkpoint_exit_script)
395            .expect("valid exit leaf")
396            .finalize(&secp, unspendable_key)
397            .expect("can be finalized");
398
399        Self { spend_info }
400    }
401
402    fn script_pubkey(&self) -> ScriptBuf {
403        tr_script_pubkey(&self.spend_info)
404    }
405}
406
407fn build_checkpoint_psbt(
408    vtxo_input: &VtxoInput,
409    // An alternative way for the _server_ to unilaterally spend the checkpoint output, in case the
410    // owner does not spend it.
411    //
412    // This is defined by the Ark server.
413    checkpoint_exit_script: ScriptBuf,
414) -> Result<(Psbt, CheckpointSpendInfo), Error> {
415    let (lock_time, sequence) = match vtxo_input.locktime {
416        Some(timelock) => (timelock, bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF),
417        None => (LockTime::ZERO, bitcoin::Sequence::MAX),
418    };
419
420    let inputs = vec![TxIn {
421        previous_output: vtxo_input.outpoint,
422        script_sig: Default::default(),
423        sequence,
424        witness: Default::default(),
425    }];
426
427    let checkpoint_spend_info = CheckpointSpendInfo::new(vtxo_input, checkpoint_exit_script);
428
429    let outputs = vec![
430        TxOut {
431            value: vtxo_input.amount,
432            script_pubkey: checkpoint_spend_info.script_pubkey(),
433        },
434        anchor_output(),
435    ];
436
437    let unsigned_tx = Transaction {
438        version: transaction::Version::non_standard(3),
439        lock_time,
440        input: inputs,
441        output: outputs,
442    };
443
444    let mut unsigned_checkpoint_psbt =
445        Psbt::from_unsigned_tx(unsigned_tx).map_err(Error::transaction)?;
446
447    // Set VTXO being spent as `witness_utxo` field.
448
449    unsigned_checkpoint_psbt.inputs[0].witness_utxo = Some(TxOut {
450        value: vtxo_input.amount,
451        script_pubkey: vtxo_input.script_pubkey.clone(),
452    });
453
454    // Set script to be used in `tap_scripts` field for spending the VTXO.
455
456    let (vtxo_spend_script, vtxo_spend_control_block) = vtxo_input.spend_info();
457
458    let leaf_version = vtxo_spend_control_block.leaf_version;
459    unsigned_checkpoint_psbt.inputs[0].tap_scripts = BTreeMap::from_iter([(
460        vtxo_spend_control_block.clone(),
461        (vtxo_spend_script.clone(), leaf_version),
462    )]);
463
464    // Add _all_ the scripts in the Taproot tree to custom unknown field.
465
466    let mut bytes = Vec::new();
467
468    for script in vtxo_input.tapscripts.iter() {
469        // Write the depth (always 1). TODO: Support more depth.
470        bytes.push(1);
471
472        // TODO: Support future leaf versions.
473        bytes.push(LeafVersion::TapScript.to_consensus());
474
475        let mut script_bytes = script.to_bytes();
476
477        write_compact_size_uint(&mut bytes, script_bytes.len() as u64)
478            .map_err(Error::transaction)?;
479
480        bytes.append(&mut script_bytes);
481    }
482
483    unsigned_checkpoint_psbt.inputs[0].unknown.insert(
484        psbt::raw::Key {
485            type_value: 222,
486            key: VTXO_TAPROOT_KEY.to_vec(),
487        },
488        bytes,
489    );
490    unsigned_checkpoint_psbt.inputs[0].witness_script = Some(vtxo_spend_script.clone());
491
492    Ok((unsigned_checkpoint_psbt, checkpoint_spend_info))
493}
494
495fn write_compact_size_uint<W: Write>(w: &mut W, val: u64) -> io::Result<()> {
496    if val < 253 {
497        w.write_all(&[val as u8])?;
498    } else if val < 0x10000 {
499        w.write_all(&[253])?;
500        w.write_all(&(val as u16).to_le_bytes())?;
501    } else if val < 0x100000000 {
502        w.write_all(&[254])?;
503        w.write_all(&(val as u32).to_le_bytes())?;
504    } else {
505        w.write_all(&[255])?;
506        w.write_all(&val.to_le_bytes())?;
507    }
508    Ok(())
509}
510
511// TODO: Sign checkpoint and sign Ark are basically the same. We can combine them, probably.
512pub fn sign_checkpoint_transaction<S>(sign_fn: S, psbt: &mut Psbt) -> Result<(), Error>
513where
514    S: FnOnce(
515        &mut psbt::Input,
516        secp256k1::Message,
517    ) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, Error>,
518{
519    let witness_utxo = [psbt.inputs[0].witness_utxo.clone().expect("witness UTXO")];
520    let prevouts = Prevouts::All(&witness_utxo);
521
522    let psbt_input = psbt.inputs.get_mut(0).expect("input at index");
523
524    let (_, (vtxo_spend_script, leaf_version)) =
525        psbt_input.tap_scripts.first_key_value().expect("one entry");
526
527    let leaf_hash = TapLeafHash::from_script(vtxo_spend_script, *leaf_version);
528
529    let tap_sighash = SighashCache::new(&psbt.unsigned_tx)
530        .taproot_script_spend_signature_hash(0, &prevouts, leaf_hash, TapSighashType::Default)
531        .map_err(Error::crypto)
532        .context("failed to generate sighash")?;
533
534    let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
535
536    let sigs = sign_fn(psbt_input, msg)?;
537    for (sig, pk) in sigs {
538        let sig = taproot::Signature {
539            signature: sig,
540            sighash_type: TapSighashType::Default,
541        };
542
543        psbt_input.tap_script_sigs.insert((pk, leaf_hash), sig);
544    }
545
546    Ok(())
547}
548
549pub fn sign_ark_transaction<S>(sign_fn: S, psbt: &mut Psbt, input_index: usize) -> Result<(), Error>
550where
551    S: FnOnce(
552        &mut psbt::Input,
553        secp256k1::Message,
554    ) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, Error>,
555{
556    tracing::debug!(index = input_index, "Signing Ark transaction input");
557
558    let witness_utxos = psbt
559        .inputs
560        .iter()
561        .map(|i| i.witness_utxo.clone().expect("witness UTXO"))
562        .collect::<Vec<_>>();
563
564    let psbt_input = psbt.inputs.get_mut(input_index).expect("input at index");
565
566    // To spend a checkpoint output we are using a script spend path.
567
568    let prevouts = Prevouts::All(&witness_utxos);
569
570    let (_, (vtxo_spend_script, leaf_version)) =
571        psbt_input.tap_scripts.first_key_value().expect("one entry");
572
573    let leaf_hash = TapLeafHash::from_script(vtxo_spend_script, *leaf_version);
574
575    let tap_sighash = SighashCache::new(&psbt.unsigned_tx)
576        .taproot_script_spend_signature_hash(
577            input_index,
578            &prevouts,
579            leaf_hash,
580            TapSighashType::Default,
581        )
582        .map_err(Error::crypto)
583        .context("failed to generate sighash")?;
584
585    let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
586
587    let sigs = sign_fn(psbt_input, msg)?;
588    for (sig, pk) in sigs {
589        let sig = taproot::Signature {
590            signature: sig,
591            sighash_type: TapSighashType::Default,
592        };
593
594        psbt_input.tap_script_sigs.insert((pk, leaf_hash), sig);
595    }
596
597    Ok(())
598}
599
600/// Build unsigned offchain transactions for sending BTC and optional assets to one or more
601/// receivers.
602///
603/// We first build the BTC transaction skeleton via [`build_offchain_transactions`] and then, if the
604/// transfer actually involves assets, add exactly one asset packet that:
605///
606/// - routes each requested asset amount to the corresponding receiver output index
607/// - preserves leftover carried assets on the BTC change output
608///
609/// Specialized flows such as issuance, reissuance, and burn should call
610/// [`build_offchain_transactions`] directly and attach their own packet semantics explicitly.
611///
612/// # Errors
613///
614/// Returns an error if BTC transaction construction fails, if a receiver references an asset that
615/// is not present in the selected inputs, if the requested amount for any asset exceeds the
616/// selected input amount for that asset, or if leftover assets would need to be preserved but the
617/// transaction has no BTC change output.
618pub fn build_asset_send_transactions(
619    receivers: &[SendReceiver],
620    change_address: &ArkAddress,
621    vtxo_inputs: &[VtxoInput],
622    server_info: &server::Info,
623) -> Result<OffchainTransactions, Error> {
624    let mut offchain =
625        build_offchain_transactions(receivers, change_address, vtxo_inputs, server_info)?;
626
627    if let Some(packet) = create_send_packet(vtxo_inputs, receivers, &offchain.ark_tx)? {
628        add_asset_packet_to_psbt(&mut offchain.ark_tx, &packet)?;
629    }
630
631    Ok(offchain)
632}
633
634/// Build unsigned offchain transactions for burning a specific amount of an asset.
635///
636/// The burn is represented by consuming the selected asset amount from the chosen inputs without
637/// creating a corresponding asset output. Any remaining carried assets are preserved on the BTC
638/// change output.
639///
640/// # Errors
641///
642/// Returns an error if BTC transaction construction fails, if the selected inputs do not contain
643/// the asset to burn, if the selected amount for the burned asset is insufficient, or if leftover
644/// carried assets would need to be preserved but the transaction has no BTC change output.
645pub fn build_asset_burn_transactions(
646    own_address: &ArkAddress,
647    change_address: &ArkAddress,
648    vtxo_inputs: &[VtxoInput],
649    server_info: &server::Info,
650    burn_asset_id: AssetId,
651    burn_amount: u64,
652) -> Result<OffchainTransactions, Error> {
653    let mut offchain = build_offchain_transactions(
654        &[SendReceiver {
655            address: *own_address,
656            amount: server_info.dust,
657            assets: Vec::new(),
658        }],
659        change_address,
660        vtxo_inputs,
661        server_info,
662    )?;
663
664    if let Some(packet) =
665        create_burn_packet(vtxo_inputs, burn_asset_id, burn_amount, &offchain.ark_tx)?
666    {
667        add_asset_packet_to_psbt(&mut offchain.ark_tx, &packet)?;
668    }
669
670    Ok(offchain)
671}
672
673/// Create the asset packet for a generic asset send.
674///
675/// Receiver asset allocations are assigned to their corresponding receiver output indexes. Any
676/// leftover carried assets are preserved on the BTC change output when one exists.
677fn create_send_packet(
678    inputs: &[VtxoInput],
679    receivers: &[SendReceiver],
680    ark_tx: &Psbt,
681) -> Result<Option<asset::packet::Packet>, Error> {
682    struct AssetTransfer {
683        inputs: Vec<asset::packet::AssetInput>,
684        outputs: Vec<asset::packet::AssetOutput>,
685        input_amount: u64,
686        requested_amount: u64,
687    }
688
689    let mut transfers: HashMap<AssetId, AssetTransfer> = HashMap::new();
690
691    for (input_index, input) in inputs.iter().enumerate() {
692        for asset in &input.assets {
693            let transfer = transfers
694                .entry(asset.asset_id)
695                .or_insert_with(|| AssetTransfer {
696                    inputs: Vec::new(),
697                    outputs: Vec::new(),
698                    input_amount: 0,
699                    requested_amount: 0,
700                });
701
702            transfer.inputs.push(asset::packet::AssetInput {
703                input_index: input_index as u16,
704                amount: asset.amount,
705            });
706
707            transfer.input_amount = transfer
708                .input_amount
709                .checked_add(asset.amount)
710                .ok_or_else(|| Error::ad_hoc("asset input amount overflow"))?;
711        }
712    }
713
714    let any_receiver_assets = receivers.iter().any(|receiver| !receiver.assets.is_empty());
715    if transfers.is_empty() && !any_receiver_assets {
716        return Ok(None);
717    }
718
719    for (receiver_index, receiver) in receivers.iter().enumerate() {
720        for asset in &receiver.assets {
721            let transfer = transfers.get_mut(&asset.asset_id).ok_or_else(|| {
722                Error::ad_hoc(format!(
723                    "receiver references asset {} that is not present in selected inputs",
724                    asset.asset_id
725                ))
726            })?;
727
728            transfer.outputs.push(asset::packet::AssetOutput {
729                output_index: receiver_index as u16,
730                amount: asset.amount,
731            });
732            transfer.requested_amount = transfer
733                .requested_amount
734                .checked_add(asset.amount)
735                .ok_or_else(|| Error::ad_hoc("asset transfer amount overflow"))?;
736        }
737    }
738
739    let change_output_index = btc_change_output_index(ark_tx, receivers.len());
740    let mut groups = Vec::new();
741
742    for (asset_id, mut transfer) in transfers.into_iter() {
743        let leftover_amount = transfer
744            .input_amount
745            .checked_sub(transfer.requested_amount)
746            .ok_or_else(|| {
747                Error::ad_hoc(format!(
748                    "requested amount for asset {} exceeds selected input amount",
749                    asset_id
750                ))
751            })?;
752
753        match (change_output_index, leftover_amount) {
754            (Some(change_output_index), leftover_amount) if leftover_amount > 0 => {
755                transfer.outputs.push(asset::packet::AssetOutput {
756                    output_index: change_output_index,
757                    amount: leftover_amount,
758                });
759            }
760            (None, leftover_amount) if leftover_amount > 0 => {
761                return Err(Error::ad_hoc(
762                    "asset transfer has preserved asset changes but no BTC change output",
763                ));
764            }
765            _ => {}
766        }
767
768        groups.push(asset::packet::AssetGroup {
769            asset_id: Some(asset_id),
770            control_asset: None,
771            metadata: None,
772            inputs: transfer.inputs,
773            outputs: transfer.outputs,
774        });
775    }
776
777    groups.sort_by_key(|group| {
778        let asset_id = group
779            .asset_id
780            .expect("generic asset-send groups always have asset ids");
781        (*asset_id.txid.as_byte_array(), asset_id.group_index)
782    });
783
784    Ok(Some(asset::packet::Packet { groups }))
785}
786
787fn create_burn_packet(
788    inputs: &[VtxoInput],
789    burn_asset_id: AssetId,
790    burn_amount: u64,
791    ark_tx: &Psbt,
792) -> Result<Option<asset::packet::Packet>, Error> {
793    struct AssetTransfer {
794        inputs: Vec<asset::packet::AssetInput>,
795        input_amount: u64,
796    }
797
798    let mut transfers: HashMap<AssetId, AssetTransfer> = HashMap::new();
799
800    for (input_index, input) in inputs.iter().enumerate() {
801        for asset in input.assets() {
802            let transfer = transfers
803                .entry(asset.asset_id)
804                .or_insert_with(|| AssetTransfer {
805                    inputs: Vec::new(),
806                    input_amount: 0,
807                });
808
809            transfer.inputs.push(asset::packet::AssetInput {
810                input_index: input_index as u16,
811                amount: asset.amount,
812            });
813            transfer.input_amount += asset.amount;
814        }
815    }
816
817    if transfers.is_empty() {
818        return Err(Error::ad_hoc(format!(
819            "selected inputs do not contain asset {}",
820            burn_asset_id
821        )));
822    }
823
824    let burn_input_amount = transfers
825        .get(&burn_asset_id)
826        .ok_or_else(|| {
827            Error::ad_hoc(format!(
828                "selected inputs do not contain asset {}",
829                burn_asset_id
830            ))
831        })?
832        .input_amount;
833
834    let burn_leftover_amount = burn_input_amount.checked_sub(burn_amount).ok_or_else(|| {
835        Error::ad_hoc(format!(
836            "requested burn amount for asset {} exceeds selected input amount",
837            burn_asset_id
838        ))
839    })?;
840
841    let preserved_output_index = btc_change_output_index(ark_tx, 1).unwrap_or(0);
842    let mut groups = Vec::new();
843
844    for (asset_id, transfer) in transfers.into_iter() {
845        let leftover_amount = if asset_id == burn_asset_id {
846            burn_leftover_amount
847        } else {
848            transfer.input_amount
849        };
850
851        let mut outputs = Vec::new();
852        if leftover_amount > 0 {
853            outputs.push(asset::packet::AssetOutput {
854                output_index: preserved_output_index,
855                amount: leftover_amount,
856            });
857        }
858
859        groups.push(asset::packet::AssetGroup {
860            asset_id: Some(asset_id),
861            control_asset: None,
862            metadata: None,
863            inputs: transfer.inputs,
864            outputs,
865        });
866    }
867
868    groups.sort_by_key(|group| {
869        let asset_id = group
870            .asset_id
871            .expect("asset-burn groups always have asset ids");
872        (*asset_id.txid.as_byte_array(), asset_id.group_index)
873    });
874
875    Ok(Some(asset::packet::Packet { groups }))
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use crate::asset::packet::AssetGroup;
882    use crate::asset::packet::AssetInput;
883    use crate::asset::packet::AssetOutput;
884    use crate::asset::packet::Packet;
885    use crate::script::multisig_script;
886    use crate::send::VtxoInput;
887    use crate::server::Info;
888    use bitcoin::key::Secp256k1;
889    use bitcoin::opcodes::OP_TRUE;
890    use bitcoin::script::Builder;
891    use bitcoin::taproot::LeafVersion;
892    use bitcoin::taproot::TaprootBuilder;
893    use bitcoin::Amount;
894    use bitcoin::Network;
895    use bitcoin::OutPoint;
896    use bitcoin::Sequence;
897    use bitcoin::Txid;
898
899    #[test]
900    fn build_offchain_transactions_has_no_packet_even_when_assets_are_present() {
901        let server_info = test_server_info();
902        let asset_id = AssetId {
903            txid: Txid::from_byte_array([10; 32]),
904            group_index: 0,
905        };
906        let (input, own_address) = asset_send_input(
907            1,
908            660,
909            vec![Asset {
910                asset_id,
911                amount: 10,
912            }],
913        );
914        let receiver = SendReceiver {
915            address: own_address,
916            amount: Amount::from_sat(330),
917            assets: vec![Asset {
918                asset_id,
919                amount: 6,
920            }],
921        };
922
923        let res =
924            build_offchain_transactions(&[receiver], &own_address, &[input], &server_info).unwrap();
925
926        assert_eq!(res.ark_tx.unsigned_tx.output.len(), 3);
927    }
928
929    #[test]
930    fn build_asset_send_transactions_routes_requested_assets_to_receiver_outputs_and_change() {
931        let server_info = test_server_info();
932        let asset_id = AssetId {
933            txid: Txid::from_byte_array([11; 32]),
934            group_index: 4,
935        };
936        let (input, own_address) = asset_send_input(
937            2,
938            660,
939            vec![Asset {
940                asset_id,
941                amount: 10,
942            }],
943        );
944        let receiver = SendReceiver {
945            address: own_address,
946            amount: Amount::from_sat(330),
947            assets: vec![Asset {
948                asset_id,
949                amount: 6,
950            }],
951        };
952
953        let res = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info)
954            .unwrap();
955
956        let expected_packet = Packet {
957            groups: vec![AssetGroup {
958                asset_id: Some(asset_id),
959                control_asset: None,
960                metadata: None,
961                inputs: vec![AssetInput {
962                    input_index: 0,
963                    amount: 10,
964                }],
965                outputs: vec![
966                    AssetOutput {
967                        output_index: 0,
968                        amount: 6,
969                    },
970                    AssetOutput {
971                        output_index: 1,
972                        amount: 4,
973                    },
974                ],
975            }],
976        };
977
978        assert_eq!(
979            res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)],
980            expected_packet.to_txout()
981        );
982    }
983
984    #[test]
985    fn build_asset_send_transactions_errors_when_receiver_references_missing_asset() {
986        let server_info = test_server_info();
987        let missing_asset_id = AssetId {
988            txid: Txid::from_byte_array([12; 32]),
989            group_index: 1,
990        };
991        let (input, own_address) = asset_send_input(3, 330, vec![]);
992        let receiver = SendReceiver {
993            address: own_address,
994            amount: Amount::from_sat(330),
995            assets: vec![Asset {
996                asset_id: missing_asset_id,
997                amount: 1,
998            }],
999        };
1000
1001        let err = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info)
1002            .unwrap_err();
1003
1004        assert!(err.to_string().contains("receiver references asset"));
1005    }
1006
1007    #[test]
1008    fn build_asset_send_transactions_errors_when_leftover_assets_exist_but_no_btc_change_output() {
1009        let server_info = test_server_info();
1010        let asset_id = AssetId {
1011            txid: Txid::from_byte_array([13; 32]),
1012            group_index: 2,
1013        };
1014        let (input, own_address) = asset_send_input(
1015            4,
1016            330,
1017            vec![Asset {
1018                asset_id,
1019                amount: 10,
1020            }],
1021        );
1022        let receiver = SendReceiver {
1023            address: own_address,
1024            amount: Amount::from_sat(330),
1025            assets: vec![Asset {
1026                asset_id,
1027                amount: 6,
1028            }],
1029        };
1030
1031        let err = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info)
1032            .unwrap_err();
1033
1034        assert!(err
1035            .to_string()
1036            .contains("asset transfer has preserved asset changes but no BTC change output"));
1037    }
1038
1039    #[test]
1040    fn build_asset_send_transactions_sorts_packet_groups_stably() {
1041        let server_info = test_server_info();
1042        let asset_id_a = AssetId {
1043            txid: Txid::from_byte_array([14; 32]),
1044            group_index: 1,
1045        };
1046        let asset_id_b = AssetId {
1047            txid: Txid::from_byte_array([15; 32]),
1048            group_index: 0,
1049        };
1050        let (input, own_address) = asset_send_input(
1051            5,
1052            660,
1053            vec![
1054                Asset {
1055                    asset_id: asset_id_b,
1056                    amount: 8,
1057                },
1058                Asset {
1059                    asset_id: asset_id_a,
1060                    amount: 10,
1061                },
1062            ],
1063        );
1064        let receiver = SendReceiver {
1065            address: own_address,
1066            amount: Amount::from_sat(330),
1067            assets: vec![
1068                Asset {
1069                    asset_id: asset_id_b,
1070                    amount: 3,
1071                },
1072                Asset {
1073                    asset_id: asset_id_a,
1074                    amount: 4,
1075                },
1076            ],
1077        };
1078
1079        let res = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info)
1080            .unwrap();
1081
1082        let expected_packet = Packet {
1083            groups: vec![
1084                AssetGroup {
1085                    asset_id: Some(asset_id_a),
1086                    control_asset: None,
1087                    metadata: None,
1088                    inputs: vec![AssetInput {
1089                        input_index: 0,
1090                        amount: 10,
1091                    }],
1092                    outputs: vec![
1093                        AssetOutput {
1094                            output_index: 0,
1095                            amount: 4,
1096                        },
1097                        AssetOutput {
1098                            output_index: 1,
1099                            amount: 6,
1100                        },
1101                    ],
1102                },
1103                AssetGroup {
1104                    asset_id: Some(asset_id_b),
1105                    control_asset: None,
1106                    metadata: None,
1107                    inputs: vec![AssetInput {
1108                        input_index: 0,
1109                        amount: 8,
1110                    }],
1111                    outputs: vec![
1112                        AssetOutput {
1113                            output_index: 0,
1114                            amount: 3,
1115                        },
1116                        AssetOutput {
1117                            output_index: 1,
1118                            amount: 5,
1119                        },
1120                    ],
1121                },
1122            ],
1123        };
1124
1125        assert_eq!(
1126            res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)],
1127            expected_packet.to_txout()
1128        );
1129    }
1130
1131    #[test]
1132    fn build_asset_burn_transactions_routes_leftover_assets_to_change() {
1133        let server_info = test_server_info();
1134        let burn_asset_id = AssetId {
1135            txid: Txid::from_byte_array([16; 32]),
1136            group_index: 0,
1137        };
1138        let carried_asset_id = AssetId {
1139            txid: Txid::from_byte_array([17; 32]),
1140            group_index: 1,
1141        };
1142        let (input, own_address) = asset_send_input(
1143            6,
1144            660,
1145            vec![
1146                Asset {
1147                    asset_id: burn_asset_id,
1148                    amount: 10,
1149                },
1150                Asset {
1151                    asset_id: carried_asset_id,
1152                    amount: 4,
1153                },
1154            ],
1155        );
1156
1157        let res = build_asset_burn_transactions(
1158            &own_address,
1159            &own_address,
1160            &[input],
1161            &server_info,
1162            burn_asset_id,
1163            6,
1164        )
1165        .unwrap();
1166
1167        let expected_packet = Packet {
1168            groups: vec![
1169                AssetGroup {
1170                    asset_id: Some(burn_asset_id),
1171                    control_asset: None,
1172                    metadata: None,
1173                    inputs: vec![AssetInput {
1174                        input_index: 0,
1175                        amount: 10,
1176                    }],
1177                    outputs: vec![AssetOutput {
1178                        output_index: 1,
1179                        amount: 4,
1180                    }],
1181                },
1182                AssetGroup {
1183                    asset_id: Some(carried_asset_id),
1184                    control_asset: None,
1185                    metadata: None,
1186                    inputs: vec![AssetInput {
1187                        input_index: 0,
1188                        amount: 4,
1189                    }],
1190                    outputs: vec![AssetOutput {
1191                        output_index: 1,
1192                        amount: 4,
1193                    }],
1194                },
1195            ],
1196        };
1197
1198        assert_eq!(
1199            res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)],
1200            expected_packet.to_txout()
1201        );
1202    }
1203
1204    #[test]
1205    fn build_asset_burn_transactions_errors_when_asset_is_missing() {
1206        let server_info = test_server_info();
1207        let missing_asset_id = AssetId {
1208            txid: Txid::from_byte_array([18; 32]),
1209            group_index: 0,
1210        };
1211        let (input, own_address) = asset_send_input(7, 330, vec![]);
1212
1213        let err = build_asset_burn_transactions(
1214            &own_address,
1215            &own_address,
1216            &[input],
1217            &server_info,
1218            missing_asset_id,
1219            1,
1220        )
1221        .unwrap_err();
1222
1223        assert!(err
1224            .to_string()
1225            .contains("selected inputs do not contain asset"));
1226    }
1227
1228    #[test]
1229    fn build_asset_burn_transactions_routes_leftover_assets_to_self_output_without_btc_change() {
1230        let server_info = test_server_info();
1231        let burn_asset_id = AssetId {
1232            txid: Txid::from_byte_array([19; 32]),
1233            group_index: 0,
1234        };
1235        let (input, own_address) = asset_send_input(
1236            8,
1237            330,
1238            vec![Asset {
1239                asset_id: burn_asset_id,
1240                amount: 10,
1241            }],
1242        );
1243
1244        let res = build_asset_burn_transactions(
1245            &own_address,
1246            &own_address,
1247            &[input],
1248            &server_info,
1249            burn_asset_id,
1250            6,
1251        )
1252        .unwrap();
1253
1254        let expected_packet = Packet {
1255            groups: vec![AssetGroup {
1256                asset_id: Some(burn_asset_id),
1257                control_asset: None,
1258                metadata: None,
1259                inputs: vec![AssetInput {
1260                    input_index: 0,
1261                    amount: 10,
1262                }],
1263                outputs: vec![AssetOutput {
1264                    output_index: 0,
1265                    amount: 4,
1266                }],
1267            }],
1268        };
1269
1270        assert_eq!(
1271            res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)],
1272            expected_packet.to_txout()
1273        );
1274    }
1275
1276    fn test_server_info() -> Info {
1277        let signer_pk = "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
1278            .parse()
1279            .unwrap();
1280        let forfeit_pk = "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624"
1281            .parse()
1282            .unwrap();
1283
1284        Info {
1285            version: "test".into(),
1286            signer_pk,
1287            forfeit_pk,
1288            forfeit_address: "bcrt1q8frde3yn78tl9ecgq4anlz909jh0clefhucdur"
1289                .parse::<bitcoin::Address<_>>()
1290                .unwrap()
1291                .require_network(Network::Regtest)
1292                .unwrap(),
1293            checkpoint_tapscript: Builder::new().push_opcode(OP_TRUE).into_script(),
1294            network: Network::Regtest,
1295            session_duration: 0,
1296            unilateral_exit_delay: Sequence::MAX,
1297            boarding_exit_delay: Sequence::MAX,
1298            utxo_min_amount: None,
1299            utxo_max_amount: None,
1300            vtxo_min_amount: Some(Amount::from_sat(1)),
1301            vtxo_max_amount: None,
1302            dust: Amount::from_sat(330),
1303            fees: None,
1304            scheduled_session: None,
1305            deprecated_signers: vec![],
1306            service_status: Default::default(),
1307            digest: "test".into(),
1308            max_tx_weight: 40_000,
1309            max_op_return_outputs: 3,
1310        }
1311    }
1312
1313    fn asset_send_input(
1314        outpoint_tag: u8,
1315        amount_sat: u64,
1316        assets: Vec<Asset>,
1317    ) -> (VtxoInput, ArkAddress) {
1318        let secp = Secp256k1::new();
1319
1320        let server_pk: PublicKey =
1321            "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
1322                .parse()
1323                .unwrap();
1324        let owner_pk: PublicKey =
1325            "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624"
1326                .parse()
1327                .unwrap();
1328
1329        let server_xonly = server_pk.inner.x_only_public_key().0;
1330        let owner_xonly = owner_pk.inner.x_only_public_key().0;
1331        let spend_script = multisig_script(server_xonly, owner_xonly);
1332        let spend_info = TaprootBuilder::new()
1333            .add_leaf(0, spend_script.clone())
1334            .unwrap()
1335            .finalize(&secp, server_xonly)
1336            .unwrap();
1337        let control_block = spend_info
1338            .control_block(&(spend_script.clone(), LeafVersion::TapScript))
1339            .unwrap();
1340        let own_address = ArkAddress::new(Network::Regtest, server_xonly, spend_info.output_key());
1341
1342        (
1343            VtxoInput::new(
1344                spend_script.clone(),
1345                None,
1346                control_block,
1347                vec![spend_script],
1348                own_address.to_p2tr_script_pubkey(),
1349                Amount::from_sat(amount_sat),
1350                OutPoint::new(Txid::from_byte_array([outpoint_tag; 32]), 0),
1351                assets,
1352            ),
1353            own_address,
1354        )
1355    }
1356
1357    fn asset_packet_index(ark_tx: &Psbt) -> usize {
1358        ark_tx.unsigned_tx.output.len() - 2
1359    }
1360}