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