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