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