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#[derive(Debug, Clone)]
53pub struct VtxoInput {
54 spend_script: ScriptBuf,
58 locktime: Option<LockTime>,
61 control_block: ControlBlock,
62 tapscripts: Vec<ScriptBuf>,
64 script_pubkey: ScriptBuf,
65 amount: Amount,
67 outpoint: OutPoint,
69 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#[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
141pub(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
147pub 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 unsigned_ark_psbt.inputs[i].witness_utxo =
303 Some(checkpoint_psbt.unsigned_tx.output[0].clone());
304
305 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 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 bytes.push(1);
327
328 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 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 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 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 let mut bytes = Vec::new();
445
446 for script in vtxo_input.tapscripts.iter() {
447 bytes.push(1);
449
450 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
489pub 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 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
578pub 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
612pub 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
651fn 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}