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