ark/arkoor/
mod.rs

1//! Utilities to create out-of-round transactions using
2//! checkpoint transactions.
3//!
4//! # Checkpoints keep users and the server safe
5//!
6//! When an Ark transaction is spent out-of-round a new
7//! transaction is added on top of that. In the naive
8//! approach we just keep adding transactions and the
9//! chain becomes longer.
10//!
11//! A first problem is that this can become unsafe for the server.
12//! If a client performs a partial exit attack the server
13//! will have to broadcast a long chain of transactions
14//! to get the forfeit published.
15//!
16//! A second problem is that if one user exits it affects everyone.
17//! In their chunk of the tree. The server cannot sweep the funds
18//! anymore and all other users are forced to collect their funds
19//! from the chain (which can be expensive).
20//!
21//! # How do they work
22//!
23//! The core idea is that each out-of-round spent will go through
24//! a checkpoint transaction. The checkpoint transaction has the policy
25//! `A + S or S after expiry`.
26//!
27//! Note, that the `A+S` path is fast and will always take priority.
28//! Users will still be able to exit their funds at any time.
29//! But if a partial exit occurs, the server can just broadcast
30//! a single checkpoint transaction and continue like nothing happened.
31//!
32//! Other users will be fully unaffected by this. Their [Vtxo] will now
33//! be anchored in the checkpoint which can be swept after expiry.
34//!
35//! # Usage
36//!
37//! This module creates a checkpoint transaction that originates
38//! from a single [Vtxo]. It is a low-level construct and the developer
39//! has to compute the paid amount, change and fees themselves.
40//!
41//! The core construct is [ArkoorBuilder] which can be
42//! used to build arkoor transactions. The struct is designed to be
43//! used by both the client and the server.
44//!
45//! [ArkoorBuilder::new]  is a constructor that validates
46//! the intended transaction. At this point, all transactions that
47//! will be constructed are fully designed. You can
48//! use [ArkoorBuilder::build_unsigned_vtxos] to construct the
49//! vtxos but they will still lack signatures.
50//!
51//! Constructing the signatures is an interactive process in which the
52//! server signs first.
53//!
54//! The client will call [ArkoorBuilder::generate_user_nonces]
55//! which will update the builder-state to  [state::UserGeneratedNonces].
56//! The client will create a [CosignRequest] which contains the details
57//! about the arkoor payment including the user nonces. The server will
58//! respond with a [CosignResponse] which can be used to finalize all
59//! signatures. At the end the client can call [ArkoorBuilder::build_signed_vtxos]
60//! to get their fully signed VTXOs.
61//!
62//! The server will also use [ArkoorBuilder::from_cosign_request]
63//! to construct a builder. The [ArkoorBuilder::server_cosign]
64//! will construct the [CosignResponse] which is sent to the client.
65//!
66
67pub mod package;
68
69use std::marker::PhantomData;
70
71use bitcoin::hashes::Hash;
72use bitcoin::sighash::{self, SighashCache};
73use bitcoin::{
74	Amount, OutPoint, ScriptBuf, Sequence, TapSighash, TapSighashType, Transaction, TxIn, TxOut, Txid, Witness
75};
76use bitcoin::taproot::TapTweakHash;
77use bitcoin::secp256k1::{schnorr, Keypair, PublicKey};
78use bitcoin_ext::{fee, P2TR_DUST, TxOutExt};
79use secp256k1_musig::musig::PublicNonce;
80
81use crate::{musig, scripts, Vtxo, VtxoId, ServerVtxo};
82use crate::vtxo::{Full, ServerVtxoPolicy, VtxoPolicy};
83use crate::vtxo::genesis::{GenesisItem, GenesisTransition};
84
85pub use package::ArkoorPackageBuilder;
86
87
88#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
89pub enum ArkoorConstructionError {
90	#[error("Input amount of {input} does not match output amount of {output}")]
91	Unbalanced {
92		input: Amount,
93		output: Amount,
94	},
95	#[error("An output is below the dust threshold")]
96	Dust,
97	#[error("At least one output is required")]
98	NoOutputs,
99	#[error("Too many inputs provided")]
100	TooManyInputs,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
104pub enum ArkoorSigningError {
105	#[error("An error occurred while building arkoor: {0}")]
106	ArkoorConstructionError(ArkoorConstructionError),
107	#[error("Wrong number of user nonces provided. Expected {expected}, got {got}")]
108	InvalidNbUserNonces {
109		expected: usize,
110		got: usize,
111	},
112	#[error("Wrong number of server nonces provided. Expected {expected}, got {got}")]
113	InvalidNbServerNonces {
114		expected: usize,
115		got: usize,
116	},
117	#[error("Incorrect signing key provided. Expected {expected}, got {got}")]
118	IncorrectKey {
119		expected: PublicKey,
120		got: PublicKey,
121	},
122	#[error("Wrong number of server partial sigs. Expected {expected}, got {got}")]
123	InvalidNbServerPartialSigs {
124		expected: usize,
125		got: usize
126	},
127	#[error("Invalid partial signature at index {index}")]
128	InvalidPartialSignature {
129		index: usize,
130	},
131	#[error("Wrong number of packages. Expected {expected}, got {got}")]
132	InvalidNbPackages {
133		expected: usize,
134		got: usize,
135	},
136	#[error("Wrong number of keypairs. Expected {expected}, got {got}")]
137	InvalidNbKeypairs {
138		expected: usize,
139		got: usize,
140	},
141}
142
143/// The destination of an arkoor pacakage
144///
145/// Because arkoor does not allow multiple inputs, often the destinations
146/// are broken up into multiple VTXOs with the same policy.
147#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
148pub struct ArkoorDestination {
149	pub total_amount: Amount,
150	#[serde(with = "crate::encode::serde")]
151	pub policy: VtxoPolicy,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ArkoorCosignResponse {
156	pub server_pub_nonces: Vec<musig::PublicNonce>,
157	pub server_partial_sigs: Vec<musig::PartialSignature>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct ArkoorCosignRequest<V> {
162	pub user_pub_nonces: Vec<musig::PublicNonce>,
163	pub input: V,
164	pub outputs: Vec<ArkoorDestination>,
165	pub isolated_outputs: Vec<ArkoorDestination>,
166	pub use_checkpoint: bool,
167}
168
169impl<V> ArkoorCosignRequest<V> {
170	pub fn new(
171		user_pub_nonces: Vec<musig::PublicNonce>,
172		input: V,
173		outputs: Vec<ArkoorDestination>,
174		isolated_outputs: Vec<ArkoorDestination>,
175		use_checkpoint: bool,
176	) -> Self {
177		Self {
178			user_pub_nonces,
179			input,
180			outputs,
181			isolated_outputs,
182			use_checkpoint,
183		}
184	}
185
186	pub fn all_outputs(&self) -> impl Iterator<Item = &ArkoorDestination> + Clone {
187		self.outputs.iter().chain(&self.isolated_outputs)
188	}
189}
190
191impl ArkoorCosignRequest<VtxoId> {
192	pub fn with_vtxo(self, vtxo: Vtxo<Full>) -> Result<ArkoorCosignRequest<Vtxo<Full>>, &'static str> {
193		if self.input != vtxo.id() {
194			return Err("Input vtxo id does not match the provided vtxo id")
195		}
196
197		Ok(ArkoorCosignRequest::new(
198			self.user_pub_nonces,
199			vtxo,
200			self.outputs,
201			self.isolated_outputs,
202			self.use_checkpoint,
203		))
204	}
205}
206
207
208pub mod state {
209	/// There are two paths that a can be followed
210	///
211	/// 1. [Initial] -> [UserGeneratedNonces] -> [UserSigned]
212	/// 2. [Initial] -> [ServerCanCosign] -> [ServerSigned]
213	///
214	/// The first option is taken by the user and the second by the server
215
216	mod sealed {
217		pub trait Sealed {}
218		impl Sealed for super::Initial {}
219		impl Sealed for super::UserGeneratedNonces {}
220		impl Sealed for super::UserSigned {}
221		impl Sealed for super::ServerCanCosign {}
222		impl Sealed for super::ServerSigned {}
223	}
224
225	pub trait BuilderState: sealed::Sealed {}
226
227	// The initial state of the builder
228	pub struct Initial;
229	impl BuilderState for Initial {}
230
231	// The user has generated their nonces
232	pub struct UserGeneratedNonces;
233	impl BuilderState for UserGeneratedNonces {}
234
235	// The user can sign
236	pub struct UserSigned;
237	impl BuilderState for UserSigned {}
238
239	// The server can cosign
240	pub struct ServerCanCosign;
241	impl BuilderState for ServerCanCosign {}
242
243
244	/// The server has signed and knows the partial signatures
245	pub struct ServerSigned;
246	impl BuilderState for ServerSigned {}
247}
248
249pub struct ArkoorBuilder<S: state::BuilderState> {
250	// These variables are provided by the user
251	/// The input vtxo to be spent
252	input: Vtxo<Full>,
253	/// Regular output vtxos
254	outputs: Vec<ArkoorDestination>,
255	/// Isolated outputs that will go through an isolation tx
256	///
257	/// This is meant to isolate dust outputs from non-dust ones.
258	isolated_outputs: Vec<ArkoorDestination>,
259
260	/// Data on the checkpoint tx, if checkpoints are enabled
261	///
262	/// - the unsigned checkpoint transaction
263	/// - the txid of the checkpoint transaction
264	checkpoint_data: Option<(Transaction, Txid)>,
265	/// The unsigned arkoor transactions (one per normal output)
266	unsigned_arkoor_txs: Vec<Transaction>,
267	/// The unsigned isolation fanout transaction (only when dust isolation is needed)
268	/// Splits the combined dust checkpoint output into k outputs with user's final policies
269	unsigned_isolation_fanout_tx: Option<Transaction>,
270	/// The sighashes that must be signed
271	sighashes: Vec<TapSighash>,
272	/// Taptweak derived from the input vtxo's policy.
273	input_tweak: TapTweakHash,
274	/// Taptweak for all outputs of the checkpoint tx.
275	/// NB: Also used for dust isolation outputs even when not using checkpoints.
276	checkpoint_policy_tweak: TapTweakHash,
277	/// The [VtxoId]s of all new [Vtxo]s that will be created
278	new_vtxo_ids: Vec<VtxoId>,
279
280	//  These variables are filled in when the state progresses
281	/// We need 1 signature for the checkpoint transaction
282	/// We need n signatures. This is one for each arkoor tx
283	/// `1+n` public nonces created by the user
284	user_pub_nonces: Option<Vec<musig::PublicNonce>>,
285	/// `1+n` secret nonces created by the user
286	user_sec_nonces: Option<Vec<musig::SecretNonce>>,
287	/// `1+n` public nonces created by the server
288	server_pub_nonces: Option<Vec<musig::PublicNonce>>,
289	/// `1+n` partial signatures created by the server
290	server_partial_sigs: Option<Vec<musig::PartialSignature>>,
291	/// `1+n` signatures that are signed by the user and server
292	full_signatures: Option<Vec<schnorr::Signature>>,
293
294	_state: PhantomData<S>,
295}
296
297impl<S: state::BuilderState> ArkoorBuilder<S> {
298	/// Access the input VTXO
299	pub fn input(&self) -> &Vtxo<Full> {
300		&self.input
301	}
302
303	/// Access the regular (non-isolated) outputs of the builder
304	pub fn normal_outputs(&self) -> &[ArkoorDestination] {
305		&self.outputs
306	}
307
308	/// Access the isolated outputs of the builder
309	pub fn isolated_outputs(&self) -> &[ArkoorDestination] {
310		&self.isolated_outputs
311	}
312
313	/// Access all outputs of the builder
314	pub fn all_outputs(
315		&self,
316	) -> impl Iterator<Item = &ArkoorDestination> + Clone {
317		self.outputs.iter().chain(&self.isolated_outputs)
318	}
319
320	fn build_checkpoint_vtxo_at(
321		&self,
322		output_idx: usize,
323		checkpoint_sig: Option<schnorr::Signature>
324	) -> ServerVtxo<Full> {
325		let output = &self.outputs[output_idx];
326		let (checkpoint_tx, checkpoint_txid) = self.checkpoint_data.as_ref()
327			.expect("called checkpoint_vtxo_at in context without checkpoints");
328
329		Vtxo {
330			amount: output.total_amount,
331			policy: ServerVtxoPolicy::new_checkpoint(self.input.user_pubkey()),
332			expiry_height: self.input.expiry_height,
333			server_pubkey: self.input.server_pubkey,
334			exit_delta: self.input.exit_delta,
335			point: OutPoint::new(*checkpoint_txid, output_idx as u32),
336			anchor_point: self.input.anchor_point,
337			genesis: Full {
338				items: self.input.genesis.items.clone().into_iter().chain([
339					GenesisItem {
340						transition: GenesisTransition::new_arkoor(
341							vec![self.input.user_pubkey()],
342							self.input.policy().taproot(
343								self.input.server_pubkey,
344								self.input.exit_delta,
345								self.input.expiry_height,
346							).tap_tweak(),
347							checkpoint_sig,
348						),
349						output_idx: output_idx as u8,
350						other_outputs: checkpoint_tx.output
351							.iter().enumerate()
352							.filter_map(|(i, txout)| {
353								if i == (output_idx as usize) || txout.is_p2a_fee_anchor() {
354									None
355								} else {
356									Some(txout.clone())
357								}
358							})
359							.collect(),
360						fee_amount: Amount::ZERO,
361					},
362				]).collect(),
363			},
364		}
365	}
366
367	fn build_vtxo_at(
368		&self,
369		output_idx: usize,
370		checkpoint_sig: Option<schnorr::Signature>,
371		arkoor_sig: Option<schnorr::Signature>,
372	) -> Vtxo<Full> {
373		let output = &self.outputs[output_idx];
374
375		if let Some((checkpoint_tx, _txid)) = &self.checkpoint_data {
376			// Two-transition genesis: Input → Checkpoint → Arkoor
377			let checkpoint_policy = ServerVtxoPolicy::new_checkpoint(self.input.user_pubkey());
378
379			Vtxo {
380				amount: output.total_amount,
381				policy: output.policy.clone(),
382				expiry_height: self.input.expiry_height,
383				server_pubkey: self.input.server_pubkey,
384				exit_delta: self.input.exit_delta,
385				point: self.new_vtxo_ids[output_idx].utxo(),
386				anchor_point: self.input.anchor_point,
387				genesis: Full {
388					items: self.input.genesis.items.iter().cloned().chain([
389						GenesisItem {
390							transition: GenesisTransition::new_arkoor(
391								vec![self.input.user_pubkey()],
392								self.input.policy.taproot(
393									self.input.server_pubkey,
394									self.input.exit_delta,
395									self.input.expiry_height,
396								).tap_tweak(),
397								checkpoint_sig,
398							),
399							output_idx: output_idx as u8,
400							other_outputs: checkpoint_tx.output
401								.iter().enumerate()
402								.filter_map(|(i, txout)| {
403									if i == (output_idx as usize) || txout.is_p2a_fee_anchor() {
404										None
405									} else {
406										Some(txout.clone())
407									}
408								})
409								.collect(),
410							fee_amount: Amount::ZERO,
411						},
412						GenesisItem {
413							transition: GenesisTransition::new_arkoor(
414								vec![self.input.user_pubkey()],
415								checkpoint_policy.taproot(
416									self.input.server_pubkey,
417									self.input.exit_delta,
418									self.input.expiry_height,
419								).tap_tweak(),
420								arkoor_sig,
421							),
422							output_idx: 0,
423							other_outputs: vec![],
424							fee_amount: Amount::ZERO,
425						}
426					]).collect(),
427				},
428			}
429		} else {
430			// Single-transition genesis: Input → Arkoor
431			let arkoor_tx = &self.unsigned_arkoor_txs[0];
432
433			Vtxo {
434				amount: output.total_amount,
435				policy: output.policy.clone(),
436				expiry_height: self.input.expiry_height,
437				server_pubkey: self.input.server_pubkey,
438				exit_delta: self.input.exit_delta,
439				point: OutPoint::new(arkoor_tx.compute_txid(), output_idx as u32),
440				anchor_point: self.input.anchor_point,
441				genesis: Full {
442					items: self.input.genesis.items.iter().cloned().chain([
443						GenesisItem {
444							transition: GenesisTransition::new_arkoor(
445								vec![self.input.user_pubkey()],
446								self.input.policy.taproot(
447									self.input.server_pubkey,
448									self.input.exit_delta,
449									self.input.expiry_height,
450								).tap_tweak(),
451								arkoor_sig,
452							),
453							output_idx: output_idx as u8,
454							other_outputs: arkoor_tx.output
455								.iter().enumerate()
456								.filter_map(|(idx, txout)| {
457									if idx == output_idx || txout.is_p2a_fee_anchor() {
458										None
459									} else {
460										Some(txout.clone())
461									}
462								})
463								.collect(),
464							fee_amount: Amount::ZERO,
465						}
466					]).collect(),
467				},
468			}
469		}
470	}
471
472	/// Build the isolated vtxo at the given index
473	///
474	/// Only used when dust isolation is active.
475	///
476	/// The `pre_fanout_tx_sig` is either
477	/// - the arkoor tx signature when no checkpoint tx is used, or
478	/// - the checkpoint tx signature when a checkpoint tx is used
479	fn build_isolated_vtxo_at(
480		&self,
481		isolated_idx: usize,
482		pre_fanout_tx_sig: Option<schnorr::Signature>,
483		isolation_fanout_tx_sig: Option<schnorr::Signature>,
484	) -> Vtxo<Full> {
485		let output = &self.isolated_outputs[isolated_idx];
486		let checkpoint_policy = ServerVtxoPolicy::new_checkpoint(self.input.user_pubkey());
487
488		let fanout_tx = self.unsigned_isolation_fanout_tx.as_ref()
489			.expect("construct_dust_vtxo_at called without dust isolation");
490
491		// The combined dust isolation output is at index outputs.len()
492		let dust_isolation_output_idx = self.outputs.len();
493
494		if let Some((checkpoint_tx, _txid)) = &self.checkpoint_data {
495			// Two transitions: Input → Checkpoint → Fanout (final vtxo)
496			Vtxo {
497				amount: output.total_amount,
498				policy: output.policy.clone(),
499				expiry_height: self.input.expiry_height,
500				server_pubkey: self.input.server_pubkey,
501				exit_delta: self.input.exit_delta,
502				point: OutPoint::new(fanout_tx.compute_txid(), isolated_idx as u32),
503				anchor_point: self.input.anchor_point,
504				genesis: Full {
505					items: self.input.genesis.items.iter().cloned().chain([
506						// Transition 1: input -> checkpoint
507						GenesisItem {
508							transition: GenesisTransition::new_arkoor(
509								vec![self.input.user_pubkey()],
510								self.input.policy.taproot(
511									self.input.server_pubkey,
512									self.input.exit_delta,
513									self.input.expiry_height,
514								).tap_tweak(),
515								pre_fanout_tx_sig,
516							),
517							output_idx: dust_isolation_output_idx as u8,
518							// other outputs are the normal outputs
519							// (we skip our combined dust output and fee anchor)
520							other_outputs: checkpoint_tx.output
521								.iter().enumerate()
522								.filter_map(|(idx, txout)| {
523									let is_p2a = txout.is_p2a_fee_anchor();
524									if idx == dust_isolation_output_idx || is_p2a {
525										None
526									} else {
527										Some(txout.clone())
528									}
529								})
530								.collect(),
531							fee_amount: Amount::ZERO,
532						},
533						// Transition 2: checkpoint -> isolation fanout tx (final vtxo)
534						GenesisItem {
535							transition: GenesisTransition::new_arkoor(
536								vec![self.input.user_pubkey()],
537								checkpoint_policy.taproot(
538									self.input.server_pubkey,
539									self.input.exit_delta,
540									self.input.expiry_height,
541								).tap_tweak(),
542								isolation_fanout_tx_sig,
543							),
544							output_idx: isolated_idx as u8,
545							// other outputs are the other isolated outputs
546							// (we skip our output and fee anchor)
547							other_outputs: fanout_tx.output
548								.iter().enumerate()
549								.filter_map(|(idx, txout)| {
550									if idx == isolated_idx || txout.is_p2a_fee_anchor() {
551										None
552									} else {
553										Some(txout.clone())
554									}
555								})
556								.collect(),
557							fee_amount: Amount::ZERO,
558						},
559					]).collect(),
560				},
561			}
562		} else {
563			// Two transitions: Input → Arkoor (with isolation output) → Fanout (final vtxo)
564			let arkoor_tx = &self.unsigned_arkoor_txs[0];
565
566			Vtxo {
567				amount: output.total_amount,
568				policy: output.policy.clone(),
569				expiry_height: self.input.expiry_height,
570				server_pubkey: self.input.server_pubkey,
571				exit_delta: self.input.exit_delta,
572				point: OutPoint::new(fanout_tx.compute_txid(), isolated_idx as u32),
573				anchor_point: self.input.anchor_point,
574				genesis: Full {
575					items: self.input.genesis.items.iter().cloned().chain([
576						// Transition 1: input -> arkoor tx (which includes isolation output)
577						GenesisItem {
578							transition: GenesisTransition::new_arkoor(
579								vec![self.input.user_pubkey()],
580								self.input.policy.taproot(
581									self.input.server_pubkey,
582									self.input.exit_delta,
583									self.input.expiry_height,
584								).tap_tweak(),
585								pre_fanout_tx_sig,
586							),
587							output_idx: dust_isolation_output_idx as u8,
588							other_outputs: arkoor_tx.output
589								.iter().enumerate()
590								.filter_map(|(idx, txout)| {
591									if idx == dust_isolation_output_idx || txout.is_p2a_fee_anchor() {
592										None
593									} else {
594										Some(txout.clone())
595									}
596								})
597								.collect(),
598							fee_amount: Amount::ZERO,
599						},
600						// Transition 2: isolation output -> isolation fanout tx (final vtxo)
601						GenesisItem {
602							transition: GenesisTransition::new_arkoor(
603								vec![self.input.user_pubkey()],
604								checkpoint_policy.taproot(
605									self.input.server_pubkey,
606									self.input.exit_delta,
607									self.input.expiry_height,
608								).tap_tweak(),
609								isolation_fanout_tx_sig,
610							),
611							output_idx: isolated_idx as u8,
612							other_outputs: fanout_tx.output
613								.iter().enumerate()
614								.filter_map(|(idx, txout)| {
615									if idx == isolated_idx || txout.is_p2a_fee_anchor() {
616										None
617									} else {
618										Some(txout.clone())
619									}
620								})
621								.collect(),
622							fee_amount: Amount::ZERO,
623						},
624					]).collect(),
625				},
626			}
627		}
628	}
629
630	fn nb_sigs(&self) -> usize {
631		let base = if self.checkpoint_data.is_some() {
632			1 + self.outputs.len()  // 1 checkpoint + m arkoor txs
633		} else {
634			1  // 1 direct arkoor tx (regardless of output count)
635		};
636
637		if self.unsigned_isolation_fanout_tx.is_some() {
638			base + 1  // Just 1 fanout tx signature
639		} else {
640			base
641		}
642	}
643
644	pub fn build_unsigned_vtxos<'a>(&'a self) -> impl Iterator<Item = Vtxo<Full>> + 'a {
645		let regular = (0..self.outputs.len()).map(|i| self.build_vtxo_at(i, None, None));
646		let isolated = (0..self.isolated_outputs.len())
647			.map(|i| self.build_isolated_vtxo_at(i, None, None));
648		regular.chain(isolated)
649	}
650
651	/// Builds the unsigned internal VTXOs
652	///
653	/// Returns the checkpoint outputs (if checkpoinst are used) and the
654	/// dust isolation output (if dust isolation is used).
655	pub fn build_unsigned_internal_vtxos<'a>(&'a self) -> impl Iterator<Item = ServerVtxo<Full>> + 'a {
656		let checkpoint_vtxos = {
657			let range = if self.checkpoint_data.is_some() {
658				0..self.outputs.len()
659			} else {
660				// none
661				0..0
662			};
663			range.map(|i| self.build_checkpoint_vtxo_at(i, None))
664		};
665
666		let isolation_vtxo = if !self.isolated_outputs.is_empty() {
667			// isolation comes after all normal outputs
668			let output_idx = self.outputs.len();
669
670			// intermediate tx depends on checkpoint
671			let (int_tx, int_txid) = if let Some((tx, txid)) = &self.checkpoint_data {
672				(tx, *txid)
673			} else {
674				let arkoor_tx = &self.unsigned_arkoor_txs[0];
675				(arkoor_tx, arkoor_tx.compute_txid())
676			};
677
678			Some(Vtxo {
679				amount: self.isolated_outputs.iter().map(|o| o.total_amount).sum(),
680				policy: ServerVtxoPolicy::new_checkpoint(self.input.user_pubkey()),
681				expiry_height: self.input.expiry_height,
682				server_pubkey: self.input.server_pubkey,
683				exit_delta: self.input.exit_delta,
684				point: OutPoint::new(int_txid, output_idx as u32),
685				anchor_point: self.input.anchor_point,
686				genesis: Full {
687					items: self.input.genesis.items.clone().into_iter().chain([
688						GenesisItem {
689							transition: GenesisTransition::new_arkoor(
690								vec![self.input.user_pubkey()],
691								self.input_tweak,
692								None,
693							),
694							output_idx: output_idx as u8,
695							other_outputs: int_tx.output.iter().enumerate()
696								.filter_map(|(i, txout)| {
697									if i == output_idx || txout.is_p2a_fee_anchor() {
698										None
699									} else {
700										Some(txout.clone())
701									}
702								})
703								.collect(),
704							fee_amount: Amount::ZERO,
705						},
706					]).collect(),
707				},
708			})
709		} else {
710			None
711		};
712
713		checkpoint_vtxos.chain(isolation_vtxo)
714	}
715
716	/// The returned [VtxoId] is spent out-of-round by [Txid]
717	pub fn spend_info(&self) -> Vec<(VtxoId, Txid)> {
718		let mut ret = Vec::with_capacity(1 + self.outputs.len());
719
720		if let Some((_tx, checkpoint_txid)) = &self.checkpoint_data {
721			// Input vtxo -> checkpoint tx
722			ret.push((self.input.id(), *checkpoint_txid));
723
724			// Non-isolated checkpoint outputs -> arkoor txs
725			for idx in 0..self.outputs.len() {
726				ret.push((
727					VtxoId::from(OutPoint::new(*checkpoint_txid, idx as u32)),
728					self.unsigned_arkoor_txs[idx].compute_txid()
729				));
730			}
731
732			// dust isolation paths (if active)
733			if let Some(fanout_tx) = &self.unsigned_isolation_fanout_tx {
734				let fanout_txid = fanout_tx.compute_txid();
735
736				// Combined isolation checkpoint output -> isolation fanout tx
737				let isolated_output_idx = self.outputs.len() as u32;
738				ret.push((
739					VtxoId::from(OutPoint::new(*checkpoint_txid, isolated_output_idx)),
740					fanout_txid,
741				));
742			}
743		} else {
744			let arkoor_txid = self.unsigned_arkoor_txs[0].compute_txid();
745
746			// Input vtxo -> arkoor tx
747			ret.push((self.input.id(), arkoor_txid));
748
749			// dust isolation paths (if active)
750			if let Some(fanout_tx) = &self.unsigned_isolation_fanout_tx {
751				let fanout_txid = fanout_tx.compute_txid();
752
753				// Isolation output in arkoor tx -> dust fanout
754				let isolation_output_idx = self.outputs.len() as u32;
755				ret.push((
756					VtxoId::from(OutPoint::new(arkoor_txid, isolation_output_idx)),
757					fanout_txid,
758				));
759			}
760		}
761
762		ret
763	}
764
765	/// Returns the txids of all virtual transactions in this arkoor:
766	/// - checkpoint tx (if checkpoints enabled)
767	/// - arkoor txs (one per normal output, exits from checkpoint)
768	/// - isolation fanout tx (if dust isolation active)
769	pub fn virtual_transactions(&self) -> Vec<Txid> {
770		let mut ret = Vec::new();
771		// Checkpoint tx
772		if let Some((_, txid)) = &self.checkpoint_data {
773			ret.push(*txid);
774		}
775		// Arkoor txs (exits for normal outputs)
776		ret.extend(self.unsigned_arkoor_txs.iter().map(|tx| tx.compute_txid()));
777		// Isolation fanout tx
778		if let Some(tx) = &self.unsigned_isolation_fanout_tx {
779			ret.push(tx.compute_txid());
780		}
781		ret
782	}
783
784	fn taptweak_at(&self, idx: usize) -> TapTweakHash {
785		if idx == 0 { self.input_tweak } else { self.checkpoint_policy_tweak }
786	}
787
788	fn user_pubkey(&self) -> PublicKey {
789		self.input.user_pubkey()
790	}
791
792	fn server_pubkey(&self) -> PublicKey {
793		self.input.server_pubkey()
794	}
795
796	/// Construct the checkpoint transaction
797	///
798	/// When dust isolation is needed, `combined_dust_amount` should be Some
799	/// with the total dust amount.
800	fn construct_unsigned_checkpoint_tx<G>(
801		input: &Vtxo<G>,
802		outputs: &[ArkoorDestination],
803		dust_isolation_amount: Option<Amount>,
804	) -> Transaction {
805
806		// All outputs on the checkpoint transaction will use exactly the same policy.
807		let output_policy = ServerVtxoPolicy::new_checkpoint(input.user_pubkey());
808		let checkpoint_spk = output_policy
809			.script_pubkey(input.server_pubkey(), input.exit_delta(), input.expiry_height());
810
811		Transaction {
812			version: bitcoin::transaction::Version(3),
813			lock_time: bitcoin::absolute::LockTime::ZERO,
814			input: vec![TxIn {
815				previous_output: input.point(),
816				script_sig: ScriptBuf::new(),
817				sequence: Sequence::ZERO,
818				witness: Witness::new(),
819			}],
820			output: outputs.iter().map(|o| {
821				TxOut {
822					value: o.total_amount,
823					script_pubkey: checkpoint_spk.clone(),
824				}
825			})
826				// add dust isolation output when required
827				.chain(dust_isolation_amount.map(|amt| {
828					TxOut {
829						value: amt,
830						script_pubkey: checkpoint_spk.clone(),
831					}
832				}))
833				.chain([fee::fee_anchor()]).collect()
834		}
835	}
836
837	fn construct_unsigned_arkoor_txs<G>(
838		input: &Vtxo<G>,
839		outputs: &[ArkoorDestination],
840		checkpoint_txid: Option<Txid>,
841		dust_isolation_amount: Option<Amount>,
842	) -> Vec<Transaction> {
843
844		if let Some(checkpoint_txid) = checkpoint_txid {
845			// Checkpoint mode: create separate arkoor tx for each output
846			let mut arkoor_txs = Vec::with_capacity(outputs.len());
847
848			for (vout, output) in outputs.iter().enumerate() {
849				let transaction = Transaction {
850					version: bitcoin::transaction::Version(3),
851					lock_time: bitcoin::absolute::LockTime::ZERO,
852					input: vec![TxIn {
853						previous_output: OutPoint::new(checkpoint_txid, vout as u32),
854						script_sig: ScriptBuf::new(),
855						sequence: Sequence::ZERO,
856						witness: Witness::new(),
857					}],
858					output: vec![
859						output.policy.txout(
860							output.total_amount,
861							input.server_pubkey(),
862							input.exit_delta(),
863							input.expiry_height(),
864						),
865						fee::fee_anchor(),
866					]
867				};
868				arkoor_txs.push(transaction);
869			}
870
871			arkoor_txs
872		} else {
873			// Direct mode: create single arkoor tx with all outputs + optional isolation output
874			let checkpoint_policy = ServerVtxoPolicy::new_checkpoint(input.user_pubkey());
875			let checkpoint_spk = checkpoint_policy.script_pubkey(
876				input.server_pubkey(),
877				input.exit_delta(),
878				input.expiry_height()
879			);
880
881			let transaction = Transaction {
882				version: bitcoin::transaction::Version(3),
883				lock_time: bitcoin::absolute::LockTime::ZERO,
884				input: vec![TxIn {
885					previous_output: input.point(),
886					script_sig: ScriptBuf::new(),
887					sequence: Sequence::ZERO,
888					witness: Witness::new(),
889				}],
890				output: outputs.iter()
891					.map(|o| o.policy.txout(
892						o.total_amount,
893						input.server_pubkey(),
894						input.exit_delta(),
895						input.expiry_height(),
896					))
897					// Add isolation output if dust is present
898					.chain(dust_isolation_amount.map(|amt| TxOut {
899						value: amt,
900						script_pubkey: checkpoint_spk.clone(),
901					}))
902					.chain([fee::fee_anchor()])
903					.collect()
904			};
905			vec![transaction]
906		}
907	}
908
909	/// Construct the dust isolation transaction that splits the combined
910	/// dust output into individual outputs
911	///
912	/// Each output uses the user's final policy directly.
913	/// Called only when dust isolation is needed.
914	///
915	/// `parent_txid` is either the checkpoint txid (checkpoint mode) or arkoor txid (direct mode)
916	fn construct_unsigned_isolation_fanout_tx<G>(
917		input: &Vtxo<G>,
918		isolated_outputs: &[ArkoorDestination],
919		parent_txid: Txid,  // Either checkpoint txid or arkoor txid
920		dust_isolation_output_vout: u32,  // Output index containing the dust isolation output
921	) -> Transaction {
922
923		Transaction {
924			version: bitcoin::transaction::Version(3),
925			lock_time: bitcoin::absolute::LockTime::ZERO,
926			input: vec![TxIn {
927				previous_output: OutPoint::new(parent_txid, dust_isolation_output_vout),
928				script_sig: ScriptBuf::new(),
929				sequence: Sequence::ZERO,
930				witness: Witness::new(),
931			}],
932			output: isolated_outputs.iter().map(|o| {
933				TxOut {
934					value: o.total_amount,
935					script_pubkey: o.policy.script_pubkey(
936						input.server_pubkey(),
937						input.exit_delta(),
938						input.expiry_height(),
939					),
940				}
941			}).chain([fee::fee_anchor()]).collect(),
942		}
943	}
944
945	fn validate_amounts<G>(
946		input: &Vtxo<G>,
947		outputs: &[ArkoorDestination],
948		isolation_outputs: &[ArkoorDestination],
949	) -> Result<(), ArkoorConstructionError> {
950
951		// Check if inputs and outputs are balanced
952		// We need to build transactions that pay exactly 0 in onchain fees
953		// to ensure our transaction with an ephemeral anchor is standard.
954		// We need `==` for standardness and we can't be lenient
955		let input_amount = input.amount();
956		let output_amount = outputs.iter().chain(isolation_outputs.iter())
957			.map(|o| o.total_amount).sum::<Amount>();
958
959		if input_amount != output_amount {
960			return Err(ArkoorConstructionError::Unbalanced {
961				input: input_amount,
962				output: output_amount,
963			})
964		}
965
966		// We need at least one output in the outputs vec
967		if outputs.is_empty() {
968			return Err(ArkoorConstructionError::NoOutputs)
969		}
970
971		// If isolation is provided, the sum must be over dust threshold
972		if !isolation_outputs.is_empty() {
973			let isolation_sum: Amount = isolation_outputs.iter()
974				.map(|o| o.total_amount).sum();
975			if isolation_sum < P2TR_DUST {
976				return Err(ArkoorConstructionError::Dust)
977			}
978		}
979
980		Ok(())
981	}
982
983
984	fn to_state<S2: state::BuilderState>(self) -> ArkoorBuilder<S2> {
985		ArkoorBuilder {
986			input: self.input,
987			outputs: self.outputs,
988			isolated_outputs: self.isolated_outputs,
989			checkpoint_data: self.checkpoint_data,
990			unsigned_arkoor_txs: self.unsigned_arkoor_txs,
991			unsigned_isolation_fanout_tx: self.unsigned_isolation_fanout_tx,
992			new_vtxo_ids: self.new_vtxo_ids,
993			sighashes: self.sighashes,
994			input_tweak: self.input_tweak,
995			checkpoint_policy_tweak: self.checkpoint_policy_tweak,
996			user_pub_nonces: self.user_pub_nonces,
997			user_sec_nonces: self.user_sec_nonces,
998			server_pub_nonces: self.server_pub_nonces,
999			server_partial_sigs: self.server_partial_sigs,
1000			full_signatures: self.full_signatures,
1001			_state: PhantomData,
1002		}
1003	}
1004}
1005
1006impl ArkoorBuilder<state::Initial> {
1007	/// Create builder with checkpoint transaction
1008	pub fn new_with_checkpoint(
1009		input: Vtxo<Full>,
1010		outputs: Vec<ArkoorDestination>,
1011		isolated_outputs: Vec<ArkoorDestination>,
1012	) -> Result<Self, ArkoorConstructionError> {
1013		Self::new(input, outputs, isolated_outputs, true)
1014	}
1015
1016	/// Create builder without checkpoint transaction
1017	pub fn new_without_checkpoint(
1018		input: Vtxo<Full>,
1019		outputs: Vec<ArkoorDestination>,
1020		isolated_outputs: Vec<ArkoorDestination>,
1021	) -> Result<Self, ArkoorConstructionError> {
1022		Self::new(input, outputs, isolated_outputs, false)
1023	}
1024
1025	/// Create builder with checkpoint and automatic dust isolation
1026	///
1027	/// This constructor takes a single list of outputs and automatically
1028	/// determines the best strategy for handling dust.
1029	pub fn new_with_checkpoint_isolate_dust(
1030		input: Vtxo<Full>,
1031		outputs: Vec<ArkoorDestination>,
1032	) -> Result<Self, ArkoorConstructionError> {
1033		Self::new_isolate_dust(input, outputs, true)
1034	}
1035
1036	pub(crate) fn new_isolate_dust(
1037		input: Vtxo<Full>,
1038		outputs: Vec<ArkoorDestination>,
1039		use_checkpoints: bool,
1040	) -> Result<Self, ArkoorConstructionError> {
1041		// fast track if they're either all dust or all non dust
1042		if outputs.iter().all(|v| v.total_amount >= P2TR_DUST)
1043			|| outputs.iter().all(|v| v.total_amount < P2TR_DUST)
1044		{
1045			return Self::new(input, outputs, vec![], use_checkpoints);
1046		}
1047
1048		// else split them up by dust limit
1049		let (mut dust, mut non_dust) = outputs.iter().cloned()
1050			.partition::<Vec<_>, _>(|v| v.total_amount < P2TR_DUST);
1051
1052		let dust_sum = dust.iter().map(|o| o.total_amount).sum::<Amount>();
1053		if dust_sum >= P2TR_DUST {
1054			return Self::new(input, non_dust, dust, use_checkpoints);
1055		}
1056
1057		// if breaking would result in additional dust, just accept
1058		let non_dust_sum = non_dust.iter().map(|o| o.total_amount).sum::<Amount>();
1059		if non_dust_sum < P2TR_DUST * 2 {
1060			return Self::new(input, outputs, vec![], use_checkpoints);
1061		}
1062
1063		// now it get's interesting, we need to break a vtxo in two
1064		let deficit = P2TR_DUST - dust_sum;
1065		// Find first viable output to split
1066		// Viable = output.total_amount - deficit >= P2TR_DUST (won't create two dust)
1067		let split_idx = non_dust.iter()
1068			.position(|o| o.total_amount - deficit >= P2TR_DUST);
1069
1070		if let Some(idx) = split_idx {
1071			let output_to_split = non_dust[idx].clone();
1072
1073			let dust_piece = ArkoorDestination {
1074				total_amount: deficit,
1075				policy: output_to_split.policy.clone(),
1076			};
1077			let leftover = ArkoorDestination {
1078				total_amount: output_to_split.total_amount - deficit,
1079				policy: output_to_split.policy,
1080			};
1081
1082			non_dust[idx] = leftover;
1083			// we want to push it to the front
1084			dust.insert(0, dust_piece);
1085
1086			return Self::new(input, non_dust, dust, use_checkpoints);
1087		} else {
1088			// No viable split found, allow mixing without isolation
1089			let all_outputs = non_dust.into_iter().chain(dust).collect();
1090			return Self::new(input, all_outputs, vec![], use_checkpoints);
1091		}
1092	}
1093
1094	pub(crate) fn new(
1095		input: Vtxo<Full>,
1096		outputs: Vec<ArkoorDestination>,
1097		isolated_outputs: Vec<ArkoorDestination>,
1098		use_checkpoint: bool,
1099	) -> Result<Self, ArkoorConstructionError> {
1100		// Do some validation on the amounts
1101		Self::validate_amounts(&input, &outputs, &isolated_outputs)?;
1102
1103		// Compute combined dust amount if dust isolation is needed
1104		let combined_dust_amount = if !isolated_outputs.is_empty() {
1105			Some(isolated_outputs.iter().map(|o| o.total_amount).sum())
1106		} else {
1107			None
1108		};
1109
1110		// Conditionally construct checkpoint transaction
1111		let unsigned_checkpoint_tx = if use_checkpoint {
1112			let tx = Self::construct_unsigned_checkpoint_tx(
1113				&input,
1114				&outputs,
1115				combined_dust_amount,
1116			);
1117			let txid = tx.compute_txid();
1118			Some((tx, txid))
1119		} else {
1120			None
1121		};
1122
1123		// Construct arkoor transactions
1124		let unsigned_arkoor_txs = Self::construct_unsigned_arkoor_txs(
1125			&input,
1126			&outputs,
1127			unsigned_checkpoint_tx.as_ref().map(|t| t.1),
1128			combined_dust_amount,
1129		);
1130
1131		// Construct dust fanout tx if dust isolation is needed
1132		let unsigned_isolation_fanout_tx = if !isolated_outputs.is_empty() {
1133			// Combined dust isolation output is at index outputs.len()
1134			// (after all normal outputs)
1135			let dust_isolation_output_vout = outputs.len() as u32;
1136
1137			let parent_txid = if let Some((_tx, txid)) = &unsigned_checkpoint_tx {
1138				*txid
1139			} else {
1140				unsigned_arkoor_txs[0].compute_txid()
1141			};
1142
1143			Some(Self::construct_unsigned_isolation_fanout_tx(
1144				&input,
1145				&isolated_outputs,
1146				parent_txid,
1147				dust_isolation_output_vout,
1148			))
1149		} else {
1150			None
1151		};
1152
1153		// Compute all vtx-ids
1154		let new_vtxo_ids = unsigned_arkoor_txs.iter()
1155			.map(|tx| OutPoint::new(tx.compute_txid(), 0))
1156			.map(|outpoint| VtxoId::from(outpoint))
1157			.collect();
1158
1159		// Compute all sighashes
1160		let mut sighashes = Vec::new();
1161
1162		if let Some((checkpoint_tx, _txid)) = &unsigned_checkpoint_tx {
1163			// Checkpoint signature
1164			sighashes.push(arkoor_sighash(&input.txout(), checkpoint_tx));
1165
1166			// Arkoor transaction signatures (one per tx)
1167			for vout in 0..outputs.len() {
1168				let prevout = checkpoint_tx.output[vout].clone();
1169				sighashes.push(arkoor_sighash(&prevout, &unsigned_arkoor_txs[vout]));
1170			}
1171		} else {
1172			// Single direct arkoor transaction signature
1173			sighashes.push(arkoor_sighash(&input.txout(), &unsigned_arkoor_txs[0]));
1174		}
1175
1176		// Add dust sighash
1177		if let Some(ref tx) = unsigned_isolation_fanout_tx {
1178			let dust_output_vout = outputs.len();  // Same for both modes
1179			let prevout = if let Some((checkpoint_tx, _txid)) = &unsigned_checkpoint_tx {
1180				checkpoint_tx.output[dust_output_vout].clone()
1181			} else {
1182				// In direct mode, it's the isolation output from the arkoor tx
1183				unsigned_arkoor_txs[0].output[dust_output_vout].clone()
1184			};
1185			sighashes.push(arkoor_sighash(&prevout, tx));
1186		}
1187
1188		// Compute taptweaks
1189		let policy = ServerVtxoPolicy::new_checkpoint(input.user_pubkey());
1190		let input_tweak = input.output_taproot().tap_tweak();
1191		let checkpoint_policy_tweak = policy.taproot(
1192			input.server_pubkey(),
1193			input.exit_delta(),
1194			input.expiry_height(),
1195		).tap_tweak();
1196
1197		Ok(Self {
1198			input: input,
1199			outputs: outputs,
1200			isolated_outputs,
1201			sighashes: sighashes,
1202			input_tweak,
1203			checkpoint_policy_tweak,
1204			checkpoint_data: unsigned_checkpoint_tx,
1205			unsigned_arkoor_txs: unsigned_arkoor_txs,
1206			unsigned_isolation_fanout_tx,
1207			new_vtxo_ids: new_vtxo_ids,
1208			user_pub_nonces: None,
1209			user_sec_nonces: None,
1210			server_pub_nonces: None,
1211			server_partial_sigs: None,
1212			full_signatures: None,
1213			_state: PhantomData,
1214		})
1215	}
1216
1217	/// Generates the user nonces and moves the builder to the [state::UserGeneratedNonces] state
1218	/// This is the path that is used by the user
1219	pub fn generate_user_nonces(
1220		mut self,
1221		user_keypair: Keypair,
1222	) -> ArkoorBuilder<state::UserGeneratedNonces> {
1223		let mut user_pub_nonces = Vec::with_capacity(self.nb_sigs());
1224		let mut user_sec_nonces = Vec::with_capacity(self.nb_sigs());
1225
1226		for idx in 0..self.nb_sigs() {
1227			let sighash = &self.sighashes[idx].to_byte_array();
1228			let (sec_nonce, pub_nonce) = musig::nonce_pair_with_msg(&user_keypair, sighash);
1229
1230			user_pub_nonces.push(pub_nonce);
1231			user_sec_nonces.push(sec_nonce);
1232		}
1233
1234		self.user_pub_nonces = Some(user_pub_nonces);
1235		self.user_sec_nonces = Some(user_sec_nonces);
1236
1237		self.to_state::<state::UserGeneratedNonces>()
1238	}
1239
1240	/// Sets the pub nonces that a user has generated.
1241	/// When this has happened the server can cosign.
1242	///
1243	/// If you are implementing a client, use [Self::generate_user_nonces] instead.
1244	/// If you are implementing a server you should look at
1245	/// [ArkoorBuilder::from_cosign_request].
1246	fn set_user_pub_nonces(
1247		mut self,
1248		user_pub_nonces: Vec<musig::PublicNonce>,
1249	) -> Result<ArkoorBuilder<state::ServerCanCosign>, ArkoorSigningError> {
1250		if user_pub_nonces.len() != self.nb_sigs() {
1251			return Err(ArkoorSigningError::InvalidNbUserNonces {
1252				expected: self.nb_sigs(),
1253				got: user_pub_nonces.len()
1254			})
1255		}
1256
1257		self.user_pub_nonces = Some(user_pub_nonces);
1258		Ok(self.to_state::<state::ServerCanCosign>())
1259	}
1260}
1261
1262impl<'a> ArkoorBuilder<state::ServerCanCosign> {
1263	pub fn from_cosign_request(
1264		cosign_request: ArkoorCosignRequest<Vtxo<Full>>,
1265	) -> Result<ArkoorBuilder<state::ServerCanCosign>, ArkoorSigningError> {
1266		let ret = ArkoorBuilder::new(
1267			cosign_request.input,
1268			cosign_request.outputs,
1269			cosign_request.isolated_outputs,
1270			cosign_request.use_checkpoint,
1271		)
1272			.map_err(ArkoorSigningError::ArkoorConstructionError)?
1273			.set_user_pub_nonces(cosign_request.user_pub_nonces.clone())?;
1274		Ok(ret)
1275	}
1276
1277	pub fn server_cosign(
1278		mut self,
1279		server_keypair: &Keypair,
1280	) -> Result<ArkoorBuilder<state::ServerSigned>, ArkoorSigningError> {
1281		// Verify that the provided keypair is correct
1282		if server_keypair.public_key() != self.input.server_pubkey() {
1283			return Err(ArkoorSigningError::IncorrectKey {
1284				expected: self.input.server_pubkey(),
1285				got: server_keypair.public_key(),
1286			});
1287		}
1288
1289		let mut server_pub_nonces = Vec::with_capacity(self.outputs.len() + 1);
1290		let mut server_partial_sigs = Vec::with_capacity(self.outputs.len() + 1);
1291
1292		for idx in 0..self.nb_sigs() {
1293			let (server_pub_nonce, server_partial_sig) = musig::deterministic_partial_sign(
1294				&server_keypair,
1295				[self.input.user_pubkey()],
1296				&[&self.user_pub_nonces.as_ref().expect("state-invariant")[idx]],
1297				self.sighashes[idx].to_byte_array(),
1298				Some(self.taptweak_at(idx).to_byte_array()),
1299			);
1300
1301			server_pub_nonces.push(server_pub_nonce);
1302			server_partial_sigs.push(server_partial_sig);
1303		};
1304
1305		self.server_pub_nonces = Some(server_pub_nonces);
1306		self.server_partial_sigs = Some(server_partial_sigs);
1307		Ok(self.to_state::<state::ServerSigned>())
1308	}
1309}
1310
1311impl ArkoorBuilder<state::ServerSigned> {
1312	pub fn user_pub_nonces(&self) -> Vec<musig::PublicNonce> {
1313		self.user_pub_nonces.as_ref().expect("state invariant").clone()
1314	}
1315
1316	pub fn server_partial_signatures(&self) -> Vec<musig::PartialSignature> {
1317		self.server_partial_sigs.as_ref().expect("state invariant").clone()
1318	}
1319
1320	pub fn cosign_response(&self) -> ArkoorCosignResponse {
1321		ArkoorCosignResponse {
1322			server_pub_nonces: self.server_pub_nonces.as_ref()
1323				.expect("state invariant").clone(),
1324			server_partial_sigs: self.server_partial_sigs.as_ref()
1325				.expect("state invariant").clone(),
1326		}
1327	}
1328}
1329
1330impl ArkoorBuilder<state::UserGeneratedNonces> {
1331	pub fn user_pub_nonces(&self) -> &[PublicNonce] {
1332		self.user_pub_nonces.as_ref().expect("State invariant")
1333	}
1334
1335	pub fn cosign_request(&self) -> ArkoorCosignRequest<Vtxo<Full>> {
1336		ArkoorCosignRequest {
1337			user_pub_nonces: self.user_pub_nonces().to_vec(),
1338			input: self.input.clone(),
1339			outputs: self.outputs.clone(),
1340			isolated_outputs: self.isolated_outputs.clone(),
1341			use_checkpoint: self.checkpoint_data.is_some(),
1342		}
1343	}
1344
1345	fn validate_server_cosign_response(
1346		&self,
1347		data: &ArkoorCosignResponse,
1348	) -> Result<(), ArkoorSigningError> {
1349
1350		// Check if the correct number of nonces is provided
1351		if data.server_pub_nonces.len() != self.nb_sigs() {
1352			return Err(ArkoorSigningError::InvalidNbServerNonces {
1353				expected: self.nb_sigs(),
1354				got: data.server_pub_nonces.len(),
1355			});
1356		}
1357
1358		if data.server_partial_sigs.len() != self.nb_sigs() {
1359			return Err(ArkoorSigningError::InvalidNbServerPartialSigs {
1360				expected: self.nb_sigs(),
1361				got: data.server_partial_sigs.len(),
1362			})
1363		}
1364
1365		// Check if the partial signatures is valid
1366		for idx in 0..self.nb_sigs() {
1367			let is_valid_sig = scripts::verify_partial_sig(
1368				self.sighashes[idx],
1369				self.taptweak_at(idx),
1370				(self.input.server_pubkey(), &data.server_pub_nonces[idx]),
1371				(self.input.user_pubkey(), &self.user_pub_nonces()[idx]),
1372				&data.server_partial_sigs[idx]
1373			);
1374
1375			if !is_valid_sig {
1376				return Err(ArkoorSigningError::InvalidPartialSignature {
1377					index: idx,
1378				});
1379			}
1380		}
1381		Ok(())
1382	}
1383
1384	pub fn user_cosign(
1385		mut self,
1386		user_keypair: &Keypair,
1387		server_cosign_data: &ArkoorCosignResponse,
1388	) -> Result<ArkoorBuilder<state::UserSigned>, ArkoorSigningError> {
1389		// Verify that the correct user keypair is provided
1390		if user_keypair.public_key() != self.input.user_pubkey() {
1391			return Err(ArkoorSigningError::IncorrectKey {
1392				expected: self.input.user_pubkey(),
1393				got: user_keypair.public_key(),
1394			});
1395		}
1396
1397		// Verify that the server cosign data is valid
1398		self.validate_server_cosign_response(&server_cosign_data)?;
1399
1400		let mut sigs = Vec::with_capacity(self.nb_sigs());
1401
1402		// Takes the secret nonces out of the [ArkoorBuilder].
1403		// Note, that we can't clone nonces so we can only sign once
1404		let user_sec_nonces = self.user_sec_nonces.take().expect("state invariant");
1405
1406		for (idx, user_sec_nonce) in user_sec_nonces.into_iter().enumerate() {
1407			let user_pub_nonce = self.user_pub_nonces()[idx];
1408			let server_pub_nonce = server_cosign_data.server_pub_nonces[idx];
1409			let agg_nonce = musig::nonce_agg(&[&user_pub_nonce, &server_pub_nonce]);
1410
1411			let (_partial, maybe_sig) = musig::partial_sign(
1412				[self.user_pubkey(), self.server_pubkey()],
1413				agg_nonce,
1414				&user_keypair,
1415				user_sec_nonce,
1416				self.sighashes[idx].to_byte_array(),
1417				Some(self.taptweak_at(idx).to_byte_array()),
1418				Some(&[&server_cosign_data.server_partial_sigs[idx]])
1419			);
1420
1421			let sig = maybe_sig.expect("The full signature exists. The server did sign first");
1422			sigs.push(sig);
1423		}
1424
1425		self.full_signatures = Some(sigs);
1426
1427		Ok(self.to_state::<state::UserSigned>())
1428	}
1429}
1430
1431
1432impl<'a> ArkoorBuilder<state::UserSigned> {
1433	pub fn build_signed_vtxos(&self) -> Vec<Vtxo<Full>> {
1434		let sigs = self.full_signatures.as_ref().expect("state invariant");
1435		let mut ret = Vec::with_capacity(self.outputs.len() + self.isolated_outputs.len());
1436
1437		if self.checkpoint_data.is_some() {
1438			let checkpoint_sig = sigs[0];
1439
1440			// Build regular vtxos (signatures 1..1+m)
1441			for i in 0..self.outputs.len() {
1442				let arkoor_sig = sigs[1 + i];
1443				ret.push(self.build_vtxo_at(i, Some(checkpoint_sig), Some(arkoor_sig)));
1444			}
1445
1446			// Build isolated vtxos if present
1447			if self.unsigned_isolation_fanout_tx.is_some() {
1448				let m = self.outputs.len();
1449				let fanout_tx_sig = sigs[1 + m];
1450
1451				for i in 0..self.isolated_outputs.len() {
1452					ret.push(self.build_isolated_vtxo_at(
1453						i,
1454						Some(checkpoint_sig),
1455						Some(fanout_tx_sig),
1456					));
1457				}
1458			}
1459		} else {
1460			// Direct mode: no checkpoint signature
1461			let arkoor_sig = sigs[0];
1462
1463			// Build regular vtxos (all use same arkoor signature)
1464			for i in 0..self.outputs.len() {
1465				ret.push(self.build_vtxo_at(i, None, Some(arkoor_sig)));
1466			}
1467
1468			// Build isolation vtxos if present
1469			if self.unsigned_isolation_fanout_tx.is_some() {
1470				let fanout_tx_sig = sigs[1];
1471
1472				for i in 0..self.isolated_outputs.len() {
1473					ret.push(self.build_isolated_vtxo_at(
1474						i,
1475						Some(arkoor_sig),  // In direct mode, first sig is arkoor, not checkpoint
1476						Some(fanout_tx_sig),
1477					));
1478				}
1479			}
1480		}
1481
1482		ret
1483	}
1484}
1485
1486fn arkoor_sighash(prevout: &TxOut, arkoor_tx: &Transaction) -> TapSighash {
1487	let mut shc = SighashCache::new(arkoor_tx);
1488
1489	shc.taproot_key_spend_signature_hash(
1490		0, &sighash::Prevouts::All(&[prevout]), TapSighashType::Default,
1491	).expect("sighash error")
1492}
1493
1494#[cfg(test)]
1495mod test {
1496	use super::*;
1497
1498	use std::collections::HashSet;
1499
1500	use bitcoin::Amount;
1501	use bitcoin::secp256k1::Keypair;
1502	use bitcoin::secp256k1::rand;
1503
1504	use crate::SECP;
1505	use crate::test_util::dummy::DummyTestVtxoSpec;
1506	use crate::vtxo::VtxoId;
1507
1508	/// Verify properties of spend_info(), build_unsigned_internal_vtxos(), and final vtxos.
1509	fn verify_builder<S: state::BuilderState>(
1510		builder: &ArkoorBuilder<S>,
1511		input: &Vtxo<Full>,
1512		outputs: &[ArkoorDestination],
1513		isolated_outputs: &[ArkoorDestination],
1514	) {
1515		let has_isolation = !isolated_outputs.is_empty();
1516
1517		let spend_info = builder.spend_info();
1518		let spend_vtxo_ids: HashSet<VtxoId> = spend_info.iter().map(|(id, _)| *id).collect();
1519
1520		// the input vtxo is the first to be spent
1521		assert_eq!(spend_info[0].0, input.id());
1522
1523		// no vtxo should be spent twice
1524		assert_eq!(spend_vtxo_ids.len(), spend_info.len());
1525
1526		// all intermediate vtxos are spent and use checkpoint policy for efficient cosigning
1527		let internal_vtxos = builder.build_unsigned_internal_vtxos().collect::<Vec<_>>();
1528		let internal_vtxo_ids = internal_vtxos.iter().map(|v| v.id()).collect::<HashSet<_>>();
1529		for internal_vtxo in &internal_vtxos {
1530			assert!(spend_vtxo_ids.contains(&internal_vtxo.id()));
1531			assert!(matches!(internal_vtxo.policy(), ServerVtxoPolicy::Checkpoint(_)));
1532		}
1533
1534		// all spent vtxos except the input are internal vtxos
1535		for (vtxo_id, _) in &spend_info[1..] {
1536			assert!(internal_vtxo_ids.contains(vtxo_id));
1537		}
1538
1539		// isolation vtxo holds combined value of all dust outputs
1540		if has_isolation {
1541			let isolation_vtxo = internal_vtxos.last().unwrap();
1542			let expected_isolation_amount: Amount = isolated_outputs.iter()
1543				.map(|o| o.total_amount)
1544				.sum();
1545			assert_eq!(isolation_vtxo.amount(), expected_isolation_amount);
1546		}
1547
1548		// final vtxos are unspent outputs that recipients receive
1549		let final_vtxos = builder.build_unsigned_vtxos().collect::<Vec<_>>();
1550		for final_vtxo in &final_vtxos {
1551			assert!(!spend_vtxo_ids.contains(&final_vtxo.id()));
1552		}
1553
1554		// final vtxos match requested destinations
1555		let all_destinations = outputs.iter()
1556			.chain(isolated_outputs.iter())
1557			.collect::<Vec<&_>>();
1558		for (vtxo, dest) in final_vtxos.iter().zip(all_destinations.iter()) {
1559			assert_eq!(vtxo.amount(), dest.total_amount);
1560			assert_eq!(vtxo.policy, dest.policy);
1561		}
1562
1563		// total value is conserved
1564		let total_output_amount: Amount = final_vtxos.iter().map(|v| v.amount()).sum();
1565		assert_eq!(total_output_amount, input.amount());
1566	}
1567
1568	#[test]
1569	fn build_checkpointed_arkoor() {
1570		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1571		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1572		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1573
1574		println!("Alice keypair: {}", alice_keypair.public_key());
1575		println!("Bob keypair: {}", bob_keypair.public_key());
1576		println!("Server keypair: {}", server_keypair.public_key());
1577		println!("-----------------------------------------------");
1578
1579		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1580			amount: Amount::from_sat(100_330),
1581			fee: Amount::from_sat(330),
1582			expiry_height: 1000,
1583			exit_delta : 128,
1584			user_keypair: alice_keypair.clone(),
1585			server_keypair: server_keypair.clone()
1586		}.build();
1587
1588		// Validate Alice's vtxo
1589		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1590
1591		let dest = vec![
1592			ArkoorDestination {
1593				total_amount: Amount::from_sat(96_000),
1594				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1595			},
1596			ArkoorDestination {
1597				total_amount: Amount::from_sat(4_000),
1598				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1599			}
1600		];
1601
1602		let user_builder = ArkoorBuilder::new_with_checkpoint(
1603			alice_vtxo.clone(),
1604			dest.clone(),
1605			vec![], // no isolation outputs
1606		).expect("Valid arkoor request");
1607
1608		verify_builder(&user_builder, &alice_vtxo, &dest, &[]);
1609
1610		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1611		let cosign_request = user_builder.cosign_request();
1612
1613		// The server will cosign the request
1614		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1615			.expect("Invalid cosign request")
1616			.server_cosign(&server_keypair)
1617			.expect("Incorrect key");
1618
1619		let cosign_data = server_builder.cosign_response();
1620
1621		// The user will cosign the request and construct their vtxos
1622		let vtxos = user_builder
1623			.user_cosign(&alice_keypair, &cosign_data)
1624			.expect("Valid cosign data and correct key")
1625			.build_signed_vtxos();
1626
1627		for vtxo in vtxos.into_iter() {
1628			// Check if the vtxo is considered valid
1629			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1630
1631			// Check all transactions using libbitcoin-kernel
1632			let mut prev_tx = funding_tx.clone();
1633			for tx in vtxo.transactions().map(|item| item.tx) {
1634				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1635				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1636				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1637				prev_tx = tx;
1638			}
1639		}
1640
1641	}
1642
1643	#[test]
1644	fn build_checkpointed_arkoor_with_dust_isolation() {
1645		// Test mixed outputs: some dust, some non-dust
1646		// This should activate dust isolation
1647		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1648		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1649		let charlie_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1650		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1651
1652		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1653			amount: Amount::from_sat(100_330),
1654			fee: Amount::from_sat(330),
1655			expiry_height: 1000,
1656			exit_delta : 128,
1657			user_keypair: alice_keypair.clone(),
1658			server_keypair: server_keypair.clone()
1659		}.build();
1660
1661		// Validate Alice's vtxo
1662		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1663
1664		// Non-dust outputs (>= 330 sats)
1665		let outputs = vec![
1666			ArkoorDestination {
1667				total_amount: Amount::from_sat(99_600),
1668				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1669			},
1670		];
1671
1672		// dust outputs (< 330 sats each, but combined >= 330)
1673		let dust_outputs = vec![
1674			ArkoorDestination {
1675				total_amount: Amount::from_sat(200),  // < 330, truly dust
1676				policy: VtxoPolicy::new_pubkey(charlie_keypair.public_key())
1677			},
1678			ArkoorDestination {
1679				total_amount: Amount::from_sat(200),  // < 330, truly dust
1680				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1681			}
1682		];
1683
1684		let user_builder = ArkoorBuilder::new_with_checkpoint(
1685			alice_vtxo.clone(),
1686			outputs.clone(),
1687			dust_outputs.clone(),
1688		).expect("Valid arkoor request with dust isolation");
1689
1690		verify_builder(&user_builder, &alice_vtxo, &outputs, &dust_outputs);
1691
1692		// Verify dust isolation is active
1693		assert!(
1694			user_builder.unsigned_isolation_fanout_tx.is_some(),
1695			"Dust isolation should be active",
1696		);
1697
1698		// Check signature count: 1 checkpoint + 1 arkoor + 1 dust fanout = 3
1699		assert_eq!(user_builder.nb_sigs(), 3);
1700
1701		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1702		let cosign_request = user_builder.cosign_request();
1703
1704		// The server will cosign the request
1705		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1706			.expect("Invalid cosign request")
1707			.server_cosign(&server_keypair)
1708			.expect("Incorrect key");
1709
1710		let cosign_data = server_builder.cosign_response();
1711
1712		// The user will cosign the request and construct their vtxos
1713		let vtxos = user_builder
1714			.user_cosign(&alice_keypair, &cosign_data)
1715			.expect("Valid cosign data and correct key")
1716			.build_signed_vtxos();
1717
1718		// Should have 3 vtxos: 1 non-dust + 2 dust
1719		assert_eq!(vtxos.len(), 3);
1720
1721		for vtxo in vtxos.into_iter() {
1722			// Check if the vtxo is considered valid
1723			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1724
1725			// Check all transactions using libbitcoin-kernel
1726			let mut prev_tx = funding_tx.clone();
1727			for tx in vtxo.transactions().map(|item| item.tx) {
1728				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1729				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1730				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1731				prev_tx = tx;
1732			}
1733		}
1734	}
1735
1736	#[test]
1737	fn build_no_checkpoint_arkoor() {
1738		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1739		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1740		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1741
1742		println!("Alice keypair: {}", alice_keypair.public_key());
1743		println!("Bob keypair: {}", bob_keypair.public_key());
1744		println!("Server keypair: {}", server_keypair.public_key());
1745		println!("-----------------------------------------------");
1746
1747		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1748			amount: Amount::from_sat(100_330),
1749			fee: Amount::from_sat(330),
1750			expiry_height: 1000,
1751			exit_delta : 128,
1752			user_keypair: alice_keypair.clone(),
1753			server_keypair: server_keypair.clone()
1754		}.build();
1755
1756		// Validate Alice's vtxo
1757		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1758
1759		let dest = vec![
1760			ArkoorDestination {
1761				total_amount: Amount::from_sat(96_000),
1762				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1763			},
1764			ArkoorDestination {
1765				total_amount: Amount::from_sat(4_000),
1766				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1767			}
1768		];
1769
1770		let user_builder = ArkoorBuilder::new_without_checkpoint(
1771			alice_vtxo.clone(),
1772			dest.clone(),
1773			vec![], // no isolation outputs
1774		).expect("Valid arkoor request");
1775
1776		verify_builder(&user_builder, &alice_vtxo, &dest, &[]);
1777
1778		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1779		let cosign_request = user_builder.cosign_request();
1780
1781		// The server will cosign the request
1782		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1783			.expect("Invalid cosign request")
1784			.server_cosign(&server_keypair)
1785			.expect("Incorrect key");
1786
1787		let cosign_data = server_builder.cosign_response();
1788
1789		// The user will cosign the request and construct their vtxos
1790		let vtxos = user_builder
1791			.user_cosign(&alice_keypair, &cosign_data)
1792			.expect("Valid cosign data and correct key")
1793			.build_signed_vtxos();
1794
1795		for vtxo in vtxos.into_iter() {
1796			// Check if the vtxo is considered valid
1797			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1798
1799			// Check all transactions using libbitcoin-kernel
1800			let mut prev_tx = funding_tx.clone();
1801			for tx in vtxo.transactions().map(|item| item.tx) {
1802				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1803				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1804				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1805				prev_tx = tx;
1806			}
1807		}
1808
1809	}
1810
1811	#[test]
1812	fn build_no_checkpoint_arkoor_with_dust_isolation() {
1813		// Test mixed outputs: some dust, some non-dust
1814		// This should activate dust isolation
1815		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1816		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1817		let charlie_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1818		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1819
1820		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1821			amount: Amount::from_sat(100_330),
1822			fee: Amount::from_sat(330),
1823			expiry_height: 1000,
1824			exit_delta : 128,
1825			user_keypair: alice_keypair.clone(),
1826			server_keypair: server_keypair.clone()
1827		}.build();
1828
1829		// Validate Alice's vtxo
1830		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1831
1832		// Non-dust outputs (>= 330 sats)
1833		let outputs = vec![
1834			ArkoorDestination {
1835				total_amount: Amount::from_sat(99_600),
1836				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1837			},
1838		];
1839
1840		// dust outputs (< 330 sats each, but combined >= 330)
1841		let dust_outputs = vec![
1842			ArkoorDestination {
1843				total_amount: Amount::from_sat(200),  // < 330, truly dust
1844				policy: VtxoPolicy::new_pubkey(charlie_keypair.public_key())
1845			},
1846			ArkoorDestination {
1847				total_amount: Amount::from_sat(200),  // < 330, truly dust
1848				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1849			}
1850		];
1851
1852		let user_builder = ArkoorBuilder::new_without_checkpoint(
1853			alice_vtxo.clone(),
1854			outputs.clone(),
1855			dust_outputs.clone(),
1856		).expect("Valid arkoor request with dust isolation");
1857
1858		verify_builder(&user_builder, &alice_vtxo, &outputs, &dust_outputs);
1859
1860		// Verify dust isolation is active
1861		assert!(
1862			user_builder.unsigned_isolation_fanout_tx.is_some(),
1863			"Dust isolation should be active",
1864		);
1865
1866		// Check signature count: 1 arkoor + 1 dust fanout = 2
1867		// (no checkpoint in non-checkpointed mode)
1868		assert_eq!(user_builder.nb_sigs(), 2);
1869
1870		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1871		let cosign_request = user_builder.cosign_request();
1872
1873		// The server will cosign the request
1874		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1875			.expect("Invalid cosign request")
1876			.server_cosign(&server_keypair)
1877			.expect("Incorrect key");
1878
1879		let cosign_data = server_builder.cosign_response();
1880
1881		// The user will cosign the request and construct their vtxos
1882		let vtxos = user_builder
1883			.user_cosign(&alice_keypair, &cosign_data)
1884			.expect("Valid cosign data and correct key")
1885			.build_signed_vtxos();
1886
1887		// Should have 3 vtxos: 1 non-dust + 2 dust
1888		assert_eq!(vtxos.len(), 3);
1889
1890		for vtxo in vtxos.into_iter() {
1891			// Check if the vtxo is considered valid
1892			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1893
1894			// Check all transactions using libbitcoin-kernel
1895			let mut prev_tx = funding_tx.clone();
1896			for tx in vtxo.transactions().map(|item| item.tx) {
1897				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1898				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1899				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1900				prev_tx = tx;
1901			}
1902		}
1903	}
1904
1905	#[test]
1906	fn build_checkpointed_arkoor_outputs_must_be_above_dust_if_mixed() {
1907		// Test that outputs in the outputs list must be >= P2TR_DUST
1908		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1909		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1910		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1911
1912		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1913			amount: Amount::from_sat(1_330),
1914			fee: Amount::from_sat(330),
1915			expiry_height: 1000,
1916			exit_delta : 128,
1917			user_keypair: alice_keypair.clone(),
1918			server_keypair: server_keypair.clone()
1919		}.build();
1920
1921		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1922
1923		// only dust is allowed
1924		ArkoorBuilder::new_with_checkpoint(
1925			alice_vtxo.clone(),
1926			vec![
1927				ArkoorDestination {
1928					total_amount: Amount::from_sat(100),  // < 330 sats (P2TR_DUST)
1929					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1930				}; 10
1931			],
1932			vec![],
1933		).unwrap();
1934
1935		// empty outputs vec is not allowed (need at least one normal output)
1936		let res_empty = ArkoorBuilder::new_with_checkpoint(
1937			alice_vtxo.clone(),
1938			vec![],
1939			vec![
1940				ArkoorDestination {
1941					total_amount: Amount::from_sat(100),  // < 330 sats (P2TR_DUST)
1942					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1943				}; 10
1944			],
1945		);
1946		match res_empty {
1947			Err(ArkoorConstructionError::NoOutputs) => {},
1948			_ => panic!("Expected NoOutputs error for empty outputs"),
1949		}
1950
1951		// normal case: non-dust in normal outputs and dust in isolation
1952		ArkoorBuilder::new_with_checkpoint(
1953			alice_vtxo.clone(),
1954			vec![
1955				ArkoorDestination {
1956					total_amount: Amount::from_sat(330),  // >= 330 sats
1957					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1958				}; 2
1959			],
1960			vec![
1961				ArkoorDestination {
1962					total_amount: Amount::from_sat(170),
1963					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1964				}; 2
1965			],
1966		).unwrap();
1967
1968		// mixing with isolation sum < 330 should fail
1969		let res_mixed_small = ArkoorBuilder::new_with_checkpoint(
1970			alice_vtxo.clone(),
1971			vec![
1972				ArkoorDestination {
1973					total_amount: Amount::from_sat(500),
1974					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1975				},
1976				ArkoorDestination {
1977					total_amount: Amount::from_sat(300),
1978					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1979				}
1980			],
1981			vec![
1982				ArkoorDestination {
1983					total_amount: Amount::from_sat(100),
1984					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1985				}; 2  // sum = 200, which is < 330
1986			],
1987		);
1988		match res_mixed_small {
1989			Err(ArkoorConstructionError::Dust) => {},
1990			_ => panic!("Expected Dust error for isolation sum < 330"),
1991		}
1992	}
1993
1994	#[test]
1995	fn build_checkpointed_arkoor_dust_sum_too_small() {
1996		// Test that dust_sum < P2TR_DUST is now allowed after removing validation
1997		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1998		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1999		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2000
2001		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2002			amount: Amount::from_sat(100_330),
2003			fee: Amount::from_sat(330),
2004			expiry_height: 1000,
2005			exit_delta : 128,
2006			user_keypair: alice_keypair.clone(),
2007			server_keypair: server_keypair.clone()
2008		}.build();
2009
2010		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
2011
2012		// Non-dust outputs
2013		let outputs = vec![
2014			ArkoorDestination {
2015				total_amount: Amount::from_sat(99_900),
2016				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2017			},
2018		];
2019
2020		// dust outputs with combined sum < P2TR_DUST (330)
2021		let dust_outputs = vec![
2022			ArkoorDestination {
2023				total_amount: Amount::from_sat(50),
2024				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2025			},
2026			ArkoorDestination {
2027				total_amount: Amount::from_sat(50),
2028				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2029			}
2030		];
2031
2032		// This should fail because isolation sum (100) < P2TR_DUST (330)
2033		let result = ArkoorBuilder::new_with_checkpoint(
2034			alice_vtxo.clone(),
2035			outputs.clone(),
2036			dust_outputs.clone(),
2037		);
2038		match result {
2039			Err(ArkoorConstructionError::Dust) => {},
2040			_ => panic!("Expected Dust error for isolation sum < 330"),
2041		}
2042	}
2043
2044	#[test]
2045	fn spend_dust_vtxo() {
2046		// Test the "all dust" case: create a 200 sat vtxo and split into two 100 sat outputs
2047		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2048		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2049		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2050
2051		// Create a 200 sat input vtxo (this is dust since 200 < 330)
2052		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2053			amount: Amount::from_sat(200),
2054			fee: Amount::ZERO,
2055			expiry_height: 1000,
2056			exit_delta: 128,
2057			user_keypair: alice_keypair.clone(),
2058			server_keypair: server_keypair.clone()
2059		}.build();
2060
2061		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
2062
2063		// Split into two 100 sat outputs
2064		// outputs is empty, all outputs go to dust_outputs
2065		let dust_outputs = vec![
2066			ArkoorDestination {
2067				total_amount: Amount::from_sat(100),
2068				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2069			},
2070			ArkoorDestination {
2071				total_amount: Amount::from_sat(100),
2072				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2073			}
2074		];
2075
2076		let user_builder = ArkoorBuilder::new_with_checkpoint(
2077			alice_vtxo.clone(),
2078			dust_outputs,
2079			vec![],
2080		).expect("Valid arkoor request for all-dust case");
2081
2082		// Verify dust isolation is NOT active (all-dust case, no mixing)
2083		assert!(
2084			user_builder.unsigned_isolation_fanout_tx.is_none(),
2085			"Dust isolation should NOT be active",
2086		);
2087
2088		// Check we have 2 outputs
2089		assert_eq!(user_builder.outputs.len(), 2);
2090
2091		// Check signature count: 1 checkpoint + 2 arkoor = 3
2092		assert_eq!(user_builder.nb_sigs(), 3);
2093
2094		// The user generates their nonces
2095		let user_builder = user_builder.generate_user_nonces(alice_keypair);
2096		let cosign_request = user_builder.cosign_request();
2097
2098		// The server will cosign the request
2099		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
2100			.expect("Invalid cosign request")
2101			.server_cosign(&server_keypair)
2102			.expect("Incorrect key");
2103
2104		let cosign_data = server_builder.cosign_response();
2105
2106		// The user will cosign the request and construct their vtxos
2107		let vtxos = user_builder
2108			.user_cosign(&alice_keypair, &cosign_data)
2109			.expect("Valid cosign data and correct key")
2110			.build_signed_vtxos();
2111
2112		// Should have 2 vtxos
2113		assert_eq!(vtxos.len(), 2);
2114
2115		for vtxo in vtxos.into_iter() {
2116			// Check if the vtxo is considered valid
2117			vtxo.validate(&funding_tx).expect("Invalid VTXO");
2118
2119			// Verify amount is 100 sats
2120			assert_eq!(vtxo.amount(), Amount::from_sat(100));
2121
2122			// Check all transactions using libbitcoin-kernel
2123			let mut prev_tx = funding_tx.clone();
2124			for tx in vtxo.transactions().map(|item| item.tx) {
2125				let prev_outpoint: OutPoint = tx.input[0].previous_output;
2126				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
2127				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
2128				prev_tx = tx;
2129			}
2130		}
2131	}
2132
2133	#[test]
2134	fn spend_nondust_vtxo_to_dust() {
2135		// Test: take a 500 sat vtxo (above dust) and split into two 250 sat vtxos (below dust)
2136		// Input is non-dust, outputs are all dust - no dust isolation needed
2137		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2138		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2139		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2140
2141		// Create a 500 sat input vtxo (this is above P2TR_DUST of 330)
2142		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2143			amount: Amount::from_sat(500),
2144			fee: Amount::ZERO,
2145			expiry_height: 1000,
2146			exit_delta: 128,
2147			user_keypair: alice_keypair.clone(),
2148			server_keypair: server_keypair.clone()
2149		}.build();
2150
2151		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
2152
2153		// Split into two 250 sat outputs (each below P2TR_DUST)
2154		// outputs is empty, all outputs go to dust_outputs
2155		let dust_outputs = vec![
2156			ArkoorDestination {
2157				total_amount: Amount::from_sat(250),
2158				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2159			},
2160			ArkoorDestination {
2161				total_amount: Amount::from_sat(250),
2162				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2163			}
2164		];
2165
2166		let user_builder = ArkoorBuilder::new_with_checkpoint(
2167			alice_vtxo.clone(),
2168			dust_outputs,
2169			vec![],
2170		).expect("Valid arkoor request for non-dust to dust case");
2171
2172		// Verify dust isolation is NOT active (all-dust case, no mixing)
2173		assert!(
2174			user_builder.unsigned_isolation_fanout_tx.is_none(),
2175			"Dust isolation should NOT be active",
2176		);
2177
2178		// Check we have 2 outputs
2179		assert_eq!(user_builder.outputs.len(), 2);
2180
2181		// Check signature count: 1 checkpoint + 2 arkoor = 3
2182		assert_eq!(user_builder.nb_sigs(), 3);
2183
2184		// The user generates their nonces
2185		let user_builder = user_builder.generate_user_nonces(alice_keypair);
2186		let cosign_request = user_builder.cosign_request();
2187
2188		// The server will cosign the request
2189		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
2190			.expect("Invalid cosign request")
2191			.server_cosign(&server_keypair)
2192			.expect("Incorrect key");
2193
2194		let cosign_data = server_builder.cosign_response();
2195
2196		// The user will cosign the request and construct their vtxos
2197		let vtxos = user_builder
2198			.user_cosign(&alice_keypair, &cosign_data)
2199			.expect("Valid cosign data and correct key")
2200			.build_signed_vtxos();
2201
2202		// Should have 2 vtxos
2203		assert_eq!(vtxos.len(), 2);
2204
2205		for vtxo in vtxos.into_iter() {
2206			// Check if the vtxo is considered valid
2207			vtxo.validate(&funding_tx).expect("Invalid VTXO");
2208
2209			// Verify amount is 250 sats
2210			assert_eq!(vtxo.amount(), Amount::from_sat(250));
2211
2212			// Check all transactions using libbitcoin-kernel
2213			let mut prev_tx = funding_tx.clone();
2214			for tx in vtxo.transactions().map(|item| item.tx) {
2215				let prev_outpoint: OutPoint = tx.input[0].previous_output;
2216				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
2217				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
2218				prev_tx = tx;
2219			}
2220		}
2221	}
2222
2223	#[test]
2224	fn isolate_dust_all_nondust() {
2225		// Test scenario: All outputs >= 330 sats
2226		// Should use normal path without isolation
2227		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2228		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2229		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2230
2231		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2232			amount: Amount::from_sat(1000),
2233			fee: Amount::ZERO,
2234			expiry_height: 1000,
2235			exit_delta: 128,
2236			user_keypair: alice_keypair.clone(),
2237			server_keypair: server_keypair.clone()
2238		}.build();
2239
2240		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2241
2242		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2243			alice_vtxo,
2244			vec![
2245				ArkoorDestination {
2246					total_amount: Amount::from_sat(500),
2247					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2248				},
2249				ArkoorDestination {
2250					total_amount: Amount::from_sat(500),
2251					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2252				}
2253			],
2254		).unwrap();
2255
2256		// Should not have dust isolation active
2257		assert!(builder.unsigned_isolation_fanout_tx.is_none());
2258
2259		// Should have 2 regular outputs
2260		assert_eq!(builder.outputs.len(), 2);
2261		assert_eq!(builder.isolated_outputs.len(), 0);
2262	}
2263
2264	#[test]
2265	fn isolate_dust_all_dust() {
2266		// Test scenario: All outputs < 330 sats
2267		// Should use all-dust path
2268		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2269		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2270		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2271
2272		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2273			amount: Amount::from_sat(400),
2274			fee: Amount::ZERO,
2275			expiry_height: 1000,
2276			exit_delta: 128,
2277			user_keypair: alice_keypair.clone(),
2278			server_keypair: server_keypair.clone()
2279		}.build();
2280
2281		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2282
2283		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2284			alice_vtxo,
2285			vec![
2286				ArkoorDestination {
2287					total_amount: Amount::from_sat(200),
2288					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2289				},
2290				ArkoorDestination {
2291					total_amount: Amount::from_sat(200),
2292					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2293				}
2294			],
2295		).unwrap();
2296
2297		// Should not have dust isolation active (all dust)
2298		assert!(builder.unsigned_isolation_fanout_tx.is_none());
2299
2300		// All outputs should be in outputs vec (no isolation needed)
2301		assert_eq!(builder.outputs.len(), 2);
2302		assert_eq!(builder.isolated_outputs.len(), 0);
2303	}
2304
2305	#[test]
2306	fn isolate_dust_sufficient_dust() {
2307		// Test scenario: Mixed with dust sum >= 330
2308		// Should use dust isolation
2309		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2310		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2311		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2312
2313		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2314			amount: Amount::from_sat(1000),
2315			fee: Amount::ZERO,
2316			expiry_height: 1000,
2317			exit_delta: 128,
2318			user_keypair: alice_keypair.clone(),
2319			server_keypair: server_keypair.clone()
2320		}.build();
2321
2322		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2323
2324		// 600 non-dust + 200 + 200 dust = 400 dust total (>= 330)
2325		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2326			alice_vtxo,
2327			vec![
2328				ArkoorDestination {
2329					total_amount: Amount::from_sat(600),
2330					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2331				},
2332				ArkoorDestination {
2333					total_amount: Amount::from_sat(200),
2334					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2335				},
2336				ArkoorDestination {
2337					total_amount: Amount::from_sat(200),
2338					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2339				}
2340			],
2341		).unwrap();
2342
2343		// Should have dust isolation active
2344		assert!(builder.unsigned_isolation_fanout_tx.is_some());
2345
2346		// 1 regular output, 2 isolated dust outputs
2347		assert_eq!(builder.outputs.len(), 1);
2348		assert_eq!(builder.isolated_outputs.len(), 2);
2349	}
2350
2351	#[test]
2352	fn isolate_dust_split_successful() {
2353		// Test scenario: Mixed with dust sum < 330, but can split
2354		// 800 non-dust + 100 + 100 dust = 200 dust, need 130 more
2355		// Should split 800 into 670 + 130
2356		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2357		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2358		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2359
2360		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2361			amount: Amount::from_sat(1000),
2362			fee: Amount::ZERO,
2363			expiry_height: 1000,
2364			exit_delta: 128,
2365			user_keypair: alice_keypair.clone(),
2366			server_keypair: server_keypair.clone()
2367		}.build();
2368
2369		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2370
2371		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2372			alice_vtxo,
2373			vec![
2374				ArkoorDestination {
2375					total_amount: Amount::from_sat(800),
2376					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2377				},
2378				ArkoorDestination {
2379					total_amount: Amount::from_sat(100),
2380					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2381				},
2382				ArkoorDestination {
2383					total_amount: Amount::from_sat(100),
2384					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2385				}
2386			],
2387		).unwrap();
2388
2389		// Should have dust isolation active (split successful)
2390		assert!(builder.unsigned_isolation_fanout_tx.is_some());
2391
2392		// 1 regular output (670), 3 isolated dust outputs (130 + 100 + 100 = 330)
2393		assert_eq!(builder.outputs.len(), 1);
2394		assert_eq!(builder.isolated_outputs.len(), 3);
2395
2396		// Verify the split amounts
2397		assert_eq!(builder.outputs[0].total_amount, Amount::from_sat(670));
2398		let isolated_sum: Amount = builder.isolated_outputs.iter().map(|o| o.total_amount).sum();
2399		assert_eq!(isolated_sum, P2TR_DUST);
2400	}
2401
2402	#[test]
2403	fn isolate_dust_split_impossible() {
2404		// Test scenario: Mixed with dust sum < 330, can't split
2405		// 400 non-dust + 100 + 100 dust = 200 dust, need 130 more
2406		// 400 - 130 = 270 < 330, can't split without creating two dust
2407		// Should allow mixing without isolation
2408		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2409		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2410		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2411
2412		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2413			amount: Amount::from_sat(600),
2414			fee: Amount::ZERO,
2415			expiry_height: 1000,
2416			exit_delta: 128,
2417			user_keypair: alice_keypair.clone(),
2418			server_keypair: server_keypair.clone()
2419		}.build();
2420
2421		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2422
2423		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2424			alice_vtxo,
2425			vec![
2426				ArkoorDestination {
2427					total_amount: Amount::from_sat(400),
2428					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2429				},
2430				ArkoorDestination {
2431					total_amount: Amount::from_sat(100),
2432					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2433				},
2434				ArkoorDestination {
2435					total_amount: Amount::from_sat(100),
2436					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2437				}
2438			],
2439		).unwrap();
2440
2441		// Should not have dust isolation (mixing allowed)
2442		assert!(builder.unsigned_isolation_fanout_tx.is_none());
2443
2444		// All 3 outputs should be in outputs vec (mixed without isolation)
2445		assert_eq!(builder.outputs.len(), 3);
2446		assert_eq!(builder.isolated_outputs.len(), 0);
2447	}
2448
2449	#[test]
2450	fn isolate_dust_exactly_boundary() {
2451		// Test scenario: dust sum is already >= 330 (exactly at boundary)
2452		// 660 non-dust + 170 + 170 dust = 340 dust (>= 330)
2453		// Should use isolation without splitting
2454		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2455		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2456		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2457
2458		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2459			amount: Amount::from_sat(1000),
2460			fee: Amount::ZERO,
2461			expiry_height: 1000,
2462			exit_delta: 128,
2463			user_keypair: alice_keypair.clone(),
2464			server_keypair: server_keypair.clone()
2465		}.build();
2466
2467		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2468
2469		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2470			alice_vtxo,
2471			vec![
2472				ArkoorDestination {
2473					total_amount: Amount::from_sat(660),
2474					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2475				},
2476				ArkoorDestination {
2477					total_amount: Amount::from_sat(170),
2478					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2479				},
2480				ArkoorDestination {
2481					total_amount: Amount::from_sat(170),
2482					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2483				}
2484			],
2485		).unwrap();
2486
2487		// Should have dust isolation active (340 >= 330)
2488		assert!(builder.unsigned_isolation_fanout_tx.is_some());
2489
2490		// 1 regular output, 2 isolated dust outputs
2491		assert_eq!(builder.outputs.len(), 1);
2492		assert_eq!(builder.isolated_outputs.len(), 2);
2493
2494		// Verify amounts weren't modified
2495		assert_eq!(builder.outputs[0].total_amount, Amount::from_sat(660));
2496		assert_eq!(builder.isolated_outputs[0].total_amount, Amount::from_sat(170));
2497		assert_eq!(builder.isolated_outputs[1].total_amount, Amount::from_sat(170));
2498	}
2499}