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