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