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