Skip to main content

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