Skip to main content

ark/
board.rs

1//!
2//! Board flow:
3//!
4//! * user creates a builder using [BoardBuilder::new]
5//! * user creates the funding tx which pays to [BoardBuilder::funding_script_pubkey]
6//! * user sets the funding output in [BoardBuilder::set_funding_details]
7//! * user generates signing nonces using [BoardBuilder::generate_user_nonces]
8//! * user sends all board info to the server
9//! * server creates a builder using [BoardBuilder::new_for_cosign]
10//! * server cosigns using [BoardBuilder::server_cosign] and sends cosign response to user
11//! * user validates cosign response using [BoardBuilder::verify_cosign_response]
12//! * user finishes the vtxos by cross-signing using [BoardBuilder::build_vtxo]
13
14use std::marker::PhantomData;
15
16use bitcoin::sighash::{self, SighashCache};
17use bitcoin::taproot::TaprootSpendInfo;
18use bitcoin::{Amount, OutPoint, ScriptBuf, TapSighash, Transaction, TxOut, Txid};
19use bitcoin::hashes::Hash;
20use bitcoin::secp256k1::{Keypair, PublicKey};
21
22use bitcoin_ext::{BlockDelta, BlockHeight, TaprootSpendInfoExt};
23
24use crate::error::IncorrectSigningKeyError;
25use crate::{musig, scripts, SECP};
26use crate::tree::signed::cosign_taproot;
27use crate::vtxo::{self, Full, Vtxo, VtxoId, VtxoPolicy, ServerVtxo, ServerVtxoPolicy, GenesisItem, GenesisTransition};
28
29use self::state::BuilderState;
30
31
32/// The output index of the board vtxo in the board tx.
33pub const BOARD_FUNDING_TX_VTXO_VOUT: u32 = 0;
34
35/// Cached data computed from the exit transaction.
36#[derive(Debug)]
37struct ExitData {
38	sighash: TapSighash,
39	funding_taproot: TaprootSpendInfo,
40	tx: Transaction,
41	txid: Txid,
42}
43
44fn compute_exit_data(
45	user_pubkey: PublicKey,
46	server_pubkey: PublicKey,
47	expiry_height: BlockHeight,
48	exit_delta: BlockDelta,
49	amount: Amount,
50	fee: Amount,
51	utxo: OutPoint,
52) -> ExitData {
53	let combined_pubkey = musig::combine_keys([user_pubkey, server_pubkey])
54		.x_only_public_key().0;
55	let funding_taproot = cosign_taproot(combined_pubkey, server_pubkey, expiry_height);
56	let funding_txout = TxOut {
57		value: amount,
58		script_pubkey: funding_taproot.script_pubkey(),
59	};
60
61	let exit_taproot = VtxoPolicy::new_pubkey(user_pubkey)
62		.taproot(server_pubkey, exit_delta, expiry_height);
63	let exit_txout = TxOut {
64		value: amount - fee,
65		script_pubkey: exit_taproot.script_pubkey(),
66	};
67
68	let tx = vtxo::create_exit_tx(utxo, exit_txout, None, fee);
69	let sighash = SighashCache::new(&tx).taproot_key_spend_signature_hash(
70		0, &sighash::Prevouts::All(&[funding_txout]), sighash::TapSighashType::Default,
71	).expect("matching prevouts");
72
73	let txid = tx.compute_txid();
74	ExitData { sighash, funding_taproot, tx, txid }
75}
76
77#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
78pub enum BoardFundingError {
79	#[error("fee larger than amount: amount {amount}, fee {fee}")]
80	FeeHigherThanAmount {
81		amount: Amount,
82		fee: Amount,
83	},
84	#[error("amount is zero")]
85	ZeroAmount,
86	#[error("amount after fee is <= 0: amount {amount}, fee {fee}")]
87	ZeroAmountAfterFee {
88		amount: Amount,
89		fee: Amount,
90	},
91}
92
93#[derive(Debug, Clone, thiserror::Error)]
94pub enum BoardFromVtxoError {
95	#[error("funding txid mismatch: expected {expected}, got {got}")]
96	FundingTxMismatch {
97		expected: Txid,
98		got: Txid,
99	},
100	#[error("server pubkey mismatch: expected {expected}, got {got}")]
101	ServerPubkeyMismatch {
102		expected: PublicKey,
103		got: PublicKey,
104	},
105	#[error("vtxo id mismatch: expected {expected}, got {got}")]
106	VtxoIdMismatch {
107		expected: OutPoint,
108		got: OutPoint,
109	},
110	#[error("incorrect number of genesis items {genesis_count}, should be 1")]
111	IncorrectGenesisItemCount {
112		genesis_count: usize,
113	},
114}
115
116/// Partial signature the server responds to a board request.
117#[derive(Debug)]
118pub struct BoardCosignResponse {
119	pub pub_nonce: musig::PublicNonce,
120	pub partial_signature: musig::PartialSignature,
121}
122
123pub mod state {
124	mod sealed {
125		/// Just a trait to seal the BuilderState trait.
126		pub trait Sealed {}
127		impl Sealed for super::Preparing {}
128		impl Sealed for super::CanGenerateNonces {}
129		impl Sealed for super::ServerCanCosign {}
130		impl Sealed for super::CanFinish {}
131		impl Sealed for super::ServerCanBuildVtxos {}
132	}
133
134	/// A marker trait used as a generic for [super::BoardBuilder].
135	pub trait BuilderState: sealed::Sealed {}
136
137	/// The user is preparing the board tx.
138	pub struct Preparing;
139	impl BuilderState for Preparing {}
140
141	/// The UTXO that will be used to fund the board is known, so the
142	/// user's signing nonces can be generated.
143	pub struct CanGenerateNonces;
144	impl BuilderState for CanGenerateNonces {}
145
146	/// All the information for the server to cosign the VTXO is known.
147	pub struct ServerCanCosign;
148	impl BuilderState for ServerCanCosign {}
149
150	/// The user is ready to build the VTXO as soon as it has
151	/// a cosign response from the server.
152	pub struct CanFinish;
153	impl BuilderState for CanFinish {}
154
155	/// All the information for the server to build the VTXO is known.
156	pub struct ServerCanBuildVtxos;
157	impl BuilderState for ServerCanBuildVtxos {}
158
159	/// Trait to capture all states that have sufficient information
160	/// for either party to create signatures.
161	pub trait CanSign: BuilderState {}
162	impl CanSign for ServerCanCosign {}
163	impl CanSign for CanFinish {}
164
165	/// Trait for once the funding details are known
166	pub trait HasFundingDetails: BuilderState {}
167	impl HasFundingDetails for CanGenerateNonces {}
168	impl HasFundingDetails for ServerCanCosign {}
169	impl HasFundingDetails for CanFinish {}
170}
171
172/// A request for the server to cosign an board vtxo.
173///
174/// An object of this type is created by the user, sent to the server who will
175/// cosign the request and return his partial signature (along with public nonce)
176/// back to the user so that the user can finish the request and create a [Vtxo].
177///
178/// Currently you can only create VTXOs with [VtxoPolicy::Pubkey].
179#[derive(Debug)]
180pub struct BoardBuilder<S: BuilderState> {
181	pub user_pubkey: PublicKey,
182	pub expiry_height: BlockHeight,
183	pub server_pubkey: PublicKey,
184	pub exit_delta: BlockDelta,
185
186	amount: Option<Amount>,
187	fee: Option<Amount>,
188	utxo: Option<OutPoint>,
189
190	user_pub_nonce: Option<musig::PublicNonce>,
191	user_sec_nonce: Option<musig::SecretNonce>,
192
193	// Cached exit tx data (computed when funding details are set)
194	exit_data: Option<ExitData>,
195
196	/// The signed VTXO provided by the user
197	signed_vtxo: Option<Vtxo<Full>>,
198
199	_state: PhantomData<S>,
200}
201
202impl<S: BuilderState> BoardBuilder<S> {
203	/// The scriptPubkey to send the board funds to.
204	pub fn funding_script_pubkey(&self) -> ScriptBuf {
205		let combined_pubkey = musig::combine_keys([self.user_pubkey, self.server_pubkey])
206			.x_only_public_key().0;
207		cosign_taproot(combined_pubkey, self.server_pubkey, self.expiry_height).script_pubkey()
208	}
209
210	fn to_state<S2: BuilderState>(self) -> BoardBuilder<S2> {
211		BoardBuilder {
212			user_pubkey: self.user_pubkey,
213			expiry_height: self.expiry_height,
214			server_pubkey: self.server_pubkey,
215			exit_delta: self.exit_delta,
216			amount: self.amount,
217			utxo: self.utxo,
218			fee: self.fee,
219			user_pub_nonce: self.user_pub_nonce,
220			user_sec_nonce: self.user_sec_nonce,
221			exit_data: self.exit_data,
222			signed_vtxo: self.signed_vtxo,
223			_state: PhantomData,
224		}
225	}
226}
227
228impl BoardBuilder<state::Preparing> {
229	/// Create a new builder to construct a board vtxo.
230	///
231	/// See module-level documentation for an overview of the board flow.
232	pub fn new(
233		user_pubkey: PublicKey,
234		expiry_height: BlockHeight,
235		server_pubkey: PublicKey,
236		exit_delta: BlockDelta,
237	) -> BoardBuilder<state::Preparing> {
238		BoardBuilder {
239			user_pubkey, expiry_height, server_pubkey, exit_delta,
240			amount: None,
241			utxo: None,
242			fee: None,
243			user_pub_nonce: None,
244			user_sec_nonce: None,
245			exit_data: None,
246			signed_vtxo: None,
247			_state: PhantomData,
248		}
249	}
250
251	/// Set the UTXO where the board will be funded, the total board amount and the fee to be
252	/// deducted.
253	pub fn set_funding_details(
254		mut self,
255		amount: Amount,
256		fee: Amount,
257		utxo: OutPoint,
258	) -> Result<BoardBuilder<state::CanGenerateNonces>, BoardFundingError> {
259		if amount == Amount::ZERO {
260			return Err(BoardFundingError::ZeroAmount);
261		} else if fee > amount {
262			return Err(BoardFundingError::FeeHigherThanAmount { amount, fee });
263		} else if amount - fee == Amount::ZERO {
264			return Err(BoardFundingError::ZeroAmountAfterFee { amount, fee });
265		}
266
267		let exit_data = compute_exit_data(
268			self.user_pubkey, self.server_pubkey, self.expiry_height,
269			self.exit_delta, amount, fee, utxo,
270		);
271
272		self.amount = Some(amount);
273		self.utxo = Some(utxo);
274		self.fee = Some(fee);
275		self.exit_data = Some(exit_data);
276
277		Ok(self.to_state())
278	}
279}
280
281impl BoardBuilder<state::CanGenerateNonces> {
282	/// Generate user nonces.
283	pub fn generate_user_nonces(mut self) -> BoardBuilder<state::CanFinish> {
284		let exit_data = self.exit_data.as_ref().expect("state invariant");
285		let funding_taproot = &exit_data.funding_taproot;
286		let exit_sighash = exit_data.sighash;
287
288		let (agg, _) = musig::tweaked_key_agg(
289			[self.user_pubkey, self.server_pubkey],
290			funding_taproot.tap_tweak().to_byte_array(),
291		);
292		//TODO(stevenroose) consider trying to move this to musig module
293		let (sec_nonce, pub_nonce) = agg.nonce_gen(
294			musig::SessionSecretRand::assume_unique_per_nonce_gen(rand::random()),
295			musig::pubkey_to(self.user_pubkey),
296			&exit_sighash.to_byte_array(),
297			None,
298		);
299
300		self.user_pub_nonce = Some(pub_nonce);
301		self.user_sec_nonce = Some(sec_nonce);
302		self.to_state()
303	}
304
305	/// Returns a reference to the exit transaction.
306	///
307	/// The exit transaction spends the board's funding UTXO and creates
308	/// the VTXO output.
309	pub fn exit_tx(&self) -> &Transaction {
310		&self.exit_data.as_ref().expect("state invariant").tx
311	}
312
313	/// Returns spend information mapping input VTXO IDs to spending transaction IDs.
314	pub fn spend_info(&self) -> Vec<(VtxoId, Txid)> {
315		let exit_txid = self.exit_data.as_ref().expect("state invariant").txid;
316		vec![(self.utxo.expect("state invariant").into(), exit_txid)]
317	}
318}
319
320impl<S: state::CanSign> BoardBuilder<S> {
321	pub fn user_pub_nonce(&self) -> &musig::PublicNonce {
322		self.user_pub_nonce.as_ref().expect("state invariant")
323	}
324}
325
326impl BoardBuilder<state::ServerCanCosign> {
327	/// This constructor is to be used by the server with the information provided
328	/// by the user.
329	pub fn new_for_cosign(
330		user_pubkey: PublicKey,
331		expiry_height: BlockHeight,
332		server_pubkey: PublicKey,
333		exit_delta: BlockDelta,
334		amount: Amount,
335		fee: Amount,
336		utxo: OutPoint,
337		user_pub_nonce: musig::PublicNonce,
338	) -> BoardBuilder<state::ServerCanCosign> {
339		let exit_data = compute_exit_data(
340			user_pubkey, server_pubkey, expiry_height, exit_delta, amount, fee, utxo,
341		);
342
343		BoardBuilder {
344			user_pubkey, expiry_height, server_pubkey, exit_delta,
345			amount: Some(amount),
346			fee: Some(fee),
347			utxo: Some(utxo),
348			user_pub_nonce: Some(user_pub_nonce),
349			user_sec_nonce: None,
350			exit_data: Some(exit_data),
351			signed_vtxo: None,
352			_state: PhantomData,
353		}
354	}
355
356	/// This method is used by the server to cosign the board request.
357	///
358	/// Returns `None` if utxo or user_pub_nonce field is not provided.
359	pub fn server_cosign(&self, key: &Keypair) -> BoardCosignResponse {
360		let exit_data = self.exit_data.as_ref().expect("state invariant");
361		let sighash = exit_data.sighash;
362		let taproot = &exit_data.funding_taproot;
363		let (pub_nonce, partial_signature) = musig::deterministic_partial_sign(
364			key,
365			[self.user_pubkey],
366			&[&self.user_pub_nonce()],
367			sighash.to_byte_array(),
368			Some(taproot.tap_tweak().to_byte_array()),
369		);
370		BoardCosignResponse { pub_nonce, partial_signature }
371	}
372}
373
374impl BoardBuilder<state::ServerCanBuildVtxos> {
375	/// Constructs a BoardBuilder from a vtxo
376	///
377	/// This is used to validate that a vtxo is a board
378	/// that originates from the provided server.
379	///
380	/// This call assumes the [Vtxo] is valid. The caller
381	/// has to call [Vtxo::validate] before using this
382	/// constructor.
383	pub fn new_from_vtxo(
384		vtxo: &Vtxo<Full>,
385		funding_tx: &Transaction,
386		server_pubkey: PublicKey,
387	) -> Result<Self, BoardFromVtxoError> {
388		if vtxo.chain_anchor().txid != funding_tx.compute_txid() {
389			return Err(BoardFromVtxoError::FundingTxMismatch {
390				expected: vtxo.chain_anchor().txid,
391				got: funding_tx.compute_txid(),
392			})
393		}
394
395		if vtxo.server_pubkey() != server_pubkey {
396			return Err(BoardFromVtxoError::ServerPubkeyMismatch {
397				expected: server_pubkey,
398				got: vtxo.server_pubkey(),
399			})
400		}
401
402		if vtxo.genesis.items.len() != 1 {
403			return Err(BoardFromVtxoError::IncorrectGenesisItemCount {
404				genesis_count: vtxo.genesis.items.len(),
405			});
406		}
407
408		let fee = vtxo.genesis.items.first().unwrap().fee_amount;
409		let exit_data = compute_exit_data(
410			vtxo.user_pubkey(),
411			server_pubkey,
412			vtxo.expiry_height,
413			vtxo.exit_delta,
414			vtxo.amount() + fee,
415			fee,
416			vtxo.chain_anchor(),
417		);
418
419		// We compute the vtxo_id again from all reconstructed data
420		// It must match exactly
421		let expected_vtxo_id = OutPoint::new(exit_data.txid, BOARD_FUNDING_TX_VTXO_VOUT);
422		if vtxo.point() != expected_vtxo_id {
423			return Err(BoardFromVtxoError::VtxoIdMismatch {
424				expected: expected_vtxo_id,
425				got: vtxo.point(),
426			})
427		}
428
429		Ok(Self {
430			user_pub_nonce: None,
431			user_sec_nonce: None,
432			amount: Some(vtxo.amount() + fee),
433			fee: Some(fee),
434			user_pubkey: vtxo.user_pubkey(),
435			server_pubkey,
436			expiry_height: vtxo.expiry_height,
437			exit_delta: vtxo.exit_delta,
438			utxo: Some(vtxo.chain_anchor()),
439			exit_data: Some(exit_data),
440			signed_vtxo: Some(vtxo.clone()),
441			_state: PhantomData,
442		})
443	}
444
445	/// Returns the txid of the exit transaction.
446	pub fn exit_txid(&self) -> Txid {
447		self.exit_data.as_ref().expect("state invariant").txid
448	}
449
450	/// Builds the internal unsigned VTXOs created by this board operation.
451	///
452	/// Returns two VTXOs:
453	/// 1. An expiry VTXO with empty genesis (for server tracking)
454	/// 2. The validated output VTXO
455	pub fn build_server_vtxos(&self) -> Vec<ServerVtxo<Full>> {
456		let combined_pubkey = musig::combine_keys([self.user_pubkey, self.server_pubkey])
457			.x_only_public_key().0;
458
459		let vtxo = self.signed_vtxo.as_ref().expect("state invariant").clone();
460
461		vec![
462			Vtxo {
463				policy: ServerVtxoPolicy::new_expiry(combined_pubkey),
464				amount: self.amount.expect("state invariant"),
465				expiry_height: self.expiry_height,
466				server_pubkey: self.server_pubkey,
467				exit_delta: self.exit_delta,
468				anchor_point: self.utxo.expect("state invariant"),
469				genesis: Full { items: vec![] },
470				point: self.utxo.expect("state invariant"),
471			},
472			ServerVtxo::from(vtxo),
473		]
474	}
475
476	/// Returns spend information mapping input VTXO IDs to spending transaction IDs.
477	pub fn spend_info(&self) -> Vec<(VtxoId, Txid)> {
478		let exit_txid = self.exit_data.as_ref().expect("state invariant").txid;
479		vec![(self.utxo.expect("state invariant").into(), exit_txid)]
480	}
481}
482
483impl BoardBuilder<state::CanFinish> {
484	/// Validate the server's partial signature.
485	pub fn verify_cosign_response(&self, server_cosign: &BoardCosignResponse) -> bool {
486		let exit_data = self.exit_data.as_ref().expect("state invariant");
487		let sighash = exit_data.sighash;
488		let taproot = &exit_data.funding_taproot;
489		scripts::verify_partial_sig(
490			sighash,
491			taproot.tap_tweak(),
492			(self.server_pubkey, &server_cosign.pub_nonce),
493			(self.user_pubkey, self.user_pub_nonce()),
494			&server_cosign.partial_signature
495		)
496	}
497
498	/// Finishes the board request and create a vtxo.
499	pub fn build_vtxo(
500		mut self,
501		server_cosign: &BoardCosignResponse,
502		user_key: &Keypair,
503	) -> Result<Vtxo<Full>, IncorrectSigningKeyError> {
504		if user_key.public_key() != self.user_pubkey {
505			return Err(IncorrectSigningKeyError {
506				required: Some(self.user_pubkey),
507				provided: user_key.public_key(),
508			});
509		}
510
511		let exit_data = self.exit_data.as_ref().expect("state invariant");
512		let sighash = exit_data.sighash;
513		let taproot = &exit_data.funding_taproot;
514		let exit_txid = exit_data.txid;
515
516		let agg_nonce = musig::nonce_agg(&[&self.user_pub_nonce(), &server_cosign.pub_nonce]);
517		let (user_sig, final_sig) = musig::partial_sign(
518			[self.user_pubkey, self.server_pubkey],
519			agg_nonce,
520			user_key,
521			self.user_sec_nonce.take().expect("state invariant"),
522			sighash.to_byte_array(),
523			Some(taproot.tap_tweak().to_byte_array()),
524			Some(&[&server_cosign.partial_signature]),
525		);
526		debug_assert!(
527			scripts::verify_partial_sig(
528				sighash,
529				taproot.tap_tweak(),
530				(self.user_pubkey, self.user_pub_nonce()),
531				(self.server_pubkey, &server_cosign.pub_nonce),
532				&user_sig,
533			),
534			"invalid board partial exit tx signature produced",
535		);
536
537		let final_sig = final_sig.expect("we provided the other sig");
538		debug_assert!(
539			SECP.verify_schnorr(
540				&final_sig, &sighash.into(), &taproot.output_key().to_x_only_public_key(),
541			).is_ok(),
542			"invalid board exit tx signature produced",
543		);
544
545		let amount = self.amount.expect("state invariant");
546		let fee = self.fee.expect("state invariant");
547		let vtxo_amount = amount.checked_sub(fee).expect("fee cannot exceed amount");
548
549		Ok(Vtxo {
550			amount: vtxo_amount,
551			expiry_height: self.expiry_height,
552			server_pubkey: self.server_pubkey,
553			exit_delta: self.exit_delta,
554			anchor_point: self.utxo.expect("state invariant"),
555			genesis: Full {
556				items: vec![GenesisItem {
557					transition: GenesisTransition::new_cosigned(
558						vec![self.user_pubkey, self.server_pubkey],
559						Some(final_sig),
560					),
561					output_idx: 0,
562					other_outputs: vec![],
563					fee_amount: fee,
564				}],
565			},
566			policy: VtxoPolicy::new_pubkey(self.user_pubkey),
567			point: OutPoint::new(exit_txid, BOARD_FUNDING_TX_VTXO_VOUT),
568		})
569	}
570}
571
572#[derive(Debug, Clone, thiserror::Error)]
573#[error("board funding tx validation error: {0}")]
574pub struct BoardFundingTxValidationError(String);
575
576
577#[cfg(test)]
578mod test {
579	use std::str::FromStr;
580
581	use bitcoin::{absolute, transaction, Amount};
582
583	use crate::test_util::encoding_roundtrip;
584
585	use super::*;
586
587	#[test]
588	fn test_board_builder() {
589		//! Passes through the entire flow so that all assertions
590		//! inside the code are ran at least once.
591
592		let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
593		let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
594
595		// user
596		let amount = Amount::from_btc(1.5).unwrap();
597		let fee = Amount::from_btc(0.1).unwrap();
598		let expiry = 100_000;
599		let server_pubkey = server_key.public_key();
600		let exit_delta = 24;
601		let builder = BoardBuilder::new(
602			user_key.public_key(), expiry, server_pubkey, exit_delta,
603		);
604		let funding_tx = Transaction {
605			version: transaction::Version::TWO,
606			lock_time: absolute::LockTime::ZERO,
607			input: vec![],
608			output: vec![TxOut {
609				value: amount,
610				script_pubkey: builder.funding_script_pubkey(),
611			}],
612		};
613		let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
614		assert_eq!(utxo.to_string(), "8c4b87af4ce8456bbd682859959ba64b95d5425d761a367f4f20b8ffccb1bde0:0");
615		let builder = builder.set_funding_details(amount, fee, utxo).unwrap().generate_user_nonces();
616
617		// server
618		let cosign = {
619			let server_builder = BoardBuilder::new_for_cosign(
620				builder.user_pubkey, expiry, server_pubkey, exit_delta, amount, fee, utxo, *builder.user_pub_nonce(),
621			);
622			server_builder.server_cosign(&server_key)
623		};
624
625		// user
626		assert!(builder.verify_cosign_response(&cosign));
627		let vtxo = builder.build_vtxo(&cosign, &user_key).unwrap();
628
629		encoding_roundtrip(&vtxo);
630
631		vtxo.validate(&funding_tx).unwrap();
632	}
633
634	/// Helper to create a valid vtxo and funding tx for testing new_from_vtxo
635	fn create_board_vtxo() -> (Vtxo<Full>, Transaction, Keypair, Keypair) {
636		let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
637		let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
638
639		let amount = Amount::from_btc(1.5).unwrap();
640		let fee = Amount::from_btc(0.1).unwrap();
641		let expiry = 100_000;
642		let server_pubkey = server_key.public_key();
643		let exit_delta = 24;
644
645		let builder = BoardBuilder::new(
646			user_key.public_key(), expiry, server_pubkey, exit_delta,
647		);
648		let funding_tx = Transaction {
649			version: transaction::Version::TWO,
650			lock_time: absolute::LockTime::ZERO,
651			input: vec![],
652			output: vec![TxOut {
653				value: amount,
654				script_pubkey: builder.funding_script_pubkey(),
655			}],
656		};
657		let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
658		let builder = builder.set_funding_details(amount, fee, utxo).unwrap().generate_user_nonces();
659
660		let cosign = {
661			let server_builder = BoardBuilder::new_for_cosign(
662				builder.user_pubkey, expiry, server_pubkey, exit_delta, amount, fee, utxo, *builder.user_pub_nonce(),
663			);
664			server_builder.server_cosign(&server_key)
665		};
666
667		let vtxo = builder.build_vtxo(&cosign, &user_key).unwrap();
668		(vtxo, funding_tx, user_key, server_key)
669	}
670
671	#[test]
672	fn test_new_from_vtxo_success() {
673		let (vtxo, funding_tx, _, server_key) = create_board_vtxo();
674
675		vtxo.validate(&funding_tx).unwrap();
676		println!("amount: {}", vtxo.amount());
677
678		// Should succeed with correct inputs
679		let builder = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, server_key.public_key())
680			.expect("Is valid");
681
682		let server_vtxos = builder.build_server_vtxos();
683		assert_eq!(server_vtxos.len(), 2);
684		assert!(matches!(server_vtxos[0].policy(), ServerVtxoPolicy::Expiry(..)));
685		assert!(matches!(server_vtxos[1].policy(), ServerVtxoPolicy::User(VtxoPolicy::Pubkey {..})));
686		assert_eq!(server_vtxos[1].id(), vtxo.id());
687		assert_eq!(server_vtxos[1].txout(), vtxo.txout());
688		assert_eq!(server_vtxos[0].txout(), funding_tx.output[0]);
689		assert_eq!(
690			server_vtxos[1].transactions().nth(0).unwrap().tx.compute_txid(),
691			vtxo.transactions().nth(0).unwrap().tx.compute_txid(),
692		);
693	}
694
695	#[test]
696	fn test_new_from_vtxo_txid_mismatch() {
697		let (vtxo, funding_tx, _, server_key) = create_board_vtxo();
698
699		// Create a different funding tx with wrong txid
700		let wrong_funding_tx = Transaction {
701			version: transaction::Version::TWO,
702			lock_time: absolute::LockTime::ZERO,
703			input: vec![],
704			output: vec![TxOut {
705				value: Amount::from_btc(2.0).unwrap(), // Different amount = different txid
706				script_pubkey: funding_tx.output[0].script_pubkey.clone(),
707			}],
708		};
709
710		let result = BoardBuilder::new_from_vtxo(&vtxo, &wrong_funding_tx, server_key.public_key());
711		assert!(matches!(
712			result,
713			Err(BoardFromVtxoError::FundingTxMismatch { expected, got })
714			if expected == vtxo.chain_anchor().txid && got == wrong_funding_tx.compute_txid()
715		));
716	}
717
718	#[test]
719	fn test_new_from_vtxo_server_pubkey_mismatch() {
720		let (vtxo, funding_tx, _, _) = create_board_vtxo();
721
722		// Use a different server pubkey
723		let wrong_server_key = Keypair::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
724
725		let result = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, wrong_server_key.public_key());
726		assert!(matches!(
727			result,
728			Err(BoardFromVtxoError::ServerPubkeyMismatch { expected, got })
729			if expected == wrong_server_key.public_key() && got == vtxo.server_pubkey()
730		));
731	}
732
733	#[test]
734	fn test_new_from_vtxo_vtxoid_mismatch() {
735		// This test verifies that BoardBuilder::new_from_vtxo detects when the
736		// vtxo's point doesn't match the computed exit tx output.
737		//
738		// Note: It is not the responsibility of new_from_vtxo to validate that
739		// the vtxo's point is correct in the first place. That validation
740		// happens in Vtxo::validate. This check ensures internal consistency
741		// when reconstructing the board from a vtxo.
742		let (mut vtxo, funding_tx, _, server_key) = create_board_vtxo();
743
744		// Tamper with the vtxo's point to cause a mismatch
745		let original_point = vtxo.point;
746		vtxo.point = OutPoint::new(vtxo.point.txid, vtxo.point.vout + 1);
747
748		let result = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, server_key.public_key());
749		assert!(matches!(
750			result,
751			Err(BoardFromVtxoError::VtxoIdMismatch { expected, got })
752			if expected == original_point && got == vtxo.point
753		));
754	}
755
756	#[test]
757	fn test_board_funding_error() {
758		fn new_builder_with_funding_details(amount: Amount, fee: Amount) -> Result<BoardBuilder<state::CanGenerateNonces>, BoardFundingError> {
759			let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
760			let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
761			let expiry = 100_000;
762			let server_pubkey = server_key.public_key();
763			let exit_delta = 24;
764			let builder = BoardBuilder::new(
765				user_key.public_key(), expiry, server_pubkey, exit_delta,
766			);
767			let funding_tx = Transaction {
768				version: transaction::Version::TWO,
769				lock_time: absolute::LockTime::ZERO,
770				input: vec![],
771				output: vec![TxOut {
772					value: amount,
773					script_pubkey: builder.funding_script_pubkey(),
774				}],
775			};
776			let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
777			builder.set_funding_details(amount, fee, utxo)
778		}
779
780		let fee = Amount::ONE_BTC;
781
782		let zero_amount_err = new_builder_with_funding_details(Amount::ZERO, fee).err();
783		assert_eq!(zero_amount_err, Some(BoardFundingError::ZeroAmount));
784
785		let fee_higher_err = new_builder_with_funding_details(Amount::ONE_SAT, fee).err();
786		assert_eq!(fee_higher_err, Some(BoardFundingError::FeeHigherThanAmount { amount: Amount::ONE_SAT, fee }));
787
788		let zero_amount_after_fee_err = new_builder_with_funding_details(fee, fee).err();
789		assert_eq!(zero_amount_after_fee_err, Some(BoardFundingError::ZeroAmountAfterFee { amount: fee, fee }));
790	}
791}
792