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].to_point(),
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 internal VTXOs (checkpoints and dust isolation),
695	/// each paired with the txid of the transaction that spends it.
696	///
697	/// Pass `None` for unsigned, or `Some(sig)` to embed the intermediate
698	/// transaction signature in the genesis data.
699	fn build_internal_vtxos(
700		&self,
701		intermediate_sig: Option<schnorr::Signature>,
702	) -> Vec<(ServerVtxo<Full>, Txid)> {
703		let mut ret = Vec::new();
704
705		if self.checkpoint_data.is_some() {
706			for idx in 0..self.outputs.len() {
707				let vtxo = self.build_checkpoint_vtxo_at(idx, intermediate_sig);
708				let spending_txid = self.unsigned_arkoor_txs[idx].compute_txid();
709				ret.push((vtxo, spending_txid));
710			}
711		}
712
713		if !self.isolated_outputs.is_empty() {
714			let output_idx = self.outputs.len();
715
716			let (int_tx, int_txid) = if let Some((tx, txid)) = &self.checkpoint_data {
717				(tx, *txid)
718			} else {
719				let arkoor_tx = &self.unsigned_arkoor_txs[0];
720				(arkoor_tx, arkoor_tx.compute_txid())
721			};
722
723			let vtxo = Vtxo {
724				amount: self.isolated_outputs.iter().map(|o| o.total_amount).sum(),
725				policy: ServerVtxoPolicy::new_checkpoint(self.input.user_pubkey()),
726				expiry_height: self.input.expiry_height,
727				server_pubkey: self.input.server_pubkey,
728				exit_delta: self.input.exit_delta,
729				point: OutPoint::new(int_txid, output_idx as u32),
730				anchor_point: self.input.anchor_point,
731				genesis: Full {
732					items: self.input.genesis.items.clone().into_iter().chain([
733						GenesisItem {
734							transition: GenesisTransition::new_arkoor(
735								vec![self.input.user_pubkey()],
736								self.input_tweak,
737								intermediate_sig,
738							),
739							output_idx: output_idx as u8,
740							other_outputs: int_tx.output.iter().enumerate()
741								.filter_map(|(i, txout)| {
742									if i == output_idx || txout.is_p2a_fee_anchor() {
743										None
744									} else {
745										Some(txout.clone())
746									}
747								})
748								.collect(),
749							fee_amount: Amount::ZERO,
750						},
751					]).collect(),
752				},
753			};
754
755			let spending_txid = self.unsigned_isolation_fanout_tx.as_ref()
756				.expect("isolation fanout tx must exist when isolated_outputs is non-empty")
757				.compute_txid();
758			ret.push((vtxo, spending_txid));
759		}
760
761		ret
762	}
763
764	/// Returns the (vtxo_id, spending_txid) for the input vtxo.
765	pub fn input_spend_info(&self) -> (VtxoId, Txid) {
766		if let Some((_tx, checkpoint_txid)) = &self.checkpoint_data {
767			(self.input.id(), *checkpoint_txid)
768		} else {
769			(self.input.id(), self.unsigned_arkoor_txs[0].compute_txid())
770		}
771	}
772
773	/// Builds the unsigned internal VTXOs, each paired with the txid
774	/// of the transaction that spends it.
775	pub fn build_unsigned_internal_vtxos(&self) -> Vec<(ServerVtxo<Full>, Txid)> {
776		self.build_internal_vtxos(None)
777	}
778
779	/// The returned [VtxoId] is spent out-of-round by [Txid]
780	pub fn spend_info(&self) -> Vec<(VtxoId, Txid)> {
781		let mut ret = vec![self.input_spend_info()];
782		for (vtxo, spending_txid) in self.build_unsigned_internal_vtxos() {
783			ret.push((vtxo.id(), spending_txid));
784		}
785		ret
786	}
787
788	/// Returns the txids of all virtual transactions in this arkoor:
789	/// - checkpoint tx (if checkpoints enabled)
790	/// - arkoor txs (one per normal output, exits from checkpoint)
791	/// - isolation fanout tx (if dust isolation active)
792	pub fn virtual_transactions(&self) -> Vec<Txid> {
793		let mut ret = Vec::new();
794		// Checkpoint tx
795		if let Some((_, txid)) = &self.checkpoint_data {
796			ret.push(*txid);
797		}
798		// Arkoor txs (exits for normal outputs)
799		ret.extend(self.unsigned_arkoor_txs.iter().map(|tx| tx.compute_txid()));
800		// Isolation fanout tx
801		if let Some(tx) = &self.unsigned_isolation_fanout_tx {
802			ret.push(tx.compute_txid());
803		}
804		ret
805	}
806
807	fn taptweak_at(&self, idx: usize) -> TapTweakHash {
808		if idx == 0 { self.input_tweak } else { self.checkpoint_policy_tweak }
809	}
810
811	fn user_pubkey(&self) -> PublicKey {
812		self.input.user_pubkey()
813	}
814
815	fn server_pubkey(&self) -> PublicKey {
816		self.input.server_pubkey()
817	}
818
819	/// Construct the checkpoint transaction
820	///
821	/// When dust isolation is needed, `combined_dust_amount` should be Some
822	/// with the total dust amount.
823	fn construct_unsigned_checkpoint_tx<G>(
824		input: &Vtxo<G>,
825		outputs: &[ArkoorDestination],
826		dust_isolation_amount: Option<Amount>,
827	) -> Transaction {
828
829		// All outputs on the checkpoint transaction will use exactly the same policy.
830		let output_policy = ServerVtxoPolicy::new_checkpoint(input.user_pubkey());
831		let checkpoint_spk = output_policy
832			.script_pubkey(input.server_pubkey(), input.exit_delta(), input.expiry_height());
833
834		Transaction {
835			version: bitcoin::transaction::Version(3),
836			lock_time: bitcoin::absolute::LockTime::ZERO,
837			input: vec![TxIn {
838				previous_output: input.point(),
839				script_sig: ScriptBuf::new(),
840				sequence: Sequence::ZERO,
841				witness: Witness::new(),
842			}],
843			output: outputs.iter().map(|o| {
844				TxOut {
845					value: o.total_amount,
846					script_pubkey: checkpoint_spk.clone(),
847				}
848			})
849				// add dust isolation output when required
850				.chain(dust_isolation_amount.map(|amt| {
851					TxOut {
852						value: amt,
853						script_pubkey: checkpoint_spk.clone(),
854					}
855				}))
856				.chain([fee::fee_anchor()]).collect()
857		}
858	}
859
860	fn construct_unsigned_arkoor_txs<G>(
861		input: &Vtxo<G>,
862		outputs: &[ArkoorDestination],
863		checkpoint_txid: Option<Txid>,
864		dust_isolation_amount: Option<Amount>,
865	) -> Vec<Transaction> {
866
867		if let Some(checkpoint_txid) = checkpoint_txid {
868			// Checkpoint mode: create separate arkoor tx for each output
869			let mut arkoor_txs = Vec::with_capacity(outputs.len());
870
871			for (vout, output) in outputs.iter().enumerate() {
872				let transaction = Transaction {
873					version: bitcoin::transaction::Version(3),
874					lock_time: bitcoin::absolute::LockTime::ZERO,
875					input: vec![TxIn {
876						previous_output: OutPoint::new(checkpoint_txid, vout as u32),
877						script_sig: ScriptBuf::new(),
878						sequence: Sequence::ZERO,
879						witness: Witness::new(),
880					}],
881					output: vec![
882						output.policy.txout(
883							output.total_amount,
884							input.server_pubkey(),
885							input.exit_delta(),
886							input.expiry_height(),
887						),
888						fee::fee_anchor(),
889					]
890				};
891				arkoor_txs.push(transaction);
892			}
893
894			arkoor_txs
895		} else {
896			// Direct mode: create single arkoor tx with all outputs + optional isolation output
897			let checkpoint_policy = ServerVtxoPolicy::new_checkpoint(input.user_pubkey());
898			let checkpoint_spk = checkpoint_policy.script_pubkey(
899				input.server_pubkey(),
900				input.exit_delta(),
901				input.expiry_height()
902			);
903
904			let transaction = Transaction {
905				version: bitcoin::transaction::Version(3),
906				lock_time: bitcoin::absolute::LockTime::ZERO,
907				input: vec![TxIn {
908					previous_output: input.point(),
909					script_sig: ScriptBuf::new(),
910					sequence: Sequence::ZERO,
911					witness: Witness::new(),
912				}],
913				output: outputs.iter()
914					.map(|o| o.policy.txout(
915						o.total_amount,
916						input.server_pubkey(),
917						input.exit_delta(),
918						input.expiry_height(),
919					))
920					// Add isolation output if dust is present
921					.chain(dust_isolation_amount.map(|amt| TxOut {
922						value: amt,
923						script_pubkey: checkpoint_spk.clone(),
924					}))
925					.chain([fee::fee_anchor()])
926					.collect()
927			};
928			vec![transaction]
929		}
930	}
931
932	/// Construct the dust isolation transaction that splits the combined
933	/// dust output into individual outputs
934	///
935	/// Each output uses the user's final policy directly.
936	/// Called only when dust isolation is needed.
937	///
938	/// `parent_txid` is either the checkpoint txid (checkpoint mode) or arkoor txid (direct mode)
939	fn construct_unsigned_isolation_fanout_tx<G>(
940		input: &Vtxo<G>,
941		isolated_outputs: &[ArkoorDestination],
942		parent_txid: Txid,  // Either checkpoint txid or arkoor txid
943		dust_isolation_output_vout: u32,  // Output index containing the dust isolation output
944	) -> Transaction {
945
946		Transaction {
947			version: bitcoin::transaction::Version(3),
948			lock_time: bitcoin::absolute::LockTime::ZERO,
949			input: vec![TxIn {
950				previous_output: OutPoint::new(parent_txid, dust_isolation_output_vout),
951				script_sig: ScriptBuf::new(),
952				sequence: Sequence::ZERO,
953				witness: Witness::new(),
954			}],
955			output: isolated_outputs.iter().map(|o| {
956				TxOut {
957					value: o.total_amount,
958					script_pubkey: o.policy.script_pubkey(
959						input.server_pubkey(),
960						input.exit_delta(),
961						input.expiry_height(),
962					),
963				}
964			}).chain([fee::fee_anchor()]).collect(),
965		}
966	}
967
968	fn validate_amounts<G>(
969		input: &Vtxo<G>,
970		outputs: &[ArkoorDestination],
971		isolation_outputs: &[ArkoorDestination],
972	) -> Result<(), ArkoorConstructionError> {
973
974		// Check if inputs and outputs are balanced
975		// We need to build transactions that pay exactly 0 in onchain fees
976		// to ensure our transaction with an ephemeral anchor is standard.
977		// We need `==` for standardness and we can't be lenient
978		let input_amount = input.amount();
979		let output_amount = outputs.iter().chain(isolation_outputs.iter())
980			.map(|o| o.total_amount).sum::<Amount>();
981
982		if input_amount != output_amount {
983			return Err(ArkoorConstructionError::Unbalanced {
984				input: input_amount,
985				output: output_amount,
986			})
987		}
988
989		// We need at least one output in the outputs vec
990		if outputs.is_empty() {
991			return Err(ArkoorConstructionError::NoOutputs)
992		}
993
994		// If isolation is provided, the sum must be over dust threshold
995		if !isolation_outputs.is_empty() {
996			let isolation_sum: Amount = isolation_outputs.iter()
997				.map(|o| o.total_amount).sum();
998			if isolation_sum < P2TR_DUST {
999				return Err(ArkoorConstructionError::Dust)
1000			}
1001		}
1002
1003		Ok(())
1004	}
1005
1006
1007	fn to_state<S2: state::BuilderState>(self) -> ArkoorBuilder<S2> {
1008		ArkoorBuilder {
1009			input: self.input,
1010			outputs: self.outputs,
1011			isolated_outputs: self.isolated_outputs,
1012			checkpoint_data: self.checkpoint_data,
1013			unsigned_arkoor_txs: self.unsigned_arkoor_txs,
1014			unsigned_isolation_fanout_tx: self.unsigned_isolation_fanout_tx,
1015			new_vtxo_ids: self.new_vtxo_ids,
1016			sighashes: self.sighashes,
1017			input_tweak: self.input_tweak,
1018			checkpoint_policy_tweak: self.checkpoint_policy_tweak,
1019			user_keypair: self.user_keypair,
1020			user_pub_nonces: self.user_pub_nonces,
1021			user_sec_nonces: self.user_sec_nonces,
1022			server_pub_nonces: self.server_pub_nonces,
1023			server_partial_sigs: self.server_partial_sigs,
1024			full_signatures: self.full_signatures,
1025			_state: PhantomData,
1026		}
1027	}
1028}
1029
1030impl ArkoorBuilder<state::Initial> {
1031	/// Create builder with checkpoint transaction
1032	pub fn new_with_checkpoint(
1033		input: Vtxo<Full>,
1034		outputs: Vec<ArkoorDestination>,
1035		isolated_outputs: Vec<ArkoorDestination>,
1036	) -> Result<Self, ArkoorConstructionError> {
1037		Self::new(input, outputs, isolated_outputs, true)
1038	}
1039
1040	/// Create builder without checkpoint transaction
1041	pub fn new_without_checkpoint(
1042		input: Vtxo<Full>,
1043		outputs: Vec<ArkoorDestination>,
1044		isolated_outputs: Vec<ArkoorDestination>,
1045	) -> Result<Self, ArkoorConstructionError> {
1046		Self::new(input, outputs, isolated_outputs, false)
1047	}
1048
1049	/// Create builder with checkpoint and automatic dust isolation
1050	///
1051	/// This constructor takes a single list of outputs and automatically
1052	/// determines the best strategy for handling dust.
1053	pub fn new_with_checkpoint_isolate_dust(
1054		input: Vtxo<Full>,
1055		outputs: Vec<ArkoorDestination>,
1056	) -> Result<Self, ArkoorConstructionError> {
1057		Self::new_isolate_dust(input, outputs, true)
1058	}
1059
1060	pub(crate) fn new_isolate_dust(
1061		input: Vtxo<Full>,
1062		outputs: Vec<ArkoorDestination>,
1063		use_checkpoints: bool,
1064	) -> Result<Self, ArkoorConstructionError> {
1065		// fast track if they're either all dust or all non dust
1066		if outputs.iter().all(|v| v.total_amount >= P2TR_DUST)
1067			|| outputs.iter().all(|v| v.total_amount < P2TR_DUST)
1068		{
1069			return Self::new(input, outputs, vec![], use_checkpoints);
1070		}
1071
1072		// else split them up by dust limit
1073		let (mut dust, mut non_dust) = outputs.iter().cloned()
1074			.partition::<Vec<_>, _>(|v| v.total_amount < P2TR_DUST);
1075
1076		let dust_sum = dust.iter().map(|o| o.total_amount).sum::<Amount>();
1077		if dust_sum >= P2TR_DUST {
1078			return Self::new(input, non_dust, dust, use_checkpoints);
1079		}
1080
1081		// if breaking would result in additional dust, just accept
1082		let non_dust_sum = non_dust.iter().map(|o| o.total_amount).sum::<Amount>();
1083		if non_dust_sum < P2TR_DUST * 2 {
1084			return Self::new(input, outputs, vec![], use_checkpoints);
1085		}
1086
1087		// now it get's interesting, we need to break a vtxo in two
1088		let deficit = P2TR_DUST - dust_sum;
1089		// Find first viable output to split
1090		// Viable = output.total_amount - deficit >= P2TR_DUST (won't create two dust)
1091		let split_idx = non_dust.iter()
1092			.position(|o| o.total_amount - deficit >= P2TR_DUST);
1093
1094		if let Some(idx) = split_idx {
1095			let output_to_split = non_dust[idx].clone();
1096
1097			let dust_piece = ArkoorDestination {
1098				total_amount: deficit,
1099				policy: output_to_split.policy.clone(),
1100			};
1101			let leftover = ArkoorDestination {
1102				total_amount: output_to_split.total_amount - deficit,
1103				policy: output_to_split.policy,
1104			};
1105
1106			non_dust[idx] = leftover;
1107			// we want to push it to the front
1108			dust.insert(0, dust_piece);
1109
1110			return Self::new(input, non_dust, dust, use_checkpoints);
1111		} else {
1112			// No viable split found, allow mixing without isolation
1113			let all_outputs = non_dust.into_iter().chain(dust).collect();
1114			return Self::new(input, all_outputs, vec![], use_checkpoints);
1115		}
1116	}
1117
1118	pub(crate) fn new(
1119		input: Vtxo<Full>,
1120		outputs: Vec<ArkoorDestination>,
1121		isolated_outputs: Vec<ArkoorDestination>,
1122		use_checkpoint: bool,
1123	) -> Result<Self, ArkoorConstructionError> {
1124		// Do some validation on the amounts
1125		Self::validate_amounts(&input, &outputs, &isolated_outputs)?;
1126
1127		// Compute combined dust amount if dust isolation is needed
1128		let combined_dust_amount = if !isolated_outputs.is_empty() {
1129			Some(isolated_outputs.iter().map(|o| o.total_amount).sum())
1130		} else {
1131			None
1132		};
1133
1134		// Conditionally construct checkpoint transaction
1135		let unsigned_checkpoint_tx = if use_checkpoint {
1136			let tx = Self::construct_unsigned_checkpoint_tx(
1137				&input,
1138				&outputs,
1139				combined_dust_amount,
1140			);
1141			let txid = tx.compute_txid();
1142			Some((tx, txid))
1143		} else {
1144			None
1145		};
1146
1147		// Construct arkoor transactions
1148		let unsigned_arkoor_txs = Self::construct_unsigned_arkoor_txs(
1149			&input,
1150			&outputs,
1151			unsigned_checkpoint_tx.as_ref().map(|t| t.1),
1152			combined_dust_amount,
1153		);
1154
1155		// Construct dust fanout tx if dust isolation is needed
1156		let unsigned_isolation_fanout_tx = if !isolated_outputs.is_empty() {
1157			// Combined dust isolation output is at index outputs.len()
1158			// (after all normal outputs)
1159			let dust_isolation_output_vout = outputs.len() as u32;
1160
1161			let parent_txid = if let Some((_tx, txid)) = &unsigned_checkpoint_tx {
1162				*txid
1163			} else {
1164				unsigned_arkoor_txs[0].compute_txid()
1165			};
1166
1167			Some(Self::construct_unsigned_isolation_fanout_tx(
1168				&input,
1169				&isolated_outputs,
1170				parent_txid,
1171				dust_isolation_output_vout,
1172			))
1173		} else {
1174			None
1175		};
1176
1177		// Compute all vtx-ids
1178		let new_vtxo_ids = unsigned_arkoor_txs.iter()
1179			.map(|tx| OutPoint::new(tx.compute_txid(), 0))
1180			.map(|outpoint| VtxoId::from(outpoint))
1181			.collect();
1182
1183		// Compute all sighashes
1184		let mut sighashes = Vec::new();
1185
1186		if let Some((checkpoint_tx, _txid)) = &unsigned_checkpoint_tx {
1187			// Checkpoint signature
1188			sighashes.push(arkoor_sighash(&input.txout(), checkpoint_tx));
1189
1190			// Arkoor transaction signatures (one per tx)
1191			for vout in 0..outputs.len() {
1192				let prevout = checkpoint_tx.output[vout].clone();
1193				sighashes.push(arkoor_sighash(&prevout, &unsigned_arkoor_txs[vout]));
1194			}
1195		} else {
1196			// Single direct arkoor transaction signature
1197			sighashes.push(arkoor_sighash(&input.txout(), &unsigned_arkoor_txs[0]));
1198		}
1199
1200		// Add dust sighash
1201		if let Some(ref tx) = unsigned_isolation_fanout_tx {
1202			let dust_output_vout = outputs.len();  // Same for both modes
1203			let prevout = if let Some((checkpoint_tx, _txid)) = &unsigned_checkpoint_tx {
1204				checkpoint_tx.output[dust_output_vout].clone()
1205			} else {
1206				// In direct mode, it's the isolation output from the arkoor tx
1207				unsigned_arkoor_txs[0].output[dust_output_vout].clone()
1208			};
1209			sighashes.push(arkoor_sighash(&prevout, tx));
1210		}
1211
1212		// Compute taptweaks
1213		let policy = ServerVtxoPolicy::new_checkpoint(input.user_pubkey());
1214		let input_tweak = input.output_taproot().tap_tweak();
1215		let checkpoint_policy_tweak = policy.taproot(
1216			input.server_pubkey(),
1217			input.exit_delta(),
1218			input.expiry_height(),
1219		).tap_tweak();
1220
1221		Ok(Self {
1222			input: input,
1223			outputs: outputs,
1224			isolated_outputs,
1225			sighashes: sighashes,
1226			input_tweak,
1227			checkpoint_policy_tweak,
1228			checkpoint_data: unsigned_checkpoint_tx,
1229			unsigned_arkoor_txs: unsigned_arkoor_txs,
1230			unsigned_isolation_fanout_tx,
1231			new_vtxo_ids: new_vtxo_ids,
1232			user_keypair: None,
1233			user_pub_nonces: None,
1234			user_sec_nonces: None,
1235			server_pub_nonces: None,
1236			server_partial_sigs: None,
1237			full_signatures: None,
1238			_state: PhantomData,
1239		})
1240	}
1241
1242	/// Generates the user nonces and moves the builder to the [state::UserGeneratedNonces] state
1243	/// This is the path that is used by the user
1244	pub fn generate_user_nonces(
1245		mut self,
1246		user_keypair: Keypair,
1247	) -> ArkoorBuilder<state::UserGeneratedNonces> {
1248		let mut user_pub_nonces = Vec::with_capacity(self.nb_sigs());
1249		let mut user_sec_nonces = Vec::with_capacity(self.nb_sigs());
1250
1251		for idx in 0..self.nb_sigs() {
1252			let sighash = &self.sighashes[idx].to_byte_array();
1253			let (sec_nonce, pub_nonce) = musig::nonce_pair_with_msg(&user_keypair, sighash);
1254
1255			user_pub_nonces.push(pub_nonce);
1256			user_sec_nonces.push(sec_nonce);
1257		}
1258
1259		self.user_keypair = Some(user_keypair);
1260		self.user_pub_nonces = Some(user_pub_nonces);
1261		self.user_sec_nonces = Some(user_sec_nonces);
1262
1263		self.to_state::<state::UserGeneratedNonces>()
1264	}
1265
1266	/// Sets the pub nonces that a user has generated.
1267	/// When this has happened the server can cosign.
1268	///
1269	/// If you are implementing a client, use [Self::generate_user_nonces] instead.
1270	/// If you are implementing a server you should look at
1271	/// [ArkoorBuilder::from_cosign_request].
1272	fn set_user_pub_nonces(
1273		mut self,
1274		user_pub_nonces: Vec<musig::PublicNonce>,
1275	) -> Result<ArkoorBuilder<state::ServerCanCosign>, ArkoorSigningError> {
1276		if user_pub_nonces.len() != self.nb_sigs() {
1277			return Err(ArkoorSigningError::InvalidNbUserNonces {
1278				expected: self.nb_sigs(),
1279				got: user_pub_nonces.len()
1280			})
1281		}
1282
1283		self.user_pub_nonces = Some(user_pub_nonces);
1284		Ok(self.to_state::<state::ServerCanCosign>())
1285	}
1286
1287	/// Sign as both server and user in a single step.
1288	///
1289	/// This is used when the caller controls both keypairs (e.g. the
1290	/// vtxopool spending its own VTXOs).
1291	pub fn cosign_both(
1292		mut self,
1293		user_keypair: &Keypair,
1294		server_keypair: &Keypair,
1295	) -> Result<ArkoorBuilder<state::UserSigned>, ArkoorSigningError> {
1296		if user_keypair.public_key() != self.input.user_pubkey() {
1297			return Err(ArkoorSigningError::IncorrectKey {
1298				expected: self.input.user_pubkey(),
1299				got: user_keypair.public_key(),
1300			});
1301		}
1302		if server_keypair.public_key() != self.input.server_pubkey() {
1303			return Err(ArkoorSigningError::IncorrectKey {
1304				expected: self.input.server_pubkey(),
1305				got: server_keypair.public_key(),
1306			});
1307		}
1308
1309		let mut sigs = Vec::with_capacity(self.nb_sigs());
1310		for idx in 0..self.nb_sigs() {
1311			sigs.push(musig::cosign_both(
1312				user_keypair,
1313				server_keypair,
1314				self.sighashes[idx].to_byte_array(),
1315				Some(self.taptweak_at(idx).to_byte_array()),
1316			));
1317		}
1318
1319		self.full_signatures = Some(sigs);
1320		Ok(self.to_state::<state::UserSigned>())
1321	}
1322}
1323
1324impl<'a> ArkoorBuilder<state::ServerCanCosign> {
1325	pub fn from_cosign_request(
1326		cosign_request: ArkoorCosignRequest<Vtxo<Full>>,
1327	) -> Result<ArkoorBuilder<state::ServerCanCosign>, ArkoorSigningError> {
1328		cosign_request.verify_attestation()
1329			.map_err(ArkoorSigningError::InvalidAttestation)?;
1330
1331		let ret = ArkoorBuilder::new(
1332			cosign_request.input,
1333			cosign_request.outputs,
1334			cosign_request.isolated_outputs,
1335			cosign_request.use_checkpoint,
1336		)
1337			.map_err(ArkoorSigningError::ArkoorConstructionError)?
1338			.set_user_pub_nonces(cosign_request.user_pub_nonces.clone())?;
1339		Ok(ret)
1340	}
1341
1342	pub fn server_cosign(
1343		mut self,
1344		server_keypair: &Keypair,
1345	) -> Result<ArkoorBuilder<state::ServerSigned>, ArkoorSigningError> {
1346		// Verify that the provided keypair is correct
1347		if server_keypair.public_key() != self.input.server_pubkey() {
1348			return Err(ArkoorSigningError::IncorrectKey {
1349				expected: self.input.server_pubkey(),
1350				got: server_keypair.public_key(),
1351			});
1352		}
1353
1354		let mut server_pub_nonces = Vec::with_capacity(self.outputs.len() + 1);
1355		let mut server_partial_sigs = Vec::with_capacity(self.outputs.len() + 1);
1356
1357		for idx in 0..self.nb_sigs() {
1358			let (server_pub_nonce, server_partial_sig) = musig::deterministic_partial_sign(
1359				&server_keypair,
1360				[self.input.user_pubkey()],
1361				&[&self.user_pub_nonces.as_ref().expect("state-invariant")[idx]],
1362				self.sighashes[idx].to_byte_array(),
1363				Some(self.taptweak_at(idx).to_byte_array()),
1364			);
1365
1366			server_pub_nonces.push(server_pub_nonce);
1367			server_partial_sigs.push(server_partial_sig);
1368		};
1369
1370		self.server_pub_nonces = Some(server_pub_nonces);
1371		self.server_partial_sigs = Some(server_partial_sigs);
1372		Ok(self.to_state::<state::ServerSigned>())
1373	}
1374}
1375
1376impl ArkoorBuilder<state::ServerSigned> {
1377	pub fn user_pub_nonces(&self) -> Vec<musig::PublicNonce> {
1378		self.user_pub_nonces.as_ref().expect("state invariant").clone()
1379	}
1380
1381	pub fn server_partial_signatures(&self) -> Vec<musig::PartialSignature> {
1382		self.server_partial_sigs.as_ref().expect("state invariant").clone()
1383	}
1384
1385	pub fn cosign_response(&self) -> ArkoorCosignResponse {
1386		ArkoorCosignResponse {
1387			server_pub_nonces: self.server_pub_nonces.as_ref()
1388				.expect("state invariant").clone(),
1389			server_partial_sigs: self.server_partial_sigs.as_ref()
1390				.expect("state invariant").clone(),
1391		}
1392	}
1393}
1394
1395impl ArkoorBuilder<state::UserGeneratedNonces> {
1396	pub fn user_pub_nonces(&self) -> &[PublicNonce] {
1397		self.user_pub_nonces.as_ref().expect("State invariant")
1398	}
1399
1400	pub fn cosign_request(&self) -> ArkoorCosignRequest<Vtxo<Full>> {
1401		ArkoorCosignRequest::new(
1402			self.user_pub_nonces().to_vec(),
1403			self.input.clone(),
1404			self.outputs.clone(),
1405			self.isolated_outputs.clone(),
1406			self.checkpoint_data.is_some(),
1407			self.user_keypair.as_ref().expect("State invariant"),
1408		)
1409	}
1410
1411	fn validate_server_cosign_response(
1412		&self,
1413		data: &ArkoorCosignResponse,
1414	) -> Result<(), ArkoorSigningError> {
1415
1416		// Check if the correct number of nonces is provided
1417		if data.server_pub_nonces.len() != self.nb_sigs() {
1418			return Err(ArkoorSigningError::InvalidNbServerNonces {
1419				expected: self.nb_sigs(),
1420				got: data.server_pub_nonces.len(),
1421			});
1422		}
1423
1424		if data.server_partial_sigs.len() != self.nb_sigs() {
1425			return Err(ArkoorSigningError::InvalidNbServerPartialSigs {
1426				expected: self.nb_sigs(),
1427				got: data.server_partial_sigs.len(),
1428			})
1429		}
1430
1431		// Check if the partial signatures is valid
1432		for idx in 0..self.nb_sigs() {
1433			let is_valid_sig = scripts::verify_partial_sig(
1434				self.sighashes[idx],
1435				self.taptweak_at(idx),
1436				(self.input.server_pubkey(), &data.server_pub_nonces[idx]),
1437				(self.input.user_pubkey(), &self.user_pub_nonces()[idx]),
1438				&data.server_partial_sigs[idx]
1439			);
1440
1441			if !is_valid_sig {
1442				return Err(ArkoorSigningError::InvalidPartialSignature {
1443					index: idx,
1444				});
1445			}
1446		}
1447		Ok(())
1448	}
1449
1450	pub fn user_cosign(
1451		mut self,
1452		user_keypair: &Keypair,
1453		server_cosign_data: &ArkoorCosignResponse,
1454	) -> Result<ArkoorBuilder<state::UserSigned>, ArkoorSigningError> {
1455		// Verify that the correct user keypair is provided
1456		if user_keypair.public_key() != self.input.user_pubkey() {
1457			return Err(ArkoorSigningError::IncorrectKey {
1458				expected: self.input.user_pubkey(),
1459				got: user_keypair.public_key(),
1460			});
1461		}
1462
1463		// Verify that the server cosign data is valid
1464		self.validate_server_cosign_response(&server_cosign_data)?;
1465
1466		let mut sigs = Vec::with_capacity(self.nb_sigs());
1467
1468		// Takes the secret nonces out of the [ArkoorBuilder].
1469		// Note, that we can't clone nonces so we can only sign once
1470		let user_sec_nonces = self.user_sec_nonces.take().expect("state invariant");
1471
1472		for (idx, user_sec_nonce) in user_sec_nonces.into_iter().enumerate() {
1473			let user_pub_nonce = self.user_pub_nonces()[idx];
1474			let server_pub_nonce = server_cosign_data.server_pub_nonces[idx];
1475			let agg_nonce = musig::nonce_agg(&[&user_pub_nonce, &server_pub_nonce]);
1476
1477			let (_partial, maybe_sig) = musig::partial_sign(
1478				[self.user_pubkey(), self.server_pubkey()],
1479				agg_nonce,
1480				&user_keypair,
1481				user_sec_nonce,
1482				self.sighashes[idx].to_byte_array(),
1483				Some(self.taptweak_at(idx).to_byte_array()),
1484				Some(&[&server_cosign_data.server_partial_sigs[idx]])
1485			);
1486
1487			let sig = maybe_sig.expect("The full signature exists. The server did sign first");
1488			sigs.push(sig);
1489		}
1490
1491		self.full_signatures = Some(sigs);
1492
1493		Ok(self.to_state::<state::UserSigned>())
1494	}
1495}
1496
1497
1498impl<'a> ArkoorBuilder<state::UserSigned> {
1499	pub fn build_signed_vtxos(&self) -> Vec<Vtxo<Full>> {
1500		let sigs = self.full_signatures.as_ref().expect("state invariant");
1501		let mut ret = Vec::with_capacity(self.outputs.len() + self.isolated_outputs.len());
1502
1503		if self.checkpoint_data.is_some() {
1504			let checkpoint_sig = sigs[0];
1505
1506			// Build regular vtxos (signatures 1..1+m)
1507			for i in 0..self.outputs.len() {
1508				let arkoor_sig = sigs[1 + i];
1509				ret.push(self.build_vtxo_at(i, Some(checkpoint_sig), Some(arkoor_sig)));
1510			}
1511
1512			// Build isolated vtxos if present
1513			if self.unsigned_isolation_fanout_tx.is_some() {
1514				let m = self.outputs.len();
1515				let fanout_tx_sig = sigs[1 + m];
1516
1517				for i in 0..self.isolated_outputs.len() {
1518					ret.push(self.build_isolated_vtxo_at(
1519						i,
1520						Some(checkpoint_sig),
1521						Some(fanout_tx_sig),
1522					));
1523				}
1524			}
1525		} else {
1526			// Direct mode: no checkpoint signature
1527			let arkoor_sig = sigs[0];
1528
1529			// Build regular vtxos (all use same arkoor signature)
1530			for i in 0..self.outputs.len() {
1531				ret.push(self.build_vtxo_at(i, None, Some(arkoor_sig)));
1532			}
1533
1534			// Build isolation vtxos if present
1535			if self.unsigned_isolation_fanout_tx.is_some() {
1536				let fanout_tx_sig = sigs[1];
1537
1538				for i in 0..self.isolated_outputs.len() {
1539					ret.push(self.build_isolated_vtxo_at(
1540						i,
1541						Some(arkoor_sig),  // In direct mode, first sig is arkoor, not checkpoint
1542						Some(fanout_tx_sig),
1543					));
1544				}
1545			}
1546		}
1547
1548		ret
1549	}
1550
1551	/// Returns signed copies of all intermediate transactions.
1552	///
1553	/// Same order as [virtual_transactions]: checkpoint tx (if any),
1554	/// arkoor txs, isolation fanout tx (if any).
1555	pub fn signed_virtual_transactions(&self) -> Vec<Transaction> {
1556		let sigs = self.full_signatures.as_ref().expect("state invariant");
1557		let mut ret = Vec::new();
1558		let mut sig_idx = 0;
1559		if let Some((tx, _)) = &self.checkpoint_data {
1560			let mut tx = tx.clone();
1561			tx.input[0].witness.push(&sigs[sig_idx][..]);
1562			ret.push(tx);
1563			sig_idx += 1;
1564		}
1565		for tx in &self.unsigned_arkoor_txs {
1566			let mut tx = tx.clone();
1567			tx.input[0].witness.push(&sigs[sig_idx][..]);
1568			ret.push(tx);
1569			sig_idx += 1;
1570		}
1571		if let Some(tx) = &self.unsigned_isolation_fanout_tx {
1572			let mut tx = tx.clone();
1573			tx.input[0].witness.push(&sigs[sig_idx][..]);
1574			ret.push(tx);
1575		}
1576		ret
1577	}
1578
1579	/// Builds the signed internal VTXOs (checkpoints and dust isolation),
1580	/// each paired with the txid of the transaction that spends it.
1581	pub fn build_signed_internal_vtxos(&self) -> Vec<(ServerVtxo<Full>, Txid)> {
1582		let sigs = self.full_signatures.as_ref().expect("state invariant");
1583		let intermediate_sig = if self.checkpoint_data.is_some() || !self.isolated_outputs.is_empty() {
1584			Some(sigs[0])
1585		} else {
1586			None
1587		};
1588		self.build_internal_vtxos(intermediate_sig)
1589	}
1590}
1591
1592fn arkoor_sighash(prevout: &TxOut, arkoor_tx: &Transaction) -> TapSighash {
1593	let mut shc = SighashCache::new(arkoor_tx);
1594
1595	shc.taproot_key_spend_signature_hash(
1596		0, &sighash::Prevouts::All(&[prevout]), TapSighashType::Default,
1597	).expect("sighash error")
1598}
1599
1600#[cfg(test)]
1601mod test {
1602	use super::*;
1603
1604	use std::collections::HashSet;
1605
1606	use bitcoin::Amount;
1607	use bitcoin::secp256k1::Keypair;
1608	use bitcoin::secp256k1::rand;
1609
1610	use crate::SECP;
1611	use crate::test_util::dummy::DummyTestVtxoSpec;
1612	use crate::vtxo::VtxoId;
1613
1614	/// Verify that all signed internal vtxos pass validation.
1615	fn verify_signed_internal_vtxos(
1616		builder: &ArkoorBuilder<state::UserSigned>,
1617		funding_tx: &Transaction,
1618	) {
1619		let signed = builder.build_signed_internal_vtxos();
1620		let unsigned = builder.build_unsigned_internal_vtxos();
1621		assert_eq!(signed.len(), unsigned.len());
1622
1623		for (vtxo, _spending_txid) in &signed {
1624			vtxo.validate(funding_tx).expect("signed internal vtxo must be valid");
1625		}
1626	}
1627
1628	/// Verify properties of spend_info(), build_unsigned_internal_vtxos(), and final vtxos.
1629	fn verify_builder<S: state::BuilderState>(
1630		builder: &ArkoorBuilder<S>,
1631		input: &Vtxo<Full>,
1632		outputs: &[ArkoorDestination],
1633		isolated_outputs: &[ArkoorDestination],
1634	) {
1635		let has_isolation = !isolated_outputs.is_empty();
1636
1637		let spend_info = builder.spend_info();
1638		let spend_vtxo_ids: HashSet<VtxoId> = spend_info.iter().map(|(id, _)| *id).collect();
1639
1640		// the input vtxo is the first to be spent
1641		assert_eq!(spend_info[0].0, input.id());
1642
1643		// no vtxo should be spent twice
1644		assert_eq!(spend_vtxo_ids.len(), spend_info.len());
1645
1646		// all intermediate vtxos are spent and use checkpoint policy for efficient cosigning
1647		let internal_vtxos = builder.build_unsigned_internal_vtxos();
1648		let internal_vtxo_ids = internal_vtxos.iter().map(|(v, _)| v.id()).collect::<HashSet<_>>();
1649		for (internal_vtxo, _spending_txid) in &internal_vtxos {
1650			assert!(spend_vtxo_ids.contains(&internal_vtxo.id()));
1651			assert!(matches!(internal_vtxo.policy(), ServerVtxoPolicy::Checkpoint(_)));
1652		}
1653
1654		// all spent vtxos except the input are internal vtxos
1655		for (vtxo_id, _) in &spend_info[1..] {
1656			assert!(internal_vtxo_ids.contains(vtxo_id));
1657		}
1658
1659		// isolation vtxo holds combined value of all dust outputs
1660		if has_isolation {
1661			let (isolation_vtxo, _) = internal_vtxos.last().unwrap();
1662			let expected_isolation_amount: Amount = isolated_outputs.iter()
1663				.map(|o| o.total_amount)
1664				.sum();
1665			assert_eq!(isolation_vtxo.amount(), expected_isolation_amount);
1666		}
1667
1668		// final vtxos are unspent outputs that recipients receive
1669		let final_vtxos = builder.build_unsigned_vtxos().collect::<Vec<_>>();
1670		for final_vtxo in &final_vtxos {
1671			assert!(!spend_vtxo_ids.contains(&final_vtxo.id()));
1672		}
1673
1674		// final vtxos match requested destinations
1675		let all_destinations = outputs.iter()
1676			.chain(isolated_outputs.iter())
1677			.collect::<Vec<&_>>();
1678		for (vtxo, dest) in final_vtxos.iter().zip(all_destinations.iter()) {
1679			assert_eq!(vtxo.amount(), dest.total_amount);
1680			assert_eq!(vtxo.policy, dest.policy);
1681		}
1682
1683		// total value is conserved
1684		let total_output_amount: Amount = final_vtxos.iter().map(|v| v.amount()).sum();
1685		assert_eq!(total_output_amount, input.amount());
1686	}
1687
1688	#[test]
1689	fn build_checkpointed_arkoor() {
1690		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1691		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1692		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1693
1694		println!("Alice keypair: {}", alice_keypair.public_key());
1695		println!("Bob keypair: {}", bob_keypair.public_key());
1696		println!("Server keypair: {}", server_keypair.public_key());
1697		println!("-----------------------------------------------");
1698
1699		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1700			amount: Amount::from_sat(100_330),
1701			fee: Amount::from_sat(330),
1702			expiry_height: 1000,
1703			exit_delta : 128,
1704			user_keypair: alice_keypair.clone(),
1705			server_keypair: server_keypair.clone()
1706		}.build();
1707
1708		// Validate Alice's vtxo
1709		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1710
1711		let dest = vec![
1712			ArkoorDestination {
1713				total_amount: Amount::from_sat(96_000),
1714				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1715			},
1716			ArkoorDestination {
1717				total_amount: Amount::from_sat(4_000),
1718				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1719			}
1720		];
1721
1722		let user_builder = ArkoorBuilder::new_with_checkpoint(
1723			alice_vtxo.clone(),
1724			dest.clone(),
1725			vec![], // no isolation outputs
1726		).expect("Valid arkoor request");
1727
1728		verify_builder(&user_builder, &alice_vtxo, &dest, &[]);
1729
1730		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1731		let cosign_request = user_builder.cosign_request();
1732
1733		// The server will cosign the request
1734		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1735			.expect("Invalid cosign request")
1736			.server_cosign(&server_keypair)
1737			.expect("Incorrect key");
1738
1739		let cosign_data = server_builder.cosign_response();
1740
1741		// The user will cosign the request and construct their vtxos
1742		let signed_builder = user_builder
1743			.user_cosign(&alice_keypair, &cosign_data)
1744			.expect("Valid cosign data and correct key");
1745		verify_signed_internal_vtxos(&signed_builder, &funding_tx);
1746		let vtxos = signed_builder.build_signed_vtxos();
1747
1748		for vtxo in vtxos.into_iter() {
1749			// Check if the vtxo is considered valid
1750			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1751
1752			// Check all transactions using libbitcoin-kernel
1753			let mut prev_tx = funding_tx.clone();
1754			for tx in vtxo.transactions().map(|item| item.tx) {
1755				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1756				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1757				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1758				prev_tx = tx;
1759			}
1760		}
1761
1762	}
1763
1764	#[test]
1765	fn build_checkpointed_arkoor_with_dust_isolation() {
1766		// Test mixed outputs: some dust, some non-dust
1767		// This should activate dust isolation
1768		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1769		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1770		let charlie_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1771		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1772
1773		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1774			amount: Amount::from_sat(100_330),
1775			fee: Amount::from_sat(330),
1776			expiry_height: 1000,
1777			exit_delta : 128,
1778			user_keypair: alice_keypair.clone(),
1779			server_keypair: server_keypair.clone()
1780		}.build();
1781
1782		// Validate Alice's vtxo
1783		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1784
1785		// Non-dust outputs (>= 330 sats)
1786		let outputs = vec![
1787			ArkoorDestination {
1788				total_amount: Amount::from_sat(99_600),
1789				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1790			},
1791		];
1792
1793		// dust outputs (< 330 sats each, but combined >= 330)
1794		let dust_outputs = vec![
1795			ArkoorDestination {
1796				total_amount: Amount::from_sat(200),  // < 330, truly dust
1797				policy: VtxoPolicy::new_pubkey(charlie_keypair.public_key())
1798			},
1799			ArkoorDestination {
1800				total_amount: Amount::from_sat(200),  // < 330, truly dust
1801				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1802			}
1803		];
1804
1805		let user_builder = ArkoorBuilder::new_with_checkpoint(
1806			alice_vtxo.clone(),
1807			outputs.clone(),
1808			dust_outputs.clone(),
1809		).expect("Valid arkoor request with dust isolation");
1810
1811		verify_builder(&user_builder, &alice_vtxo, &outputs, &dust_outputs);
1812
1813		// Verify dust isolation is active
1814		assert!(
1815			user_builder.unsigned_isolation_fanout_tx.is_some(),
1816			"Dust isolation should be active",
1817		);
1818
1819		// Check signature count: 1 checkpoint + 1 arkoor + 1 dust fanout = 3
1820		assert_eq!(user_builder.nb_sigs(), 3);
1821
1822		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1823		let cosign_request = user_builder.cosign_request();
1824
1825		// The server will cosign the request
1826		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1827			.expect("Invalid cosign request")
1828			.server_cosign(&server_keypair)
1829			.expect("Incorrect key");
1830
1831		let cosign_data = server_builder.cosign_response();
1832
1833		// The user will cosign the request and construct their vtxos
1834		let signed_builder = user_builder
1835			.user_cosign(&alice_keypair, &cosign_data)
1836			.expect("Valid cosign data and correct key");
1837		verify_signed_internal_vtxos(&signed_builder, &funding_tx);
1838		let vtxos = signed_builder.build_signed_vtxos();
1839
1840		// Should have 3 vtxos: 1 non-dust + 2 dust
1841		assert_eq!(vtxos.len(), 3);
1842
1843		for vtxo in vtxos.into_iter() {
1844			// Check if the vtxo is considered valid
1845			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1846
1847			// Check all transactions using libbitcoin-kernel
1848			let mut prev_tx = funding_tx.clone();
1849			for tx in vtxo.transactions().map(|item| item.tx) {
1850				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1851				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1852				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1853				prev_tx = tx;
1854			}
1855		}
1856	}
1857
1858	#[test]
1859	fn build_no_checkpoint_arkoor() {
1860		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1861		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1862		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1863
1864		println!("Alice keypair: {}", alice_keypair.public_key());
1865		println!("Bob keypair: {}", bob_keypair.public_key());
1866		println!("Server keypair: {}", server_keypair.public_key());
1867		println!("-----------------------------------------------");
1868
1869		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1870			amount: Amount::from_sat(100_330),
1871			fee: Amount::from_sat(330),
1872			expiry_height: 1000,
1873			exit_delta : 128,
1874			user_keypair: alice_keypair.clone(),
1875			server_keypair: server_keypair.clone()
1876		}.build();
1877
1878		// Validate Alice's vtxo
1879		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1880
1881		let dest = vec![
1882			ArkoorDestination {
1883				total_amount: Amount::from_sat(96_000),
1884				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1885			},
1886			ArkoorDestination {
1887				total_amount: Amount::from_sat(4_000),
1888				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1889			}
1890		];
1891
1892		let user_builder = ArkoorBuilder::new_without_checkpoint(
1893			alice_vtxo.clone(),
1894			dest.clone(),
1895			vec![], // no isolation outputs
1896		).expect("Valid arkoor request");
1897
1898		verify_builder(&user_builder, &alice_vtxo, &dest, &[]);
1899
1900		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1901		let cosign_request = user_builder.cosign_request();
1902
1903		// The server will cosign the request
1904		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1905			.expect("Invalid cosign request")
1906			.server_cosign(&server_keypair)
1907			.expect("Incorrect key");
1908
1909		let cosign_data = server_builder.cosign_response();
1910
1911		// The user will cosign the request and construct their vtxos
1912		let signed_builder = user_builder
1913			.user_cosign(&alice_keypair, &cosign_data)
1914			.expect("Valid cosign data and correct key");
1915		verify_signed_internal_vtxos(&signed_builder, &funding_tx);
1916		let vtxos = signed_builder.build_signed_vtxos();
1917
1918		for vtxo in vtxos.into_iter() {
1919			// Check if the vtxo is considered valid
1920			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1921
1922			// Check all transactions using libbitcoin-kernel
1923			let mut prev_tx = funding_tx.clone();
1924			for tx in vtxo.transactions().map(|item| item.tx) {
1925				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1926				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1927				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1928				prev_tx = tx;
1929			}
1930		}
1931
1932	}
1933
1934	#[test]
1935	fn build_no_checkpoint_arkoor_with_dust_isolation() {
1936		// Test mixed outputs: some dust, some non-dust
1937		// This should activate dust isolation
1938		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1939		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1940		let charlie_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1941		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1942
1943		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1944			amount: Amount::from_sat(100_330),
1945			fee: Amount::from_sat(330),
1946			expiry_height: 1000,
1947			exit_delta : 128,
1948			user_keypair: alice_keypair.clone(),
1949			server_keypair: server_keypair.clone()
1950		}.build();
1951
1952		// Validate Alice's vtxo
1953		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1954
1955		// Non-dust outputs (>= 330 sats)
1956		let outputs = vec![
1957			ArkoorDestination {
1958				total_amount: Amount::from_sat(99_600),
1959				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1960			},
1961		];
1962
1963		// dust outputs (< 330 sats each, but combined >= 330)
1964		let dust_outputs = vec![
1965			ArkoorDestination {
1966				total_amount: Amount::from_sat(200),  // < 330, truly dust
1967				policy: VtxoPolicy::new_pubkey(charlie_keypair.public_key())
1968			},
1969			ArkoorDestination {
1970				total_amount: Amount::from_sat(200),  // < 330, truly dust
1971				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1972			}
1973		];
1974
1975		let user_builder = ArkoorBuilder::new_without_checkpoint(
1976			alice_vtxo.clone(),
1977			outputs.clone(),
1978			dust_outputs.clone(),
1979		).expect("Valid arkoor request with dust isolation");
1980
1981		verify_builder(&user_builder, &alice_vtxo, &outputs, &dust_outputs);
1982
1983		// Verify dust isolation is active
1984		assert!(
1985			user_builder.unsigned_isolation_fanout_tx.is_some(),
1986			"Dust isolation should be active",
1987		);
1988
1989		// Check signature count: 1 arkoor + 1 dust fanout = 2
1990		// (no checkpoint in non-checkpointed mode)
1991		assert_eq!(user_builder.nb_sigs(), 2);
1992
1993		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1994		let cosign_request = user_builder.cosign_request();
1995
1996		// The server will cosign the request
1997		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
1998			.expect("Invalid cosign request")
1999			.server_cosign(&server_keypair)
2000			.expect("Incorrect key");
2001
2002		let cosign_data = server_builder.cosign_response();
2003
2004		// The user will cosign the request and construct their vtxos
2005		let signed_builder = user_builder
2006			.user_cosign(&alice_keypair, &cosign_data)
2007			.expect("Valid cosign data and correct key");
2008		verify_signed_internal_vtxos(&signed_builder, &funding_tx);
2009		let vtxos = signed_builder.build_signed_vtxos();
2010
2011		// Should have 3 vtxos: 1 non-dust + 2 dust
2012		assert_eq!(vtxos.len(), 3);
2013
2014		for vtxo in vtxos.into_iter() {
2015			// Check if the vtxo is considered valid
2016			vtxo.validate(&funding_tx).expect("Invalid VTXO");
2017
2018			// Check all transactions using libbitcoin-kernel
2019			let mut prev_tx = funding_tx.clone();
2020			for tx in vtxo.transactions().map(|item| item.tx) {
2021				let prev_outpoint: OutPoint = tx.input[0].previous_output;
2022				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
2023				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
2024				prev_tx = tx;
2025			}
2026		}
2027	}
2028
2029	#[test]
2030	fn build_checkpointed_arkoor_outputs_must_be_above_dust_if_mixed() {
2031		// Test that outputs in the outputs list must be >= P2TR_DUST
2032		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2033		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2034		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2035
2036		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2037			amount: Amount::from_sat(1_330),
2038			fee: Amount::from_sat(330),
2039			expiry_height: 1000,
2040			exit_delta : 128,
2041			user_keypair: alice_keypair.clone(),
2042			server_keypair: server_keypair.clone()
2043		}.build();
2044
2045		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
2046
2047		// only dust is allowed
2048		ArkoorBuilder::new_with_checkpoint(
2049			alice_vtxo.clone(),
2050			vec![
2051				ArkoorDestination {
2052					total_amount: Amount::from_sat(100),  // < 330 sats (P2TR_DUST)
2053					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2054				}; 10
2055			],
2056			vec![],
2057		).unwrap();
2058
2059		// empty outputs vec is not allowed (need at least one normal output)
2060		let res_empty = ArkoorBuilder::new_with_checkpoint(
2061			alice_vtxo.clone(),
2062			vec![],
2063			vec![
2064				ArkoorDestination {
2065					total_amount: Amount::from_sat(100),  // < 330 sats (P2TR_DUST)
2066					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2067				}; 10
2068			],
2069		);
2070		match res_empty {
2071			Err(ArkoorConstructionError::NoOutputs) => {},
2072			_ => panic!("Expected NoOutputs error for empty outputs"),
2073		}
2074
2075		// normal case: non-dust in normal outputs and dust in isolation
2076		ArkoorBuilder::new_with_checkpoint(
2077			alice_vtxo.clone(),
2078			vec![
2079				ArkoorDestination {
2080					total_amount: Amount::from_sat(330),  // >= 330 sats
2081					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2082				}; 2
2083			],
2084			vec![
2085				ArkoorDestination {
2086					total_amount: Amount::from_sat(170),
2087					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2088				}; 2
2089			],
2090		).unwrap();
2091
2092		// mixing with isolation sum < 330 should fail
2093		let res_mixed_small = ArkoorBuilder::new_with_checkpoint(
2094			alice_vtxo.clone(),
2095			vec![
2096				ArkoorDestination {
2097					total_amount: Amount::from_sat(500),
2098					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2099				},
2100				ArkoorDestination {
2101					total_amount: Amount::from_sat(300),
2102					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2103				}
2104			],
2105			vec![
2106				ArkoorDestination {
2107					total_amount: Amount::from_sat(100),
2108					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2109				}; 2  // sum = 200, which is < 330
2110			],
2111		);
2112		match res_mixed_small {
2113			Err(ArkoorConstructionError::Dust) => {},
2114			_ => panic!("Expected Dust error for isolation sum < 330"),
2115		}
2116	}
2117
2118	#[test]
2119	fn build_checkpointed_arkoor_dust_sum_too_small() {
2120		// Test that dust_sum < P2TR_DUST is now allowed after removing validation
2121		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2122		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2123		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2124
2125		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2126			amount: Amount::from_sat(100_330),
2127			fee: Amount::from_sat(330),
2128			expiry_height: 1000,
2129			exit_delta : 128,
2130			user_keypair: alice_keypair.clone(),
2131			server_keypair: server_keypair.clone()
2132		}.build();
2133
2134		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
2135
2136		// Non-dust outputs
2137		let outputs = vec![
2138			ArkoorDestination {
2139				total_amount: Amount::from_sat(99_900),
2140				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2141			},
2142		];
2143
2144		// dust outputs with combined sum < P2TR_DUST (330)
2145		let dust_outputs = vec![
2146			ArkoorDestination {
2147				total_amount: Amount::from_sat(50),
2148				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2149			},
2150			ArkoorDestination {
2151				total_amount: Amount::from_sat(50),
2152				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2153			}
2154		];
2155
2156		// This should fail because isolation sum (100) < P2TR_DUST (330)
2157		let result = ArkoorBuilder::new_with_checkpoint(
2158			alice_vtxo.clone(),
2159			outputs.clone(),
2160			dust_outputs.clone(),
2161		);
2162		match result {
2163			Err(ArkoorConstructionError::Dust) => {},
2164			_ => panic!("Expected Dust error for isolation sum < 330"),
2165		}
2166	}
2167
2168	#[test]
2169	fn spend_dust_vtxo() {
2170		// Test the "all dust" case: create a 200 sat vtxo and split into two 100 sat outputs
2171		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2172		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2173		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2174
2175		// Create a 200 sat input vtxo (this is dust since 200 < 330)
2176		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2177			amount: Amount::from_sat(200),
2178			fee: Amount::ZERO,
2179			expiry_height: 1000,
2180			exit_delta: 128,
2181			user_keypair: alice_keypair.clone(),
2182			server_keypair: server_keypair.clone()
2183		}.build();
2184
2185		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
2186
2187		// Split into two 100 sat outputs
2188		// outputs is empty, all outputs go to dust_outputs
2189		let dust_outputs = vec![
2190			ArkoorDestination {
2191				total_amount: Amount::from_sat(100),
2192				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2193			},
2194			ArkoorDestination {
2195				total_amount: Amount::from_sat(100),
2196				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2197			}
2198		];
2199
2200		let user_builder = ArkoorBuilder::new_with_checkpoint(
2201			alice_vtxo.clone(),
2202			dust_outputs,
2203			vec![],
2204		).expect("Valid arkoor request for all-dust case");
2205
2206		// Verify dust isolation is NOT active (all-dust case, no mixing)
2207		assert!(
2208			user_builder.unsigned_isolation_fanout_tx.is_none(),
2209			"Dust isolation should NOT be active",
2210		);
2211
2212		// Check we have 2 outputs
2213		assert_eq!(user_builder.outputs.len(), 2);
2214
2215		// Check signature count: 1 checkpoint + 2 arkoor = 3
2216		assert_eq!(user_builder.nb_sigs(), 3);
2217
2218		// The user generates their nonces
2219		let user_builder = user_builder.generate_user_nonces(alice_keypair);
2220		let cosign_request = user_builder.cosign_request();
2221
2222		// The server will cosign the request
2223		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
2224			.expect("Invalid cosign request")
2225			.server_cosign(&server_keypair)
2226			.expect("Incorrect key");
2227
2228		let cosign_data = server_builder.cosign_response();
2229
2230		// The user will cosign the request and construct their vtxos
2231		let signed_builder = user_builder
2232			.user_cosign(&alice_keypair, &cosign_data)
2233			.expect("Valid cosign data and correct key");
2234		verify_signed_internal_vtxos(&signed_builder, &funding_tx);
2235		let vtxos = signed_builder.build_signed_vtxos();
2236
2237		// Should have 2 vtxos
2238		assert_eq!(vtxos.len(), 2);
2239
2240		for vtxo in vtxos.into_iter() {
2241			// Check if the vtxo is considered valid
2242			vtxo.validate(&funding_tx).expect("Invalid VTXO");
2243
2244			// Verify amount is 100 sats
2245			assert_eq!(vtxo.amount(), Amount::from_sat(100));
2246
2247			// Check all transactions using libbitcoin-kernel
2248			let mut prev_tx = funding_tx.clone();
2249			for tx in vtxo.transactions().map(|item| item.tx) {
2250				let prev_outpoint: OutPoint = tx.input[0].previous_output;
2251				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
2252				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
2253				prev_tx = tx;
2254			}
2255		}
2256	}
2257
2258	#[test]
2259	fn spend_nondust_vtxo_to_dust() {
2260		// Test: take a 500 sat vtxo (above dust) and split into two 250 sat vtxos (below dust)
2261		// Input is non-dust, outputs are all dust - no dust isolation needed
2262		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2263		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2264		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2265
2266		// Create a 500 sat input vtxo (this is above P2TR_DUST of 330)
2267		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2268			amount: Amount::from_sat(500),
2269			fee: Amount::ZERO,
2270			expiry_height: 1000,
2271			exit_delta: 128,
2272			user_keypair: alice_keypair.clone(),
2273			server_keypair: server_keypair.clone()
2274		}.build();
2275
2276		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
2277
2278		// Split into two 250 sat outputs (each below P2TR_DUST)
2279		// outputs is empty, all outputs go to dust_outputs
2280		let dust_outputs = vec![
2281			ArkoorDestination {
2282				total_amount: Amount::from_sat(250),
2283				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2284			},
2285			ArkoorDestination {
2286				total_amount: Amount::from_sat(250),
2287				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
2288			}
2289		];
2290
2291		let user_builder = ArkoorBuilder::new_with_checkpoint(
2292			alice_vtxo.clone(),
2293			dust_outputs,
2294			vec![],
2295		).expect("Valid arkoor request for non-dust to dust case");
2296
2297		// Verify dust isolation is NOT active (all-dust case, no mixing)
2298		assert!(
2299			user_builder.unsigned_isolation_fanout_tx.is_none(),
2300			"Dust isolation should NOT be active",
2301		);
2302
2303		// Check we have 2 outputs
2304		assert_eq!(user_builder.outputs.len(), 2);
2305
2306		// Check signature count: 1 checkpoint + 2 arkoor = 3
2307		assert_eq!(user_builder.nb_sigs(), 3);
2308
2309		// The user generates their nonces
2310		let user_builder = user_builder.generate_user_nonces(alice_keypair);
2311		let cosign_request = user_builder.cosign_request();
2312
2313		// The server will cosign the request
2314		let server_builder = ArkoorBuilder::from_cosign_request(cosign_request)
2315			.expect("Invalid cosign request")
2316			.server_cosign(&server_keypair)
2317			.expect("Incorrect key");
2318
2319		let cosign_data = server_builder.cosign_response();
2320
2321		// The user will cosign the request and construct their vtxos
2322		let signed_builder = user_builder
2323			.user_cosign(&alice_keypair, &cosign_data)
2324			.expect("Valid cosign data and correct key");
2325		verify_signed_internal_vtxos(&signed_builder, &funding_tx);
2326		let vtxos = signed_builder.build_signed_vtxos();
2327
2328		// Should have 2 vtxos
2329		assert_eq!(vtxos.len(), 2);
2330
2331		for vtxo in vtxos.into_iter() {
2332			// Check if the vtxo is considered valid
2333			vtxo.validate(&funding_tx).expect("Invalid VTXO");
2334
2335			// Verify amount is 250 sats
2336			assert_eq!(vtxo.amount(), Amount::from_sat(250));
2337
2338			// Check all transactions using libbitcoin-kernel
2339			let mut prev_tx = funding_tx.clone();
2340			for tx in vtxo.transactions().map(|item| item.tx) {
2341				let prev_outpoint: OutPoint = tx.input[0].previous_output;
2342				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
2343				crate::test_util::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
2344				prev_tx = tx;
2345			}
2346		}
2347	}
2348
2349	#[test]
2350	fn isolate_dust_all_nondust() {
2351		// Test scenario: All outputs >= 330 sats
2352		// Should use normal path without isolation
2353		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2354		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2355		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2356
2357		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2358			amount: Amount::from_sat(1000),
2359			fee: Amount::ZERO,
2360			expiry_height: 1000,
2361			exit_delta: 128,
2362			user_keypair: alice_keypair.clone(),
2363			server_keypair: server_keypair.clone()
2364		}.build();
2365
2366		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2367
2368		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2369			alice_vtxo,
2370			vec![
2371				ArkoorDestination {
2372					total_amount: Amount::from_sat(500),
2373					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2374				},
2375				ArkoorDestination {
2376					total_amount: Amount::from_sat(500),
2377					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2378				}
2379			],
2380		).unwrap();
2381
2382		// Should not have dust isolation active
2383		assert!(builder.unsigned_isolation_fanout_tx.is_none());
2384
2385		// Should have 2 regular outputs
2386		assert_eq!(builder.outputs.len(), 2);
2387		assert_eq!(builder.isolated_outputs.len(), 0);
2388	}
2389
2390	#[test]
2391	fn isolate_dust_all_dust() {
2392		// Test scenario: All outputs < 330 sats
2393		// Should use all-dust path
2394		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2395		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2396		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2397
2398		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2399			amount: Amount::from_sat(400),
2400			fee: Amount::ZERO,
2401			expiry_height: 1000,
2402			exit_delta: 128,
2403			user_keypair: alice_keypair.clone(),
2404			server_keypair: server_keypair.clone()
2405		}.build();
2406
2407		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2408
2409		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2410			alice_vtxo,
2411			vec![
2412				ArkoorDestination {
2413					total_amount: Amount::from_sat(200),
2414					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2415				},
2416				ArkoorDestination {
2417					total_amount: Amount::from_sat(200),
2418					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2419				}
2420			],
2421		).unwrap();
2422
2423		// Should not have dust isolation active (all dust)
2424		assert!(builder.unsigned_isolation_fanout_tx.is_none());
2425
2426		// All outputs should be in outputs vec (no isolation needed)
2427		assert_eq!(builder.outputs.len(), 2);
2428		assert_eq!(builder.isolated_outputs.len(), 0);
2429	}
2430
2431	#[test]
2432	fn isolate_dust_sufficient_dust() {
2433		// Test scenario: Mixed with dust sum >= 330
2434		// Should use dust isolation
2435		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2436		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2437		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2438
2439		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2440			amount: Amount::from_sat(1000),
2441			fee: Amount::ZERO,
2442			expiry_height: 1000,
2443			exit_delta: 128,
2444			user_keypair: alice_keypair.clone(),
2445			server_keypair: server_keypair.clone()
2446		}.build();
2447
2448		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2449
2450		// 600 non-dust + 200 + 200 dust = 400 dust total (>= 330)
2451		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2452			alice_vtxo,
2453			vec![
2454				ArkoorDestination {
2455					total_amount: Amount::from_sat(600),
2456					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2457				},
2458				ArkoorDestination {
2459					total_amount: Amount::from_sat(200),
2460					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2461				},
2462				ArkoorDestination {
2463					total_amount: Amount::from_sat(200),
2464					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2465				}
2466			],
2467		).unwrap();
2468
2469		// Should have dust isolation active
2470		assert!(builder.unsigned_isolation_fanout_tx.is_some());
2471
2472		// 1 regular output, 2 isolated dust outputs
2473		assert_eq!(builder.outputs.len(), 1);
2474		assert_eq!(builder.isolated_outputs.len(), 2);
2475	}
2476
2477	#[test]
2478	fn isolate_dust_split_successful() {
2479		// Test scenario: Mixed with dust sum < 330, but can split
2480		// 800 non-dust + 100 + 100 dust = 200 dust, need 130 more
2481		// Should split 800 into 670 + 130
2482		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2483		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2484		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2485
2486		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2487			amount: Amount::from_sat(1000),
2488			fee: Amount::ZERO,
2489			expiry_height: 1000,
2490			exit_delta: 128,
2491			user_keypair: alice_keypair.clone(),
2492			server_keypair: server_keypair.clone()
2493		}.build();
2494
2495		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2496
2497		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2498			alice_vtxo,
2499			vec![
2500				ArkoorDestination {
2501					total_amount: Amount::from_sat(800),
2502					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2503				},
2504				ArkoorDestination {
2505					total_amount: Amount::from_sat(100),
2506					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2507				},
2508				ArkoorDestination {
2509					total_amount: Amount::from_sat(100),
2510					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2511				}
2512			],
2513		).unwrap();
2514
2515		// Should have dust isolation active (split successful)
2516		assert!(builder.unsigned_isolation_fanout_tx.is_some());
2517
2518		// 1 regular output (670), 3 isolated dust outputs (130 + 100 + 100 = 330)
2519		assert_eq!(builder.outputs.len(), 1);
2520		assert_eq!(builder.isolated_outputs.len(), 3);
2521
2522		// Verify the split amounts
2523		assert_eq!(builder.outputs[0].total_amount, Amount::from_sat(670));
2524		let isolated_sum: Amount = builder.isolated_outputs.iter().map(|o| o.total_amount).sum();
2525		assert_eq!(isolated_sum, P2TR_DUST);
2526	}
2527
2528	#[test]
2529	fn isolate_dust_split_impossible() {
2530		// Test scenario: Mixed with dust sum < 330, can't split
2531		// 400 non-dust + 100 + 100 dust = 200 dust, need 130 more
2532		// 400 - 130 = 270 < 330, can't split without creating two dust
2533		// Should allow mixing without isolation
2534		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2535		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2536		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2537
2538		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2539			amount: Amount::from_sat(600),
2540			fee: Amount::ZERO,
2541			expiry_height: 1000,
2542			exit_delta: 128,
2543			user_keypair: alice_keypair.clone(),
2544			server_keypair: server_keypair.clone()
2545		}.build();
2546
2547		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2548
2549		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2550			alice_vtxo,
2551			vec![
2552				ArkoorDestination {
2553					total_amount: Amount::from_sat(400),
2554					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2555				},
2556				ArkoorDestination {
2557					total_amount: Amount::from_sat(100),
2558					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2559				},
2560				ArkoorDestination {
2561					total_amount: Amount::from_sat(100),
2562					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2563				}
2564			],
2565		).unwrap();
2566
2567		// Should not have dust isolation (mixing allowed)
2568		assert!(builder.unsigned_isolation_fanout_tx.is_none());
2569
2570		// All 3 outputs should be in outputs vec (mixed without isolation)
2571		assert_eq!(builder.outputs.len(), 3);
2572		assert_eq!(builder.isolated_outputs.len(), 0);
2573	}
2574
2575	#[test]
2576	fn isolate_dust_exactly_boundary() {
2577		// Test scenario: dust sum is already >= 330 (exactly at boundary)
2578		// 660 non-dust + 170 + 170 dust = 340 dust (>= 330)
2579		// Should use isolation without splitting
2580		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2581		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2582		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
2583
2584		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
2585			amount: Amount::from_sat(1000),
2586			fee: Amount::ZERO,
2587			expiry_height: 1000,
2588			exit_delta: 128,
2589			user_keypair: alice_keypair.clone(),
2590			server_keypair: server_keypair.clone()
2591		}.build();
2592
2593		alice_vtxo.validate(&funding_tx).expect("Valid vtxo");
2594
2595		let builder = ArkoorBuilder::new_with_checkpoint_isolate_dust(
2596			alice_vtxo,
2597			vec![
2598				ArkoorDestination {
2599					total_amount: Amount::from_sat(660),
2600					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2601				},
2602				ArkoorDestination {
2603					total_amount: Amount::from_sat(170),
2604					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2605				},
2606				ArkoorDestination {
2607					total_amount: Amount::from_sat(170),
2608					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
2609				}
2610			],
2611		).unwrap();
2612
2613		// Should have dust isolation active (340 >= 330)
2614		assert!(builder.unsigned_isolation_fanout_tx.is_some());
2615
2616		// 1 regular output, 2 isolated dust outputs
2617		assert_eq!(builder.outputs.len(), 1);
2618		assert_eq!(builder.isolated_outputs.len(), 2);
2619
2620		// Verify amounts weren't modified
2621		assert_eq!(builder.outputs[0].total_amount, Amount::from_sat(660));
2622		assert_eq!(builder.isolated_outputs[0].total_amount, Amount::from_sat(170));
2623		assert_eq!(builder.isolated_outputs[1].total_amount, Amount::from_sat(170));
2624	}
2625}