ark/arkoor/
checkpoint.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 [CheckpointedArkoorBuilder] 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//! [CheckpointedArkoorBuilder::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 [CheckpointedArkoorBuilder::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 [CheckpointedArkoorBuilder::generate_user_nonces]
55//! which will update the builder-state to  [state::UserGeneratedNonces].
56//! The client will create a [CosignRequest] which contains the details
57//! about the arkoor payment including the user nonces. The server will
58//! respond with a [CosignResponse] which can be used to finalize all
59//! signatures. At the end the client can call [CheckpointedArkoorBuilder::build_signed_vtxos]
60//! to get their fully signed VTXOs.
61//!
62//! The server will also use [CheckpointedArkoorBuilder::from_cosign_request]
63//! to construct a builder. The [CheckpointedArkoorBuilder::server_cosign]
64//! will construct the [CosignResponse] which is sent to the client.
65//!
66
67use std::marker::PhantomData;
68
69use bitcoin::hashes::Hash;
70use bitcoin::{Amount, OutPoint, TapSighash, Transaction, Txid, TxIn, TxOut, ScriptBuf, Sequence, Witness};
71use bitcoin::taproot::TapTweakHash;
72use bitcoin::secp256k1::{schnorr, Keypair, PublicKey};
73use bitcoin_ext::{P2TR_DUST, TxOutExt, fee};
74use secp256k1_musig::musig::PublicNonce;
75
76use crate::vtxo::{GenesisItem, GenesisTransition};
77use crate::arkoor::arkoor_sighash;
78use crate::{Vtxo, VtxoId};
79use crate::VtxoRequest;
80use crate::scripts;
81use crate::musig;
82use crate::vtxo::VtxoPolicy;
83
84#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
85pub enum ArkoorConstructionError {
86	#[error("Input amount of {input} does not match output amount of {output}")]
87	Unbalanced {
88		input: Amount,
89		output: Amount,
90	},
91	#[error("An output is below the dust threshold")]
92	Dust,
93	#[error("Too many inputs provided")]
94	TooManyInputs,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
98pub enum ArkoorSigningError {
99	#[error("An error occurred while building arkoor: {0}")]
100	ArkoorConstructionError(ArkoorConstructionError),
101	#[error("Wrong number of user nonces provided. Expected {expected}, got {got}")]
102	InvalidNbUserNonces {
103		expected: usize,
104		got: usize,
105	},
106	#[error("Wrong number of server nonces provided. Expected {expected}, got {got}")]
107	InvalidNbServerNonces {
108		expected: usize,
109		got: usize,
110	},
111	#[error("Incorrect signing key provided. Expected {expected}, got {got}")]
112	IncorrectKey {
113		expected: PublicKey,
114		got: PublicKey,
115	},
116	#[error("Wrong number of server partial sigs. Expected {expected}, got {got}")]
117	InvalidNbServerPartialSigs {
118		expected: usize,
119		got: usize
120	},
121	#[error("Invalid partial signature at index {index}")]
122	InvalidPartialSignature {
123		index: usize,
124	},
125	#[error("Wrong number of packages. Expected {expected}, got {got}")]
126	InvalidNbPackages {
127		expected: usize,
128		got: usize,
129	},
130	#[error("Wrong number of keypairs. Expected {expected}, got {got}")]
131	InvalidNbKeypairs {
132		expected: usize,
133		got: usize,
134	},
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct CosignResponse {
139	pub server_pub_nonces: Vec<musig::PublicNonce>,
140	pub server_partial_sigs: Vec<musig::PartialSignature>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct CosignRequest<V> {
145	pub user_pub_nonces: Vec<musig::PublicNonce>,
146	pub input: V,
147	pub outputs: Vec<VtxoRequest>,
148	pub dust_outputs: Vec<VtxoRequest>,
149	pub use_checkpoint: bool,
150}
151
152impl<V> CosignRequest<V> {
153	pub fn new(
154		user_pub_nonces: Vec<musig::PublicNonce>,
155		input: V,
156		outputs: Vec<VtxoRequest>,
157		dust_outputs: Vec<VtxoRequest>,
158		use_checkpoint: bool,
159	) -> Self {
160		Self {
161			user_pub_nonces,
162			input,
163			outputs,
164			dust_outputs,
165			use_checkpoint,
166		}
167	}
168}
169
170impl CosignRequest<VtxoId> {
171	pub fn with_vtxo(self, vtxo: Vtxo) -> Result<CosignRequest<Vtxo>, &'static str> {
172		if self.input != vtxo.id() {
173			return Err("Input vtxo id does not match the provided vtxo id")
174		}
175
176		Ok(CosignRequest::new(
177			self.user_pub_nonces,
178			vtxo,
179			self.outputs,
180			self.dust_outputs,
181			self.use_checkpoint,
182		))
183	}
184}
185
186
187pub mod state {
188	/// There are two paths that a can be followed
189	///
190	/// 1. [Initial] -> [UserGeneratedNonces] -> [UserSigned]
191	/// 2. [Initial] -> [ServerCanCosign] -> [ServerSigned]
192	///
193	/// The first option is taken by the user and the second by the server
194
195	mod sealed {
196		pub trait Sealed {}
197		impl Sealed for super::Initial {}
198		impl Sealed for super::UserGeneratedNonces {}
199		impl Sealed for super::UserSigned {}
200		impl Sealed for super::ServerCanCosign {}
201		impl Sealed for super::ServerSigned {}
202	}
203
204	pub trait BuilderState: sealed::Sealed {}
205
206	// The initial state of the builder
207	pub struct Initial;
208	impl BuilderState for Initial {}
209
210	// The user has generated their nonces
211	pub struct UserGeneratedNonces;
212	impl BuilderState for UserGeneratedNonces {}
213
214	// The user can sign
215	pub struct UserSigned;
216	impl BuilderState for UserSigned {}
217
218	// The server can cosign
219	pub struct ServerCanCosign;
220	impl BuilderState for ServerCanCosign {}
221
222
223	/// The server has signed and knows the partial signatures
224	pub struct ServerSigned;
225	impl BuilderState for ServerSigned {}
226}
227
228pub struct CheckpointedArkoorBuilder<S: state::BuilderState> {
229	// These variables are provided by the user
230	/// The input vtxo to be spent
231	input: Vtxo,
232	/// regular output [VtxoRequest]s that the user wants to receive
233	/// if the input is dust, these can be dust
234	outputs: Vec<VtxoRequest>,
235	/// dusty [VtxoRequest]s that the user wants to receive (< P2TR_DUST)
236	dust_outputs: Vec<VtxoRequest>,
237
238	/// Data on the checkpoint tx, if checkpoints are enabled
239	///
240	/// - the unsigned checkpoint transaction
241	/// - the taptweak to sign the checkpoint tx
242	checkpoint_data: Option<(Transaction, Txid, TapTweakHash)>,
243	/// The unsigned arkoor transactions (one per non-dust output)
244	unsigned_arkoor_txs: Vec<Transaction>,
245	/// The unsigned dust fanout transaction (only when dust isolation is needed)
246	/// Splits the combined dust checkpoint output into k outputs with checkpoint policy
247	unsigned_dust_fanout_tx: Option<Transaction>,
248	/// The unsigned exit transactions (only when dust isolation is needed)
249	/// One per dust output, creates final vtxo with user's requested policy
250	unsigned_dust_exit_txs: Option<Vec<Transaction>>,
251	/// The sighashes that must be signed
252	sighashes: Vec<TapSighash>,
253	/// The taptweak to sign the arkoor tx
254	arkoor_taptweak: TapTweakHash,
255	/// The [VtxoId]s of all new [Vtxo]s that will be created
256	new_vtxo_ids: Vec<VtxoId>,
257
258	//  These variables are filled in when the state progresses
259	/// We need 1 signature for the checkpoint transaction
260	/// We need n signatures. This is one for each arkoor tx
261	/// `1+n` public nonces created by the user
262	user_pub_nonces: Option<Vec<musig::PublicNonce>>,
263	/// `1+n` secret nonces created by the user
264	user_sec_nonces: Option<Vec<musig::SecretNonce>>,
265	/// `1+n` public nonces created by the server
266	server_pub_nonces: Option<Vec<musig::PublicNonce>>,
267	/// `1+n` partial signatures created by the server
268	server_partial_sigs: Option<Vec<musig::PartialSignature>>,
269	/// `1+n` signatures that are signed by the user and server
270	full_signatures: Option<Vec<schnorr::Signature>>,
271
272	_state: PhantomData<S>,
273}
274
275impl<S: state::BuilderState> CheckpointedArkoorBuilder<S> {
276	fn checkpoint_vtxo_at(
277		&self,
278		output_idx: usize,
279		checkpoint_sig: Option<schnorr::Signature>
280	) -> Vtxo {
281		let output = &self.outputs[output_idx];
282		let (checkpoint_tx, checkpoint_txid, _tweak) = self.checkpoint_data.as_ref()
283			.expect("called checkpoint_vtxo_at in context without checkpoints");
284
285		Vtxo {
286			amount: output.amount,
287			policy: VtxoPolicy::new_checkpoint(self.input.user_pubkey()),
288			expiry_height: self.input.expiry_height,
289			server_pubkey: self.input.server_pubkey,
290			exit_delta: self.input.exit_delta,
291			point: OutPoint::new(*checkpoint_txid, output_idx as u32),
292			anchor_point: self.input.anchor_point,
293			genesis: self.input.genesis.clone().into_iter().chain([
294				GenesisItem {
295					transition: GenesisTransition::Arkoor {
296						policy: self.input.policy.clone(),
297						signature: checkpoint_sig,
298					},
299					output_idx: output_idx as u8,
300					other_outputs: checkpoint_tx.output
301						.iter().enumerate()
302						.filter_map(|(i, txout)| {
303							if i == (output_idx as usize) || txout.is_p2a_fee_anchor() {
304								None
305							} else {
306								Some(txout.clone())
307							}
308						})
309						.collect(),
310				},
311			]).collect(),
312		}
313	}
314
315	fn vtxo_at(
316		&self,
317		output_idx: usize,
318		checkpoint_sig: Option<schnorr::Signature>,
319		arkoor_sig: Option<schnorr::Signature>,
320	) -> Vtxo {
321		let output = &self.outputs[output_idx];
322
323		if let Some((checkpoint_tx, _txid, _tweak)) = &self.checkpoint_data {
324			// Two-transition genesis: Input → Checkpoint → Arkoor
325			let checkpoint_policy = VtxoPolicy::new_checkpoint(self.input.user_pubkey());
326
327			Vtxo {
328				amount: output.amount,
329				policy: output.policy.clone(),
330				expiry_height: self.input.expiry_height,
331				server_pubkey: self.input.server_pubkey,
332				exit_delta: self.input.exit_delta,
333				point: self.new_vtxo_ids[output_idx].utxo(),
334				anchor_point: self.input.anchor_point,
335				genesis: self.input.genesis.iter().cloned().chain([
336					GenesisItem {
337						transition: GenesisTransition::Arkoor {
338							policy: self.input.policy.clone(),
339							signature: checkpoint_sig,
340						},
341						output_idx: output_idx as u8,
342						other_outputs: checkpoint_tx.output
343							.iter().enumerate()
344							.filter_map(|(i, txout)| {
345								if i == (output_idx as usize) || txout.is_p2a_fee_anchor() {
346									None
347								} else {
348									Some(txout.clone())
349								}
350							})
351							.collect(),
352					},
353					GenesisItem {
354						transition: GenesisTransition::Arkoor {
355							policy: checkpoint_policy,
356							signature: arkoor_sig,
357						},
358						output_idx: 0,
359						other_outputs: vec![]
360					}
361				]).collect(),
362			}
363		} else {
364			// Single-transition genesis: Input → Arkoor
365			let arkoor_tx = &self.unsigned_arkoor_txs[0];
366
367			Vtxo {
368				amount: output.amount,
369				policy: output.policy.clone(),
370				expiry_height: self.input.expiry_height,
371				server_pubkey: self.input.server_pubkey,
372				exit_delta: self.input.exit_delta,
373				point: OutPoint::new(arkoor_tx.compute_txid(), output_idx as u32),
374				anchor_point: self.input.anchor_point,
375				genesis: self.input.genesis.iter().cloned().chain([
376					GenesisItem {
377						transition: GenesisTransition::Arkoor {
378							policy: self.input.policy.clone(),
379							signature: arkoor_sig,
380						},
381						output_idx: output_idx as u8,
382						other_outputs: arkoor_tx.output
383							.iter().enumerate()
384							.filter_map(|(idx, txout)| {
385								if idx == output_idx || txout.is_p2a_fee_anchor() {
386									None
387								} else {
388									Some(txout.clone())
389								}
390							})
391							.collect(),
392					}
393				]).collect(),
394			}
395		}
396	}
397
398	/// Build a dust vtxo at the given index (into dust_outputs)
399	///
400	/// Only used when dust isolation is active.
401	///
402	/// The `pre_fanout_tx_sig` is either
403	/// - the arkoor tx signature when no checkpoint tx is used, or
404	/// - the checkpoint tx signature when a checkpoint tx is used
405	fn construct_dust_vtxo_at(
406		&self,
407		dust_idx: usize,
408		pre_fanout_tx_sig: Option<schnorr::Signature>,
409		dust_fanout_tx_sig: Option<schnorr::Signature>,
410		exit_tx_sig: Option<schnorr::Signature>,
411	) -> Vtxo {
412		let output = &self.dust_outputs[dust_idx];
413		let checkpoint_policy = VtxoPolicy::new_checkpoint(self.input.user_pubkey());
414
415		let fanout_tx = self.unsigned_dust_fanout_tx.as_ref()
416			.expect("construct_dust_vtxo_at called without dust isolation");
417		let exit_txs = self.unsigned_dust_exit_txs.as_ref()
418			.expect("construct_dust_vtxo_at called without dust isolation");
419
420		// The combined dust output is at index outputs.len()
421		let dust_isolation_output_idx = self.outputs.len();
422
423		if let Some((checkpoint_tx, _txid, _tweak)) = &self.checkpoint_data {
424			// Three transitions: Input → Checkpoint → Dust Fanout → Exit
425			Vtxo {
426				amount: output.amount,
427				policy: output.policy.clone(),
428				expiry_height: self.input.expiry_height,
429				server_pubkey: self.input.server_pubkey,
430				exit_delta: self.input.exit_delta,
431				point: OutPoint::new(exit_txs[dust_idx].compute_txid(), 0),
432				anchor_point: self.input.anchor_point,
433				genesis: self.input.genesis.iter().cloned().chain([
434					// Transition 1: input -> checkpoint
435					GenesisItem {
436						transition: GenesisTransition::Arkoor {
437							policy: self.input.policy.clone(),
438							signature: pre_fanout_tx_sig,
439						},
440						output_idx: dust_isolation_output_idx as u8,
441						// other outputs are the non-dust outputs
442						// (we skip our combined dust output and fee anchor)
443						other_outputs: checkpoint_tx.output
444							.iter().enumerate()
445							.filter_map(|(idx, txout)| {
446								if idx == dust_isolation_output_idx || txout.is_p2a_fee_anchor() {
447									None
448								} else {
449									Some(txout.clone())
450								}
451							})
452							.collect(),
453					},
454					// Transition 2: checkpoint -> dust fanout tx
455					GenesisItem {
456						transition: GenesisTransition::Arkoor {
457							policy: checkpoint_policy.clone(),
458							signature: dust_fanout_tx_sig,
459						},
460						output_idx: dust_idx as u8,
461						// other outputs are the other dust outputs
462						// (we skip our output and fee anchor)
463						other_outputs: fanout_tx.output
464							.iter().enumerate()
465							.filter_map(|(idx, txout)| {
466								if idx == dust_idx || txout.is_p2a_fee_anchor() {
467									None
468								} else {
469									Some(txout.clone())
470								}
471							})
472							.collect(),
473					},
474					// Transition 3: dust fanout tx -> exit_tx (final vtxo)
475					GenesisItem {
476						transition: GenesisTransition::Arkoor {
477							policy: checkpoint_policy,
478							signature: exit_tx_sig,
479						},
480						output_idx: 0,
481						other_outputs: vec![]
482					}
483				]).collect(),
484			}
485		} else {
486			// Three transitions: Input → Arkoor (with isolation output) → Dust Fanout → Exit
487			let arkoor_tx = &self.unsigned_arkoor_txs[0];
488
489			Vtxo {
490				amount: output.amount,
491				policy: output.policy.clone(),
492				expiry_height: self.input.expiry_height,
493				server_pubkey: self.input.server_pubkey,
494				exit_delta: self.input.exit_delta,
495				point: OutPoint::new(exit_txs[dust_idx].compute_txid(), 0),
496				anchor_point: self.input.anchor_point,
497				genesis: self.input.genesis.iter().cloned().chain([
498					// Transition 1: input -> arkoor tx (which includes isolation output)
499					GenesisItem {
500						transition: GenesisTransition::Arkoor {
501							policy: self.input.policy.clone(),
502							signature: pre_fanout_tx_sig,  // Note: In build_signed_vtxos, this is the arkoor_sig
503						},
504						output_idx: dust_isolation_output_idx as u8,
505						other_outputs: arkoor_tx.output
506							.iter().enumerate()
507							.filter_map(|(idx, txout)| {
508								if idx == dust_isolation_output_idx || txout.is_p2a_fee_anchor() {
509									None
510								} else {
511									Some(txout.clone())
512								}
513							})
514							.collect(),
515					},
516					// Transition 2: isolation output -> dust fanout
517					GenesisItem {
518						transition: GenesisTransition::Arkoor {
519							policy: checkpoint_policy.clone(),
520							signature: dust_fanout_tx_sig,
521						},
522						output_idx: dust_idx as u8,
523						other_outputs: fanout_tx.output
524							.iter().enumerate()
525							.filter_map(|(idx, txout)| {
526								if idx == dust_idx || txout.is_p2a_fee_anchor() {
527									None
528								} else {
529									Some(txout.clone())
530								}
531							})
532							.collect(),
533					},
534					// Transition 3: dust fanout -> exit
535					GenesisItem {
536						transition: GenesisTransition::Arkoor {
537							policy: checkpoint_policy,
538							signature: exit_tx_sig,
539						},
540						output_idx: 0,
541						other_outputs: vec![]
542					}
543				]).collect(),
544			}
545		}
546	}
547
548	fn nb_sigs(&self) -> usize {
549		let base = if self.checkpoint_data.is_some() {
550			1 + self.outputs.len()  // 1 checkpoint + m arkoor txs
551		} else {
552			1  // 1 direct arkoor tx (regardless of output count)
553		};
554
555		if self.unsigned_dust_fanout_tx.is_some() {
556			base + 1 + self.dust_outputs.len()
557		} else {
558			base
559		}
560	}
561
562	//TODO(stevenroose) check if used and maybe add dust
563	fn nb_outputs(&self) -> usize {
564		self.outputs.len()
565	}
566
567	pub fn build_unsigned_vtxos<'a>(&'a self) -> impl Iterator<Item = Vtxo> + 'a {
568		(0..self.nb_outputs()).map(|i| self.vtxo_at(i, None, None))
569	}
570
571	/// Build unsigned dust vtxos (only when dust isolation is active)
572	pub fn build_unsigned_dust_vtxos<'a>(&'a self) -> impl Iterator<Item = Vtxo> + 'a {
573		(0..self.dust_outputs.len()).map(|i| self.construct_dust_vtxo_at(i, None, None, None))
574	}
575
576	pub fn build_unsigned_checkpoint_vtxos<'a>(&'a self) -> impl Iterator<Item = Vtxo> + 'a {
577		(0..self.nb_outputs()).map(|i| self.checkpoint_vtxo_at(i, None))
578	}
579
580	/// The returned [VtxoId] is spent out-of-round by [Txid]
581	pub fn spend_info(&self) -> Vec<(VtxoId, Txid)> {
582		let mut ret = Vec::with_capacity(1 + self.nb_outputs());
583
584		if let Some((_tx, checkpoint_txid, _tweak)) = &self.checkpoint_data {
585			// Input vtxo -> checkpoint tx
586			ret.push((self.input.id(), *checkpoint_txid));
587
588			// Non-dust checkpoint outputs -> arkoor txs
589			for idx in 0..self.nb_outputs() {
590				ret.push((
591					VtxoId::from(OutPoint::new(*checkpoint_txid, idx as u32)),
592					self.unsigned_arkoor_txs[idx].compute_txid()
593				));
594			}
595
596			// dust isolation paths (if active)
597			if let (Some(fanout_tx), Some(exit_txs))
598				= (&self.unsigned_dust_fanout_tx, &self.unsigned_dust_exit_txs)
599			{
600				let fanout_txid = fanout_tx.compute_txid();
601
602				// Combined dust checkpoint output -> dust fanout tx
603				let dust_output_idx = self.outputs.len() as u32;
604				ret.push((
605					VtxoId::from(OutPoint::new(*checkpoint_txid, dust_output_idx)),
606					fanout_txid
607				));
608
609				// dust fanout tx outputs -> exit_txs
610				for (idx, exit_tx) in exit_txs.iter().enumerate() {
611					ret.push((
612						VtxoId::from(OutPoint::new(fanout_txid, idx as u32)),
613						exit_tx.compute_txid()
614					));
615				}
616			}
617		} else {
618			let arkoor_txid = self.unsigned_arkoor_txs[0].compute_txid();
619
620			// Input vtxo -> arkoor tx
621			ret.push((self.input.id(), arkoor_txid));
622
623			// dust isolation paths (if active)
624			if let (Some(fanout_tx), Some(exit_txs))
625				= (&self.unsigned_dust_fanout_tx, &self.unsigned_dust_exit_txs)
626			{
627				let fanout_txid = fanout_tx.compute_txid();
628
629				// Isolation output in arkoor tx -> dust fanout
630				let dust_output_idx = self.outputs.len() as u32;
631				ret.push((
632					VtxoId::from(OutPoint::new(arkoor_txid, dust_output_idx)),
633					fanout_txid
634				));
635
636				// Dust fanout outputs -> exit txs
637				for (idx, exit_tx) in exit_txs.iter().enumerate() {
638					ret.push((
639						VtxoId::from(OutPoint::new(fanout_txid, idx as u32)),
640						exit_tx.compute_txid()
641					));
642				}
643			}
644		}
645
646		ret
647	}
648
649	/// These are the intermediate Vtxos that will be owned by the server
650	///
651	/// The tuples represent a [Vtxo] which is spent out-of-round
652	/// by [Txid]
653	pub fn checkpoint_spend_info(&self) -> Vec<(Vtxo, Txid)> {
654		if self.checkpoint_data.is_none() {
655			return vec![];
656		}
657
658		let mut result = Vec::with_capacity(self.nb_outputs());
659
660		for idx in 0..self.nb_outputs() {
661			let vtxo = self.checkpoint_vtxo_at(idx, None);
662			result.push((vtxo, self.new_vtxo_ids[idx].utxo().txid))
663		}
664
665		result
666	}
667
668	fn taptweak_at(&self, idx: usize) -> TapTweakHash {
669		if let Some((_tx, _txid, checkpoint_tweak)) = &self.checkpoint_data {
670			if idx == 0 {
671				*checkpoint_tweak
672			} else {
673				self.arkoor_taptweak
674			}
675		} else {
676			self.arkoor_taptweak
677		}
678	}
679
680	fn user_pubkey(&self) -> PublicKey {
681		self.input.user_pubkey()
682	}
683
684	fn server_pubkey(&self) -> PublicKey {
685		self.input.server_pubkey()
686	}
687
688	/// Construct the checkpoint transaction.
689	/// When dust isolation is needed, `combined_dust_amount` should be Some with the total dust amount.
690	fn construct_unsigned_checkpoint_tx(
691		input: &Vtxo,
692		outputs: &[VtxoRequest],
693		dust_isolation_amount: Option<Amount>,
694	) -> Transaction {
695		// All outputs on the checkpoint transaction will use exactly the same policy.
696		let output_policy = VtxoPolicy::new_checkpoint(input.user_pubkey());
697		let checkpoint_spk = output_policy.script_pubkey(input.server_pubkey(), input.exit_delta(), input.expiry_height());
698
699		Transaction {
700			version: bitcoin::transaction::Version(3),
701			lock_time: bitcoin::absolute::LockTime::ZERO,
702			input: vec![TxIn {
703				previous_output: input.point(),
704				script_sig: ScriptBuf::new(),
705				sequence: Sequence::ZERO,
706				witness: Witness::new(),
707			}],
708			output: outputs.iter().map(|o| {
709				TxOut {
710					value: o.amount,
711					script_pubkey: checkpoint_spk.clone(),
712				}
713			})
714				// add dust isolation output when required
715				.chain(dust_isolation_amount.map(|amt| {
716					TxOut {
717						value: amt,
718						script_pubkey: checkpoint_spk.clone(),
719					}
720				}))
721				.chain([fee::fee_anchor()]).collect()
722		}
723	}
724
725	fn construct_unsigned_arkoor_txs(
726		input: &Vtxo,
727		outputs: &[VtxoRequest],
728		checkpoint_txid: Option<Txid>,
729		dust_isolation_amount: Option<Amount>,
730	) -> Vec<Transaction> {
731		if let Some(checkpoint_txid) = checkpoint_txid {
732			// Checkpoint mode: create separate arkoor tx for each output
733			let mut arkoor_txs = Vec::with_capacity(outputs.len());
734
735			for (vout, output) in outputs.iter().enumerate() {
736				let transaction = Transaction {
737					version: bitcoin::transaction::Version(3),
738					lock_time: bitcoin::absolute::LockTime::ZERO,
739					input: vec![TxIn {
740						previous_output: OutPoint::new(checkpoint_txid, vout as u32),
741						script_sig: ScriptBuf::new(),
742						sequence: Sequence::ZERO,
743						witness: Witness::new(),
744					}],
745					output: vec![
746						output.policy.txout(output.amount, input.server_pubkey(), input.exit_delta(), input.expiry_height()),
747						fee::fee_anchor(),
748					]
749				};
750				arkoor_txs.push(transaction);
751			}
752
753			arkoor_txs
754		} else {
755			// Direct mode: create single arkoor tx with all outputs + optional isolation output
756			let checkpoint_policy = VtxoPolicy::new_checkpoint(input.user_pubkey());
757			let checkpoint_spk = checkpoint_policy.script_pubkey(
758				input.server_pubkey(),
759				input.exit_delta(),
760				input.expiry_height()
761			);
762
763			let transaction = Transaction {
764				version: bitcoin::transaction::Version(3),
765				lock_time: bitcoin::absolute::LockTime::ZERO,
766				input: vec![TxIn {
767					previous_output: input.point(),
768					script_sig: ScriptBuf::new(),
769					sequence: Sequence::ZERO,
770					witness: Witness::new(),
771				}],
772				output: outputs.iter()
773					.map(|o| o.policy.txout(o.amount, input.server_pubkey(), input.exit_delta(), input.expiry_height()))
774					// Add isolation output if dust is present
775					.chain(dust_isolation_amount.map(|amt| TxOut {
776						value: amt,
777						script_pubkey: checkpoint_spk.clone(),
778					}))
779					.chain([fee::fee_anchor()])
780					.collect()
781			};
782			vec![transaction]
783		}
784	}
785
786	/// Construct the dust isolation transaction that splits the combined
787	/// dust output into individual outputs
788	///
789	/// Each output uses checkpoint policy (not the user's final policy).
790	/// Called only when dust isolation is needed.
791	///
792	/// `parent_txid` is either the checkpoint txid (checkpoint mode) or arkoor txid (direct mode)
793	fn construct_unsigned_dust_fanout_tx(
794		input: &Vtxo,
795		dust_outputs: &[VtxoRequest],
796		parent_txid: Txid,  // Either checkpoint txid or arkoor txid
797		dust_isolation_output_vout: u32,  // Output index containing the dust isolation output
798	) -> Transaction {
799		// All outputs on the dust transaction will use exactly the same policy (checkpoint).
800		let output_policy = VtxoPolicy::new_checkpoint(input.user_pubkey());
801		let checkpoint_spk = output_policy.script_pubkey(input.server_pubkey(), input.exit_delta(), input.expiry_height());
802
803		let mut tx_outputs: Vec<TxOut> = dust_outputs.iter().map(|o| {
804			TxOut {
805				value: o.amount,
806				script_pubkey: checkpoint_spk.clone(),
807			}
808		}).collect();
809
810		// Add fee anchor
811		tx_outputs.push(fee::fee_anchor());
812
813		Transaction {
814			version: bitcoin::transaction::Version(3),
815			lock_time: bitcoin::absolute::LockTime::ZERO,
816			input: vec![TxIn {
817				previous_output: OutPoint::new(parent_txid, dust_isolation_output_vout),
818				script_sig: ScriptBuf::new(),
819				sequence: Sequence::ZERO,
820				witness: Witness::new(),
821			}],
822			output: tx_outputs,
823		}
824	}
825
826	/// Construct the exit transactions for dust isolation
827	///
828	/// Each exit tx takes one output from the dust fanout tx and creates
829	/// the final vtxo with user's policy.
830	/// Called only when dust isolation is needed.
831	fn construct_unsigned_dust_exit_txs(
832		input: &Vtxo,
833		dust_outputs: &[VtxoRequest],
834		dust_fanout_tx: &Transaction,
835	) -> Vec<Transaction> {
836		let fanout_txid = dust_fanout_tx.compute_txid();
837
838		dust_outputs.iter().enumerate().map(|(vout, output)| {
839			Transaction {
840				version: bitcoin::transaction::Version(3),
841				lock_time: bitcoin::absolute::LockTime::ZERO,
842				input: vec![TxIn {
843					previous_output: OutPoint::new(fanout_txid, vout as u32),
844					script_sig: ScriptBuf::new(),
845					sequence: Sequence::ZERO,
846					witness: Witness::new(),
847				}],
848				output: vec![
849					// Final vtxo with user's requested policy
850					output.policy.txout(
851						output.amount,
852						input.server_pubkey(),
853						input.exit_delta(),
854						input.expiry_height(),
855					),
856					fee::fee_anchor(),
857				]
858			}
859		}).collect()
860	}
861
862	fn validate_amounts(
863		input: &Vtxo,
864		outputs: &[VtxoRequest],
865		isolation_outputs: &[VtxoRequest],
866	) -> Result<(), ArkoorConstructionError> {
867		// Check if inputs and outputs are balanced
868		// We need to build transactions that pay exactly 0 in onchain fees
869		// to ensure our transaction with an ephemeral anchor is standard.
870		// We need `==` for standardness and we can't be lenient
871		let input_amount = input.amount();
872		let output_amount = outputs.iter().chain(isolation_outputs.iter())
873			.map(|o| o.amount).sum::<Amount>();
874
875		if input_amount != output_amount {
876			return Err(ArkoorConstructionError::Unbalanced {
877				input: input_amount,
878				output: output_amount,
879			})
880		}
881
882		// If dust isolation is needed (mixed outputs),
883		// - we don't allow dust outputs in the normal outputs
884		// - the combined dust must be >= P2TR_DUST
885		if !isolation_outputs.is_empty() {
886			if outputs.iter().any(|o| o.amount < P2TR_DUST) {
887				return Err(ArkoorConstructionError::Dust)
888			}
889
890			let dust_sum: Amount = isolation_outputs.iter().map(|o| o.amount).sum();
891			if dust_sum < P2TR_DUST {
892				return Err(ArkoorConstructionError::Dust)
893			}
894		} else {
895			// without isolation they have to either be all dust or all non-dust
896			let nb_dust = outputs.iter().filter(|o| o.amount < P2TR_DUST).count();
897			if !(nb_dust == 0 || nb_dust == outputs.len()) {
898				return Err(ArkoorConstructionError::Dust)
899			}
900		}
901
902		Ok(())
903	}
904
905
906	fn to_state<S2: state::BuilderState>(self) -> CheckpointedArkoorBuilder<S2> {
907		CheckpointedArkoorBuilder {
908			input: self.input,
909			outputs: self.outputs,
910			dust_outputs: self.dust_outputs,
911			checkpoint_data: self.checkpoint_data,
912			unsigned_arkoor_txs: self.unsigned_arkoor_txs,
913			unsigned_dust_fanout_tx: self.unsigned_dust_fanout_tx,
914			unsigned_dust_exit_txs: self.unsigned_dust_exit_txs,
915			new_vtxo_ids: self.new_vtxo_ids,
916			sighashes: self.sighashes,
917			arkoor_taptweak: self.arkoor_taptweak,
918			user_pub_nonces: self.user_pub_nonces,
919			user_sec_nonces: self.user_sec_nonces,
920			server_pub_nonces: self.server_pub_nonces,
921			server_partial_sigs: self.server_partial_sigs,
922			full_signatures: self.full_signatures,
923			_state: PhantomData,
924		}
925	}
926}
927
928impl CheckpointedArkoorBuilder<state::Initial> {
929	/// Create builder with checkpoint transaction
930	pub fn new_with_checkpoint(
931		input: Vtxo,
932		outputs: Vec<VtxoRequest>,
933		dust_outputs: Vec<VtxoRequest>,
934	) -> Result<Self, ArkoorConstructionError> {
935		Self::new(input, outputs, dust_outputs, true)
936	}
937
938	/// Create builder without checkpoint transaction
939	pub fn new_without_checkpoint(
940		input: Vtxo,
941		outputs: Vec<VtxoRequest>,
942		dust_outputs: Vec<VtxoRequest>,
943	) -> Result<Self, ArkoorConstructionError> {
944		Self::new(input, outputs, dust_outputs, false)
945	}
946
947	fn new(
948		input: Vtxo,
949		mut outputs: Vec<VtxoRequest>,
950		mut dust_outputs: Vec<VtxoRequest>,
951		use_checkpoint: bool,
952	) -> Result<Self, ArkoorConstructionError> {
953		// if there is only dust outputs, we just do a dust arkoor
954		if outputs.is_empty() && !dust_outputs.is_empty() {
955			std::mem::swap(&mut outputs, &mut dust_outputs);
956		}
957
958		// Do some validation on the amounts
959		Self::validate_amounts(&input, &outputs, &dust_outputs)?;
960
961		// Compute combined dust amount if dust isolation is needed
962		let combined_dust_amount = if !dust_outputs.is_empty() {
963			Some(dust_outputs.iter().map(|o| o.amount).sum())
964		} else {
965			None
966		};
967
968		// Conditionally construct checkpoint transaction
969		let unsigned_checkpoint_tx = if use_checkpoint {
970			let tx = Self::construct_unsigned_checkpoint_tx(
971				&input,
972				&outputs,
973				combined_dust_amount,
974			);
975			let txid = tx.compute_txid();
976			let taptweak = input.output_taproot().tap_tweak();
977			Some((tx, txid, taptweak))
978		} else {
979			None
980		};
981
982		// Construct arkoor transactions
983		let unsigned_arkoor_txs = Self::construct_unsigned_arkoor_txs(
984			&input,
985			&outputs,
986			unsigned_checkpoint_tx.as_ref().map(|t| t.1),
987			combined_dust_amount,
988		);
989
990		// Construct dust fanout tx and exit txs if dust isolation is needed
991		let (unsigned_dust_fanout_tx, unsigned_dust_exit_txs) = if !dust_outputs.is_empty() {
992			// Combined dust isolation output is at index outputs.len()
993			// (after all non-dust outputs)
994			let dust_isolation_output_vout = outputs.len() as u32;
995
996			let parent_txid = if let Some((_tx, txid, _tweak)) = &unsigned_checkpoint_tx {
997				*txid
998			} else {
999				unsigned_arkoor_txs[0].compute_txid()
1000			};
1001
1002			let fanout_tx = Self::construct_unsigned_dust_fanout_tx(
1003				&input,
1004				&dust_outputs,
1005				parent_txid,
1006				dust_isolation_output_vout,
1007			);
1008			let exit_txs = Self::construct_unsigned_dust_exit_txs(
1009				&input,
1010				&dust_outputs,
1011				&fanout_tx,
1012			);
1013			(Some(fanout_tx), Some(exit_txs))
1014		} else {
1015			(None, None)
1016		};
1017
1018		// Compute all vtx-ids
1019		let new_vtxo_ids = unsigned_arkoor_txs.iter()
1020			.map(|tx| OutPoint::new(tx.compute_txid(), 0))
1021			.map(|outpoint| VtxoId::from(outpoint))
1022			.collect();
1023
1024		// Compute all sighashes
1025		let mut sighashes = Vec::new();
1026
1027		if let Some((checkpoint_tx, _txid, _tweak)) = &unsigned_checkpoint_tx {
1028			// Checkpoint signature
1029			sighashes.push(arkoor_sighash(&input.txout(), checkpoint_tx));
1030
1031			// Arkoor transaction signatures (one per tx)
1032			for vout in 0..outputs.len() {
1033				let prevout = checkpoint_tx.output[vout].clone();
1034				sighashes.push(arkoor_sighash(&prevout, &unsigned_arkoor_txs[vout]));
1035			}
1036		} else {
1037			// Single direct arkoor transaction signature
1038			sighashes.push(arkoor_sighash(&input.txout(), &unsigned_arkoor_txs[0]));
1039		}
1040
1041		// Add dust sighashes
1042		if let Some(ref tx) = unsigned_dust_fanout_tx {
1043			let dust_output_vout = outputs.len();  // Same for both modes
1044			let prevout = if let Some((checkpoint_tx, _txid, _tweak)) = &unsigned_checkpoint_tx {
1045				checkpoint_tx.output[dust_output_vout].clone()
1046			} else {
1047				// In direct mode, it's the isolation output from the arkoor tx
1048				unsigned_arkoor_txs[0].output[dust_output_vout].clone()
1049			};
1050			sighashes.push(arkoor_sighash(&prevout, tx));
1051		}
1052
1053		// Add exit txs sighashes if dust isolation is needed
1054		if let (Some(fanout_tx), Some(exit_txs))
1055			= (&unsigned_dust_fanout_tx, &unsigned_dust_exit_txs)
1056		{
1057			for (vout, exit_tx) in exit_txs.iter().enumerate() {
1058				let prevout = fanout_tx.output[vout].clone();
1059				sighashes.push(arkoor_sighash(&prevout, exit_tx));
1060			}
1061		}
1062
1063		// Compute taptweaks
1064		let policy = VtxoPolicy::new_checkpoint(input.user_pubkey());
1065		let arkoor_taptweak = if use_checkpoint {
1066			policy.taproot(
1067				input.server_pubkey(),
1068				input.exit_delta(),
1069				input.expiry_height(),
1070			).tap_tweak()
1071		} else {
1072			// In direct mode, arkoor uses input's policy
1073			input.output_taproot().tap_tweak()
1074		};
1075
1076		Ok(Self {
1077			input: input,
1078			outputs: outputs,
1079			dust_outputs: dust_outputs,
1080			sighashes: sighashes,
1081			arkoor_taptweak: arkoor_taptweak,
1082			checkpoint_data: unsigned_checkpoint_tx,
1083			unsigned_arkoor_txs: unsigned_arkoor_txs,
1084			unsigned_dust_fanout_tx: unsigned_dust_fanout_tx,
1085			unsigned_dust_exit_txs: unsigned_dust_exit_txs,
1086			new_vtxo_ids: new_vtxo_ids,
1087			user_pub_nonces: None,
1088			user_sec_nonces: None,
1089			server_pub_nonces: None,
1090			server_partial_sigs: None,
1091			full_signatures: None,
1092			_state: PhantomData,
1093		})
1094	}
1095
1096	/// Generates the user nonces and moves the builder to the [state::UserGeneratedNonces] state
1097	/// This is the path that is used by the user
1098	pub fn generate_user_nonces(mut self, user_keypair: Keypair) -> CheckpointedArkoorBuilder<state::UserGeneratedNonces> {
1099		let mut user_pub_nonces = Vec::with_capacity(self.nb_sigs());
1100		let mut user_sec_nonces = Vec::with_capacity(self.nb_sigs());
1101
1102		for idx in 0..self.nb_sigs() {
1103			let sighash = &self.sighashes[idx].to_byte_array();
1104			let (sec_nonce, pub_nonce) = musig::nonce_pair_with_msg(&user_keypair, sighash);
1105
1106			user_pub_nonces.push(pub_nonce);
1107			user_sec_nonces.push(sec_nonce);
1108		}
1109
1110		self.user_pub_nonces = Some(user_pub_nonces);
1111		self.user_sec_nonces = Some(user_sec_nonces);
1112
1113		self.to_state::<state::UserGeneratedNonces>()
1114	}
1115
1116	/// Sets the pub nonces that a user has generated.
1117	/// When this has happened the server can cosign.
1118	///
1119	/// If you are implementing a client, use [Self::generate_user_nonces] instead.
1120	/// If you are implementing a server you should look at [CheckpointedArkoorBuilder::from_cosign_request]
1121	fn set_user_pub_nonces(mut self, user_pub_nonces: Vec<musig::PublicNonce>) -> Result<CheckpointedArkoorBuilder<state::ServerCanCosign>, ArkoorSigningError> {
1122		if user_pub_nonces.len() != self.nb_sigs() {
1123			return Err(ArkoorSigningError::InvalidNbUserNonces {
1124				expected: self.nb_sigs(),
1125				got: user_pub_nonces.len()
1126			})
1127		}
1128
1129		self.user_pub_nonces = Some(user_pub_nonces);
1130		Ok(self.to_state::<state::ServerCanCosign>())
1131	}
1132}
1133
1134impl<'a> CheckpointedArkoorBuilder<state::ServerCanCosign> {
1135
1136	pub fn from_cosign_request(
1137		cosign_request: CosignRequest<Vtxo>,
1138	) -> Result<CheckpointedArkoorBuilder<state::ServerCanCosign>, ArkoorSigningError> {
1139		CheckpointedArkoorBuilder::new(
1140			cosign_request.input,
1141			cosign_request.outputs,
1142			cosign_request.dust_outputs,
1143			cosign_request.use_checkpoint,
1144		)
1145			.map_err(ArkoorSigningError::ArkoorConstructionError)?
1146			.set_user_pub_nonces(cosign_request.user_pub_nonces.clone())
1147	}
1148
1149	pub fn server_cosign(mut self, server_keypair: Keypair) -> Result<CheckpointedArkoorBuilder<state::ServerSigned>, ArkoorSigningError> {
1150		// Verify that the provided keypair is correct
1151		if server_keypair.public_key() != self.input.server_pubkey() {
1152			return Err(ArkoorSigningError::IncorrectKey {
1153				expected: self.input.server_pubkey(),
1154				got: server_keypair.public_key(),
1155			});
1156		}
1157
1158		let mut server_pub_nonces = Vec::with_capacity(self.outputs.len() + 1);
1159		let mut server_partial_sigs = Vec::with_capacity(self.outputs.len() + 1);
1160
1161		for idx in 0..self.nb_sigs() {
1162			let (server_pub_nonce, server_partial_sig) = musig::deterministic_partial_sign(
1163				&server_keypair,
1164				[self.input.user_pubkey()],
1165				&[&self.user_pub_nonces.as_ref().expect("state-invariant")[idx]],
1166				self.sighashes[idx].to_byte_array(),
1167				Some(self.taptweak_at(idx).to_byte_array()),
1168			);
1169
1170			server_pub_nonces.push(server_pub_nonce);
1171			server_partial_sigs.push(server_partial_sig);
1172		};
1173
1174		self.server_pub_nonces = Some(server_pub_nonces);
1175		self.server_partial_sigs = Some(server_partial_sigs);
1176		Ok(self.to_state::<state::ServerSigned>())
1177	}
1178
1179}
1180
1181impl CheckpointedArkoorBuilder<state::ServerSigned> {
1182
1183	pub fn user_pub_nonces(&self) -> Vec<musig::PublicNonce> {
1184		self.user_pub_nonces.as_ref().expect("state invariant").clone()
1185	}
1186
1187	pub fn server_partial_signatures(&self) -> Vec<musig::PartialSignature> {
1188		self.server_partial_sigs.as_ref().expect("state invariant").clone()
1189	}
1190
1191	pub fn cosign_response(&self) -> CosignResponse {
1192		CosignResponse {
1193			server_pub_nonces: self.server_pub_nonces.as_ref().expect("state invariant").clone(),
1194			server_partial_sigs: self.server_partial_sigs.as_ref().expect("state invariant").clone(),
1195		}
1196	}
1197}
1198
1199impl CheckpointedArkoorBuilder<state::UserGeneratedNonces> {
1200
1201	pub fn user_pub_nonces(&self) -> &[PublicNonce] {
1202		self.user_pub_nonces.as_ref().expect("State invariant")
1203	}
1204
1205	pub fn cosign_request(&self) -> CosignRequest<Vtxo> {
1206		CosignRequest {
1207			user_pub_nonces: self.user_pub_nonces().to_vec(),
1208			input: self.input.clone(),
1209			outputs: self.outputs.clone(),
1210			dust_outputs: self.dust_outputs.clone(),
1211			use_checkpoint: self.checkpoint_data.is_some(),
1212		}
1213	}
1214
1215	fn validate_server_cosign_response(
1216		&self,
1217		data: &CosignResponse,
1218	) -> Result<(), ArkoorSigningError> {
1219
1220		// Check if the correct number of nonces is provided
1221		if data.server_pub_nonces.len() != self.nb_sigs() {
1222			return Err(ArkoorSigningError::InvalidNbServerNonces {
1223				expected: self.nb_sigs(),
1224				got: data.server_pub_nonces.len(),
1225			});
1226		}
1227
1228		if data.server_partial_sigs.len() != self.nb_sigs() {
1229			return Err(ArkoorSigningError::InvalidNbServerPartialSigs {
1230				expected: self.nb_sigs(),
1231				got: data.server_partial_sigs.len(),
1232			})
1233		}
1234
1235		// Check if the partial signatures is valid
1236		for idx in 0..self.nb_sigs() {
1237			let is_valid_sig = scripts::verify_partial_sig(
1238				self.sighashes[idx],
1239				self.taptweak_at(idx),
1240				(self.input.server_pubkey(), &data.server_pub_nonces[idx]),
1241				(self.input.user_pubkey(), &self.user_pub_nonces()[idx]),
1242				&data.server_partial_sigs[idx]
1243			);
1244
1245			if !is_valid_sig {
1246				return Err(ArkoorSigningError::InvalidPartialSignature {
1247					index: idx,
1248				});
1249			}
1250		}
1251		Ok(())
1252	}
1253
1254	pub fn user_cosign(
1255		mut self,
1256		user_keypair: &Keypair,
1257		server_cosign_data: &CosignResponse,
1258	) -> Result<CheckpointedArkoorBuilder<state::UserSigned>, ArkoorSigningError> {
1259		// Verify that the correct user keypair is provided
1260		if user_keypair.public_key() != self.input.user_pubkey() {
1261			return Err(ArkoorSigningError::IncorrectKey {
1262				expected: self.input.user_pubkey(),
1263				got: user_keypair.public_key(),
1264			});
1265		}
1266
1267		// Verify that the server cosign data is valid
1268		self.validate_server_cosign_response(&server_cosign_data)?;
1269
1270		let mut sigs = Vec::with_capacity(self.nb_sigs());
1271
1272		// Takes the secret nonces out of the [CheckpointedArkoorBuilder].
1273		// Note, that we can't clone nonces so we can only sign once
1274		let user_sec_nonces = self.user_sec_nonces.take().expect("state invariant");
1275
1276		for (idx, user_sec_nonce) in user_sec_nonces.into_iter().enumerate() {
1277			let user_pub_nonce = self.user_pub_nonces()[idx];
1278			let server_pub_nonce = server_cosign_data.server_pub_nonces[idx];
1279			let agg_nonce = musig::nonce_agg(&[&user_pub_nonce, &server_pub_nonce]);
1280
1281			let (_partial, maybe_sig) = musig::partial_sign(
1282				[self.user_pubkey(), self.server_pubkey()],
1283				agg_nonce,
1284				&user_keypair,
1285				user_sec_nonce,
1286				self.sighashes[idx].to_byte_array(),
1287				Some(self.taptweak_at(idx).to_byte_array()),
1288				Some(&[&server_cosign_data.server_partial_sigs[idx]])
1289			);
1290
1291			let sig = maybe_sig.expect("The full signature exists. The server did sign first");
1292			sigs.push(sig);
1293		}
1294
1295
1296		self.full_signatures = Some(sigs);
1297
1298		Ok(self.to_state::<state::UserSigned>())
1299	}
1300}
1301
1302
1303impl<'a> CheckpointedArkoorBuilder<state::UserSigned> {
1304
1305	pub fn build_signed_vtxos(&self) -> Vec<Vtxo> {
1306		let sigs = self.full_signatures.as_ref().expect("state invariant");
1307		let mut ret = Vec::with_capacity(self.outputs.len() + self.dust_outputs.len());
1308
1309		if self.checkpoint_data.is_some() {
1310			let checkpoint_sig = sigs[0];
1311
1312			// Build regular vtxos (signatures 1..1+m)
1313			for i in 0..self.outputs.len() {
1314				let arkoor_sig = sigs[1 + i];
1315				ret.push(self.vtxo_at(i, Some(checkpoint_sig), Some(arkoor_sig)));
1316			}
1317
1318			// Build dust vtxos if present
1319			if self.unsigned_dust_fanout_tx.is_some() {
1320				let m = self.outputs.len();
1321				let dust_fanout_tx_sig = sigs[1 + m];
1322
1323				for i in 0..self.dust_outputs.len() {
1324					let exit_tx_sig = sigs[2 + m + i];
1325					ret.push(self.construct_dust_vtxo_at(
1326						i,
1327						Some(checkpoint_sig),
1328						Some(dust_fanout_tx_sig),
1329						Some(exit_tx_sig),
1330					));
1331				}
1332			}
1333		} else {
1334			// Direct mode: no checkpoint signature
1335			let arkoor_sig = sigs[0];
1336
1337			// Build regular vtxos (all use same arkoor signature)
1338			for i in 0..self.outputs.len() {
1339				ret.push(self.vtxo_at(i, None, Some(arkoor_sig)));
1340			}
1341
1342			// Build dust vtxos if present
1343			if self.unsigned_dust_fanout_tx.is_some() {
1344				let dust_fanout_tx_sig = sigs[1];
1345
1346				for i in 0..self.dust_outputs.len() {
1347					let exit_tx_sig = sigs[2 + i];
1348					ret.push(self.construct_dust_vtxo_at(
1349						i,
1350						Some(arkoor_sig),  // In direct mode, first sig is arkoor, not checkpoint
1351						Some(dust_fanout_tx_sig),
1352						Some(exit_tx_sig),
1353					));
1354				}
1355			}
1356		}
1357
1358		ret
1359	}
1360}
1361
1362
1363#[cfg(test)]
1364mod test {
1365	use super::*;
1366
1367	use bitcoin::Amount;
1368	use bitcoin::secp256k1::Keypair;
1369	use bitcoin::secp256k1::rand;
1370
1371	use crate::SECP;
1372	use crate::VtxoRequest;
1373	use crate::test::dummy::DummyTestVtxoSpec;
1374
1375
1376	#[test]
1377	fn build_checkpointed_arkoor() {
1378		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1379		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1380		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1381
1382		println!("Alice keypair: {}", alice_keypair.public_key());
1383		println!("Bob keypair: {}", bob_keypair.public_key());
1384		println!("Server keypair: {}", server_keypair.public_key());
1385		println!("-----------------------------------------------");
1386
1387		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1388			amount: Amount::from_sat(100_000),
1389			expiry_height: 1000,
1390			exit_delta : 128,
1391			user_keypair: alice_keypair.clone(),
1392			server_keypair: server_keypair.clone()
1393		}.build();
1394
1395		// Validate Alice her vtxo
1396		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1397
1398		let vtxo_request = vec![
1399			VtxoRequest {
1400				amount: Amount::from_sat(96_000),
1401				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1402			},
1403			VtxoRequest {
1404				amount: Amount::from_sat(4_000),
1405				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1406			}
1407		];
1408
1409		// The user generates their nonces
1410		let user_builder = CheckpointedArkoorBuilder::new_with_checkpoint(
1411			alice_vtxo.clone(),
1412			vtxo_request.clone(),
1413			vec![], // no dust outputs
1414		).expect("Valid arkoor request");
1415
1416		// At this point all out-of-round transactions are fully defined.
1417		// They are just missing the required signatures.
1418		// We are already able to compute the vtxos and validate them
1419		let _unsigned_vtxos = user_builder.build_unsigned_vtxos().collect::<Vec<_>>();
1420
1421
1422		// The user generates their nonces
1423		let user_builder =user_builder.generate_user_nonces(alice_keypair);
1424		let cosign_request = user_builder.cosign_request();
1425
1426		// The server will cosign the request
1427		let server_builder = CheckpointedArkoorBuilder::from_cosign_request(cosign_request).expect("Invalid cosign request")
1428			.server_cosign(server_keypair).expect("Incorrect key");
1429
1430		let cosign_data = server_builder.cosign_response();
1431
1432		// The user will cosign the request and construct their vtxos
1433		let vtxos = user_builder
1434			.user_cosign(&alice_keypair, &cosign_data)
1435			.expect("Valid cosign data and correct key")
1436			.build_signed_vtxos();
1437
1438		for vtxo in vtxos.into_iter() {
1439			// Check if the vtxo is considered valid
1440			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1441
1442			// Check all transactions using libbitcoin-kernel
1443			let mut prev_tx = funding_tx.clone();
1444			for tx in vtxo.transactions().map(|item| item.tx) {
1445				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1446				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1447				crate::test::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1448				prev_tx = tx;
1449			}
1450		}
1451
1452	}
1453
1454	#[test]
1455	fn build_checkpointed_arkoor_with_dust_isolation() {
1456		// Test mixed outputs: some dust, some non-dust
1457		// This should activate dust isolation
1458		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1459		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1460		let charlie_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1461		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1462
1463		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1464			amount: Amount::from_sat(100_000),
1465			expiry_height: 1000,
1466			exit_delta : 128,
1467			user_keypair: alice_keypair.clone(),
1468			server_keypair: server_keypair.clone()
1469		}.build();
1470
1471		// Validate Alice her vtxo
1472		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1473
1474		// Non-dust outputs (>= 330 sats)
1475		let outputs = vec![
1476			VtxoRequest {
1477				amount: Amount::from_sat(99_600),
1478				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1479			},
1480		];
1481
1482		// dust outputs (< 330 sats each, but combined >= 330)
1483		let dust_outputs = vec![
1484			VtxoRequest {
1485				amount: Amount::from_sat(200),  // < 330, truly dust
1486				policy: VtxoPolicy::new_pubkey(charlie_keypair.public_key())
1487			},
1488			VtxoRequest {
1489				amount: Amount::from_sat(200),  // < 330, truly dust
1490				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1491			}
1492		];
1493
1494		// The user generates their nonces
1495		let user_builder = CheckpointedArkoorBuilder::new_with_checkpoint(
1496			alice_vtxo.clone(),
1497			outputs.clone(),
1498			dust_outputs.clone(),
1499		).expect("Valid arkoor request with dust isolation");
1500
1501		// Verify dust isolation is active
1502		assert!(user_builder.unsigned_dust_fanout_tx.is_some(), "Dust isolation should be active");
1503		assert!(user_builder.unsigned_dust_exit_txs.is_some(), "Dust exit txs should be present");
1504		assert_eq!(user_builder.unsigned_dust_exit_txs.as_ref().unwrap().len(), 2);
1505
1506		// Check signature count: 1 checkpoint + 1 arkoor + 1 dust fanout + 2 exits = 5
1507		assert_eq!(user_builder.nb_sigs(), 5);
1508
1509		// The user generates their nonces
1510		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1511		let cosign_request = user_builder.cosign_request();
1512
1513		// The server will cosign the request
1514		let server_builder = CheckpointedArkoorBuilder::from_cosign_request(cosign_request).expect("Invalid cosign request")
1515			.server_cosign(server_keypair).expect("Incorrect key");
1516
1517		let cosign_data = server_builder.cosign_response();
1518
1519		// The user will cosign the request and construct their vtxos
1520		let vtxos = user_builder
1521			.user_cosign(&alice_keypair, &cosign_data)
1522			.expect("Valid cosign data and correct key")
1523			.build_signed_vtxos();
1524
1525		// Should have 3 vtxos: 1 non-dust + 2 dust
1526		assert_eq!(vtxos.len(), 3);
1527
1528		for vtxo in vtxos.into_iter() {
1529			// Check if the vtxo is considered valid
1530			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1531
1532			// Check all transactions using libbitcoin-kernel
1533			let mut prev_tx = funding_tx.clone();
1534			for tx in vtxo.transactions().map(|item| item.tx) {
1535				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1536				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1537				crate::test::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1538				prev_tx = tx;
1539			}
1540		}
1541	}
1542
1543	#[test]
1544	fn build_checkpointed_arkoor_outputs_must_be_above_dust_if_mixed() {
1545		// Test that outputs in the outputs list must be >= P2TR_DUST
1546		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1547		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1548		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1549
1550		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1551			amount: Amount::from_sat(1000),
1552			expiry_height: 1000,
1553			exit_delta : 128,
1554			user_keypair: alice_keypair.clone(),
1555			server_keypair: server_keypair.clone()
1556		}.build();
1557
1558		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1559
1560		// only dust is allowed
1561		CheckpointedArkoorBuilder::new_with_checkpoint(
1562			alice_vtxo.clone(),
1563			vec![
1564				VtxoRequest {
1565					amount: Amount::from_sat(100),  // < 330 sats (P2TR_DUST)
1566					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1567				}; 10
1568			],
1569			vec![],
1570		).unwrap();
1571
1572		// only dust in isolation is also allowed
1573		CheckpointedArkoorBuilder::new_with_checkpoint(
1574			alice_vtxo.clone(),
1575			vec![],
1576			vec![
1577				VtxoRequest {
1578					amount: Amount::from_sat(100),  // < 330 sats (P2TR_DUST)
1579					policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1580				}; 10
1581			],
1582		).unwrap();
1583
1584		// normal case: non-dust in normal outputs and dust in isolation
1585		CheckpointedArkoorBuilder::new_with_checkpoint(
1586			alice_vtxo.clone(),
1587			vec![
1588				VtxoRequest {
1589					amount: Amount::from_sat(330),  // >= 330 sats
1590					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1591				}; 2
1592			],
1593			vec![
1594				VtxoRequest {
1595					amount: Amount::from_sat(170),
1596					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1597				}; 2
1598			],
1599		).unwrap();
1600
1601		// can't mix dust and non-dust in normal outputs
1602		let res_mixed = CheckpointedArkoorBuilder::new_with_checkpoint(
1603			alice_vtxo.clone(),
1604			vec![
1605				VtxoRequest {
1606					amount: Amount::from_sat(500),
1607					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1608				},
1609				VtxoRequest {
1610					amount: Amount::from_sat(300),
1611					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1612				}
1613			],
1614			vec![
1615				VtxoRequest {
1616					amount: Amount::from_sat(100),
1617					policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1618				}; 2
1619			],
1620		);
1621		match res_mixed {
1622			Err(ArkoorConstructionError::Dust) => {},
1623			_ => panic!("Expected Dust error"),
1624		}
1625	}
1626
1627	#[test]
1628	fn build_checkpointed_arkoor_dust_sum_too_small() {
1629		// Test mixed with dust_sum < P2TR_DUST → error
1630		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1631		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1632		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1633
1634		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1635			amount: Amount::from_sat(100_000),
1636			expiry_height: 1000,
1637			exit_delta : 128,
1638			user_keypair: alice_keypair.clone(),
1639			server_keypair: server_keypair.clone()
1640		}.build();
1641
1642		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1643
1644		// Non-dust outputs
1645		let outputs = vec![
1646			VtxoRequest {
1647				amount: Amount::from_sat(99_900),
1648				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1649			},
1650		];
1651
1652		// dust outputs with combined sum < P2TR_DUST (330)
1653		let dust_outputs = vec![
1654			VtxoRequest {
1655				amount: Amount::from_sat(50),
1656				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1657			},
1658			VtxoRequest {
1659				amount: Amount::from_sat(50),
1660				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1661			}
1662		];
1663
1664		// This should fail because combined dust < P2TR_DUST
1665		let result = CheckpointedArkoorBuilder::new_with_checkpoint(
1666			alice_vtxo.clone(),
1667			outputs.clone(),
1668			dust_outputs.clone(),
1669		);
1670
1671		match result {
1672			Err(ArkoorConstructionError::Dust) => {},
1673			_ => panic!("Expected Dust error"),
1674		}
1675	}
1676
1677	#[test]
1678	fn spend_dust_vtxo() {
1679		// Test the "all dust" case: create a 200 sat vtxo and split into two 100 sat outputs
1680		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1681		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1682		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1683
1684		// Create a 200 sat input vtxo (this is dust since 200 < 330)
1685		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1686			amount: Amount::from_sat(200),
1687			expiry_height: 1000,
1688			exit_delta: 128,
1689			user_keypair: alice_keypair.clone(),
1690			server_keypair: server_keypair.clone()
1691		}.build();
1692
1693		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1694
1695		// Split into two 100 sat outputs
1696		// outputs is empty, all outputs go to dust_outputs
1697		let outputs = vec![];
1698		let dust_outputs = vec![
1699			VtxoRequest {
1700				amount: Amount::from_sat(100),
1701				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1702			},
1703			VtxoRequest {
1704				amount: Amount::from_sat(100),
1705				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1706			}
1707		];
1708
1709		let user_builder = CheckpointedArkoorBuilder::new_with_checkpoint(
1710			alice_vtxo.clone(),
1711			outputs,
1712			dust_outputs,
1713		).expect("Valid arkoor request for all-dust case");
1714
1715		// Verify dust isolation is NOT active (all-dust case, no mixing)
1716		assert!(user_builder.unsigned_dust_fanout_tx.is_none(), "Dust isolation should NOT be active");
1717
1718		// Check we have 2 outputs
1719		assert_eq!(user_builder.nb_outputs(), 2);
1720
1721		// Check signature count: 1 checkpoint + 2 arkoor = 3
1722		assert_eq!(user_builder.nb_sigs(), 3);
1723
1724		// The user generates their nonces
1725		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1726		let cosign_request = user_builder.cosign_request();
1727
1728		// The server will cosign the request
1729		let server_builder = CheckpointedArkoorBuilder::from_cosign_request(cosign_request)
1730			.expect("Invalid cosign request")
1731			.server_cosign(server_keypair)
1732			.expect("Incorrect key");
1733
1734		let cosign_data = server_builder.cosign_response();
1735
1736		// The user will cosign the request and construct their vtxos
1737		let vtxos = user_builder
1738			.user_cosign(&alice_keypair, &cosign_data)
1739			.expect("Valid cosign data and correct key")
1740			.build_signed_vtxos();
1741
1742		// Should have 2 vtxos
1743		assert_eq!(vtxos.len(), 2);
1744
1745		for vtxo in vtxos.into_iter() {
1746			// Check if the vtxo is considered valid
1747			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1748
1749			// Verify amount is 100 sats
1750			assert_eq!(vtxo.amount(), Amount::from_sat(100));
1751
1752			// Check all transactions using libbitcoin-kernel
1753			let mut prev_tx = funding_tx.clone();
1754			for tx in vtxo.transactions().map(|item| item.tx) {
1755				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1756				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1757				crate::test::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1758				prev_tx = tx;
1759			}
1760		}
1761	}
1762
1763	#[test]
1764	fn spend_nondust_vtxo_to_dust() {
1765		// Test: take a 500 sat vtxo (above dust) and split into two 250 sat vtxos (below dust)
1766		// Input is non-dust, outputs are all dust - no dust isolation needed
1767		let alice_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1768		let bob_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1769		let server_keypair = Keypair::new(&SECP, &mut rand::thread_rng());
1770
1771		// Create a 500 sat input vtxo (this is above P2TR_DUST of 330)
1772		let (funding_tx, alice_vtxo) = DummyTestVtxoSpec {
1773			amount: Amount::from_sat(500),
1774			expiry_height: 1000,
1775			exit_delta: 128,
1776			user_keypair: alice_keypair.clone(),
1777			server_keypair: server_keypair.clone()
1778		}.build();
1779
1780		alice_vtxo.validate(&funding_tx).expect("The unsigned vtxo is valid");
1781
1782		// Split into two 250 sat outputs (each below P2TR_DUST)
1783		// outputs is empty, all outputs go to dust_outputs
1784		let outputs = vec![];
1785		let dust_outputs = vec![
1786			VtxoRequest {
1787				amount: Amount::from_sat(250),
1788				policy: VtxoPolicy::new_pubkey(bob_keypair.public_key())
1789			},
1790			VtxoRequest {
1791				amount: Amount::from_sat(250),
1792				policy: VtxoPolicy::new_pubkey(alice_keypair.public_key())
1793			}
1794		];
1795
1796		let user_builder = CheckpointedArkoorBuilder::new_with_checkpoint(
1797			alice_vtxo.clone(),
1798			outputs,
1799			dust_outputs,
1800		).expect("Valid arkoor request for non-dust to dust case");
1801
1802		// Verify dust isolation is NOT active (all-dust case, no mixing)
1803		assert!(user_builder.unsigned_dust_fanout_tx.is_none(), "Dust isolation should NOT be active");
1804
1805		// Check we have 2 outputs
1806		assert_eq!(user_builder.nb_outputs(), 2);
1807
1808		// Check signature count: 1 checkpoint + 2 arkoor = 3
1809		assert_eq!(user_builder.nb_sigs(), 3);
1810
1811		// The user generates their nonces
1812		let user_builder = user_builder.generate_user_nonces(alice_keypair);
1813		let cosign_request = user_builder.cosign_request();
1814
1815		// The server will cosign the request
1816		let server_builder = CheckpointedArkoorBuilder::from_cosign_request(cosign_request)
1817			.expect("Invalid cosign request")
1818			.server_cosign(server_keypair)
1819			.expect("Incorrect key");
1820
1821		let cosign_data = server_builder.cosign_response();
1822
1823		// The user will cosign the request and construct their vtxos
1824		let vtxos = user_builder
1825			.user_cosign(&alice_keypair, &cosign_data)
1826			.expect("Valid cosign data and correct key")
1827			.build_signed_vtxos();
1828
1829		// Should have 2 vtxos
1830		assert_eq!(vtxos.len(), 2);
1831
1832		for vtxo in vtxos.into_iter() {
1833			// Check if the vtxo is considered valid
1834			vtxo.validate(&funding_tx).expect("Invalid VTXO");
1835
1836			// Verify amount is 250 sats
1837			assert_eq!(vtxo.amount(), Amount::from_sat(250));
1838
1839			// Check all transactions using libbitcoin-kernel
1840			let mut prev_tx = funding_tx.clone();
1841			for tx in vtxo.transactions().map(|item| item.tx) {
1842				let prev_outpoint: OutPoint = tx.input[0].previous_output;
1843				let prev_txout: TxOut = prev_tx.output[prev_outpoint.vout as usize].clone();
1844				crate::test::verify_tx(&[prev_txout], 0, &tx).expect("Valid transaction");
1845				prev_tx = tx;
1846			}
1847		}
1848	}
1849}