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};
21use bitcoin_ext::{BlockDelta, BlockHeight, TaprootSpendInfoExt};
22
23use crate::error::IncorrectSigningKeyError;
24use crate::{musig, scripts, SECP};
25use crate::tree::signed::cosign_taproot;
26use crate::vtxo::{self, Vtxo, VtxoPolicy, GenesisItem, GenesisTransition};
27
28use self::state::BuilderState;
29
30
31/// The output index of the board vtxo in the board tx.
32pub const BOARD_FUNDING_TX_VTXO_VOUT: u32 = 0;
33
34fn exit_tx_sighash(
35	prev_utxo: &TxOut,
36	utxo: OutPoint,
37	output: TxOut,
38) -> (TapSighash, Transaction) {
39	let exit_tx = vtxo::create_exit_tx(utxo, output, None);
40	let sighash = SighashCache::new(&exit_tx).taproot_key_spend_signature_hash(
41		0, &sighash::Prevouts::All(&[prev_utxo]), sighash::TapSighashType::Default,
42	).expect("matching prevouts");
43	(sighash, exit_tx)
44}
45
46#[derive(Debug, Clone, thiserror::Error)]
47pub enum BoardFromVtxoError {
48	#[error("funding txid mismatch: expected {expected}, got {got}")]
49	FundingTxMismatch {
50		expected: Txid,
51		got: Txid,
52	},
53	#[error("server pubkey mismatch: expected {expected}, got {got}")]
54	ServerPubkeyMismatch {
55		expected: PublicKey,
56		got: PublicKey,
57	},
58	#[error("vtxo id mismatch: expected {expected}, got {got}")]
59	VtxoIdMismatch {
60		expected: OutPoint,
61		got: OutPoint,
62	},
63}
64
65/// Partial signature the server responds to a board request.
66#[derive(Debug)]
67pub struct BoardCosignResponse {
68	pub pub_nonce: musig::PublicNonce,
69	pub partial_signature: musig::PartialSignature,
70}
71
72pub mod state {
73	mod sealed {
74		/// Just a trait to seal the BuilderState trait.
75		pub trait Sealed {}
76		impl Sealed for super::Preparing {}
77		impl Sealed for super::CanGenerateNonces {}
78		impl Sealed for super::ServerCanCosign {}
79		impl Sealed for super::CanFinish {}
80	}
81
82	/// A marker trait used as a generic for [super::BoardBuilder].
83	pub trait BuilderState: sealed::Sealed {}
84
85	/// The user is preparing the board tx.
86	pub struct Preparing;
87	impl BuilderState for Preparing {}
88
89	/// The UTXO that will be used to fund the board is known, so the
90	/// user's signing nonces can be generated.
91	pub struct CanGenerateNonces;
92	impl BuilderState for CanGenerateNonces {}
93
94	/// All the information for the server to cosign the VTXO is known.
95	pub struct ServerCanCosign;
96	impl BuilderState for ServerCanCosign {}
97
98	/// The user is ready to build the VTXO as soon as it has
99	/// a cosign response from the server.
100	pub struct CanFinish;
101	impl BuilderState for CanFinish {}
102
103	/// Trait to capture all states that have sufficient information
104	/// for either party to create signatures.
105	pub trait CanSign: BuilderState {}
106	impl CanSign for ServerCanCosign {}
107	impl CanSign for CanFinish {}
108
109	/// Trait for once the funding details are known
110	pub trait HasFundingDetails: BuilderState {}
111	impl HasFundingDetails for CanGenerateNonces {}
112	impl HasFundingDetails for ServerCanCosign {}
113	impl HasFundingDetails for CanFinish {}
114}
115
116/// A request for the server to cosign an board vtxo.
117///
118/// An object of this type is created by the user, sent to the server who will
119/// cosign the request and return his partial signature (along with public nonce)
120/// back to the user so that the user can finish the request and create a [Vtxo].
121///
122/// Currently you can only create VTXOs with [VtxoPolicy::Pubkey].
123#[derive(Debug)]
124pub struct BoardBuilder<S: BuilderState> {
125	pub user_pubkey: PublicKey,
126	pub expiry_height: BlockHeight,
127	pub server_pubkey: PublicKey,
128	pub exit_delta: BlockDelta,
129
130	amount: Option<Amount>,
131	utxo: Option<OutPoint>,
132
133	user_pub_nonce: Option<musig::PublicNonce>,
134	user_sec_nonce: Option<musig::SecretNonce>,
135	_state: PhantomData<S>,
136}
137
138impl<S: BuilderState> BoardBuilder<S> {
139	/// The scriptPubkey to send the board funds to.
140	pub fn funding_script_pubkey(&self) -> ScriptBuf {
141		let combined_pubkey = musig::combine_keys([self.user_pubkey, self.server_pubkey]);
142		cosign_taproot(combined_pubkey, self.server_pubkey, self.expiry_height).script_pubkey()
143	}
144
145}
146
147impl BoardBuilder<state::Preparing> {
148	/// Create a new builder to construct a board vtxo.
149	///
150	/// See module-level documentation for an overview of the board flow.
151	pub fn new(
152		user_pubkey: PublicKey,
153		expiry_height: BlockHeight,
154		server_pubkey: PublicKey,
155		exit_delta: BlockDelta,
156	) -> BoardBuilder<state::Preparing> {
157		BoardBuilder {
158			user_pubkey, expiry_height, server_pubkey, exit_delta,
159			amount: None,
160			utxo: None,
161			user_pub_nonce: None,
162			user_sec_nonce: None,
163			_state: PhantomData,
164		}
165	}
166
167	/// Set the UTXO where the board will be funded and the board amount.
168	pub fn set_funding_details(
169		self,
170		amount: Amount,
171		utxo: OutPoint,
172	) -> BoardBuilder<state::CanGenerateNonces> {
173		BoardBuilder {
174			amount: Some(amount),
175			utxo: Some(utxo),
176			// copy the rest
177			user_pubkey: self.user_pubkey,
178			expiry_height: self.expiry_height,
179			server_pubkey: self.server_pubkey,
180			exit_delta: self.exit_delta,
181			user_pub_nonce: self.user_pub_nonce,
182			user_sec_nonce: self.user_sec_nonce,
183			_state: PhantomData,
184		}
185	}
186}
187
188impl BoardBuilder<state::CanGenerateNonces> {
189	/// Generate user nonces.
190	pub fn generate_user_nonces(self) -> BoardBuilder<state::CanFinish> {
191		let combined_pubkey = musig::combine_keys([self.user_pubkey, self.server_pubkey]);
192		let funding_taproot = cosign_taproot(combined_pubkey, self.server_pubkey, self.expiry_height);
193		let funding_txout = TxOut {
194			script_pubkey: funding_taproot.script_pubkey(),
195			value: self.amount.expect("state invariant"),
196		};
197
198		let exit_taproot = VtxoPolicy::new_pubkey(self.user_pubkey)
199			.taproot(self.server_pubkey, self.exit_delta, self.expiry_height);
200		let exit_txout = TxOut {
201			value: self.amount.expect("state invariant"),
202			script_pubkey: exit_taproot.script_pubkey(),
203		};
204
205		let utxo = self.utxo.expect("state invariant");
206		let (reveal_sighash, _tx) = exit_tx_sighash(&funding_txout, utxo, exit_txout);
207		let (agg, _) = musig::tweaked_key_agg(
208			[self.user_pubkey, self.server_pubkey],
209			funding_taproot.tap_tweak().to_byte_array(),
210		);
211		//TODO(stevenroose) consider trying to move this to musig module
212		let (sec_nonce, pub_nonce) = agg.nonce_gen(
213			musig::SessionSecretRand::assume_unique_per_nonce_gen(rand::random()),
214			musig::pubkey_to(self.user_pubkey),
215			&reveal_sighash.to_byte_array(),
216			None,
217		);
218
219		BoardBuilder {
220			user_pub_nonce: Some(pub_nonce),
221			user_sec_nonce: Some(sec_nonce),
222			// copy the rest
223			amount: self.amount,
224			user_pubkey: self.user_pubkey,
225			expiry_height: self.expiry_height,
226			server_pubkey: self.server_pubkey,
227			exit_delta: self.exit_delta,
228			utxo: self.utxo,
229			_state: PhantomData,
230		}
231	}
232
233	/// Constructs a BoardBuilder from a vtxo
234	///
235	/// This is used to validate that a vtxo is a board
236	/// that originates from the provided server.
237	///
238	/// This call assumes the [Vtxo] is valid. The caller
239	/// has to call [Vtxo::validate] before using this
240	/// constructor.
241	pub fn new_from_vtxo(
242		vtxo: &Vtxo,
243		funding_tx: &Transaction,
244		server_pubkey: PublicKey,
245	) -> Result<Self, BoardFromVtxoError> {
246		if vtxo.chain_anchor().txid != funding_tx.compute_txid() {
247			return Err(BoardFromVtxoError::FundingTxMismatch {
248				expected: vtxo.chain_anchor().txid,
249				got: funding_tx.compute_txid(),
250			})
251		}
252
253		if vtxo.server_pubkey() != server_pubkey {
254			return Err(BoardFromVtxoError::ServerPubkeyMismatch {
255				expected: server_pubkey,
256				got: vtxo.server_pubkey(),
257			})
258		}
259
260		let builder = Self {
261			user_pub_nonce: None,
262			user_sec_nonce: None,
263			amount: Some(vtxo.amount()),
264			user_pubkey: vtxo.user_pubkey(),
265			server_pubkey,
266			expiry_height: vtxo.expiry_height,
267			exit_delta: vtxo.exit_delta,
268			utxo: Some(vtxo.chain_anchor()),
269			_state: PhantomData,
270		};
271
272		// We compute the vtxo_id again from all reconstructed data
273		// It must match exactly
274		let (_, _, exit_tx) = builder.exit_tx_sighash_data();
275		let expected_vtxo_id = OutPoint::new(exit_tx, BOARD_FUNDING_TX_VTXO_VOUT);
276		if vtxo.point() != expected_vtxo_id {
277			return Err(BoardFromVtxoError::VtxoIdMismatch {
278				expected: expected_vtxo_id,
279				got: vtxo.point(),
280			})
281		}
282
283		Ok(builder)
284	}
285}
286
287impl<S: state::CanSign> BoardBuilder<S> {
288	pub fn user_pub_nonce(&self) -> &musig::PublicNonce {
289		self.user_pub_nonce.as_ref().expect("state invariant")
290	}
291}
292
293impl BoardBuilder<state::ServerCanCosign> {
294	/// This constructor is to be used by the server with the information provided
295	/// by the user.
296	pub fn new_for_cosign(
297		user_pubkey: PublicKey,
298		expiry_height: BlockHeight,
299		server_pubkey: PublicKey,
300		exit_delta: BlockDelta,
301		amount: Amount,
302		utxo: OutPoint,
303		user_pub_nonce: musig::PublicNonce,
304	) -> BoardBuilder<state::ServerCanCosign> {
305		BoardBuilder {
306			user_pubkey, expiry_height, server_pubkey, exit_delta,
307			amount: Some(amount),
308			utxo: Some(utxo),
309			user_pub_nonce: Some(user_pub_nonce),
310			user_sec_nonce: None,
311			_state: PhantomData,
312		}
313	}
314
315	/// This method is used by the server to cosign the board request.
316	///
317	/// Returns `None` if utxo or user_pub_nonce field is not provided.
318	pub fn server_cosign(&self, key: &Keypair) -> BoardCosignResponse {
319		let (sighash, taproot, _txid) = self.exit_tx_sighash_data();
320		let (pub_nonce, partial_signature) = musig::deterministic_partial_sign(
321			key,
322			[self.user_pubkey],
323			&[&self.user_pub_nonce()],
324			sighash.to_byte_array(),
325			Some(taproot.tap_tweak().to_byte_array()),
326		);
327		BoardCosignResponse { pub_nonce, partial_signature }
328	}
329}
330
331impl BoardBuilder<state::CanFinish> {
332	/// Validate the server's partial signature.
333	pub fn verify_cosign_response(&self, server_cosign: &BoardCosignResponse) -> bool {
334		let (sighash, taproot, _txid) = self.exit_tx_sighash_data();
335		scripts::verify_partial_sig(
336			sighash,
337			taproot.tap_tweak(),
338			(self.server_pubkey, &server_cosign.pub_nonce),
339			(self.user_pubkey, self.user_pub_nonce()),
340			&server_cosign.partial_signature
341		)
342	}
343
344	/// Finishes the board request and create a vtxo.
345	pub fn build_vtxo(
346		mut self,
347		server_cosign: &BoardCosignResponse,
348		user_key: &Keypair,
349	) -> Result<Vtxo, IncorrectSigningKeyError> {
350		if user_key.public_key() != self.user_pubkey {
351			return Err(IncorrectSigningKeyError {
352				required: Some(self.user_pubkey),
353				provided: user_key.public_key(),
354			});
355		}
356
357		let (sighash, taproot, exit_txid) = self.exit_tx_sighash_data();
358
359		let agg_nonce = musig::nonce_agg(&[&self.user_pub_nonce(), &server_cosign.pub_nonce]);
360		let (user_sig, final_sig) = musig::partial_sign(
361			[self.user_pubkey, self.server_pubkey],
362			agg_nonce,
363			user_key,
364			self.user_sec_nonce.take().expect("state invariant"),
365			sighash.to_byte_array(),
366			Some(taproot.tap_tweak().to_byte_array()),
367			Some(&[&server_cosign.partial_signature]),
368		);
369		debug_assert!(
370			scripts::verify_partial_sig(
371				sighash,
372				taproot.tap_tweak(),
373				(self.user_pubkey, self.user_pub_nonce()),
374				(self.server_pubkey, &server_cosign.pub_nonce),
375				&user_sig,
376			),
377			"invalid board partial exit tx signature produced",
378		);
379
380		let final_sig = final_sig.expect("we provided the other sig");
381		debug_assert!(
382			SECP.verify_schnorr(
383				&final_sig, &sighash.into(), &taproot.output_key().to_x_only_public_key(),
384			).is_ok(),
385			"invalid board exit tx signature produced",
386		);
387
388		Ok(Vtxo {
389			amount: self.amount.expect("state invariant"),
390			expiry_height: self.expiry_height,
391			server_pubkey: self.server_pubkey,
392			exit_delta: self.exit_delta,
393			anchor_point: self.utxo.expect("state invariant"),
394			genesis: vec![GenesisItem {
395				transition: GenesisTransition::Cosigned {
396					pubkeys: vec![self.user_pubkey, self.server_pubkey],
397					signature: final_sig,
398				},
399				output_idx: 0,
400				other_outputs: vec![],
401			}],
402			policy: VtxoPolicy::new_pubkey(self.user_pubkey),
403			point: OutPoint::new(exit_txid, BOARD_FUNDING_TX_VTXO_VOUT),
404		})
405	}
406}
407
408#[derive(Debug, Clone, thiserror::Error)]
409#[error("board funding tx validation error: {0}")]
410pub struct BoardFundingTxValidationError(String);
411
412impl<S: state::HasFundingDetails> BoardBuilder<S> {
413
414	/// The signature hash to sign the exit tx and the taproot info
415	/// (of the funding tx) used to calculate it and the exit tx's txid.
416	fn exit_tx_sighash_data(&self) -> (TapSighash, TaprootSpendInfo, Txid) {
417		let combined_pubkey = musig::combine_keys([self.user_pubkey, self.server_pubkey]);
418		let funding_taproot = cosign_taproot(combined_pubkey, self.server_pubkey, self.expiry_height);
419		let funding_txout = TxOut {
420			value: self.amount.expect("state invariant"),
421			script_pubkey: funding_taproot.script_pubkey(),
422		};
423
424		let exit_taproot = VtxoPolicy::new_pubkey(self.user_pubkey)
425			.taproot(self.server_pubkey, self.exit_delta, self.expiry_height);
426		let exit_txout = TxOut {
427			value: self.amount.expect("state invariant"),
428			script_pubkey: exit_taproot.script_pubkey(),
429		};
430
431		let utxo = self.utxo.expect("state invariant");
432		let (sighash, tx) = exit_tx_sighash(&funding_txout, utxo, exit_txout);
433		(sighash, funding_taproot, tx.compute_txid())
434	}
435}
436
437#[cfg(test)]
438mod test {
439	use std::str::FromStr;
440
441	use bitcoin::{absolute, transaction, Amount};
442
443	use crate::encode::test::encoding_roundtrip;
444
445	use super::*;
446
447	#[test]
448	fn test_board_builder() {
449		//! Passes through the entire flow so that all assertions
450		//! inside the code are ran at least once.
451
452		let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
453		let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
454
455		// user
456		let amount = Amount::from_btc(1.5).unwrap();
457		let expiry = 100_000;
458		let server_pubkey = server_key.public_key();
459		let exit_delta = 24;
460		let builder = BoardBuilder::new(
461			user_key.public_key(), expiry, server_pubkey, exit_delta,
462		);
463		let funding_tx = Transaction {
464			version: transaction::Version::TWO,
465			lock_time: absolute::LockTime::ZERO,
466			input: vec![],
467			output: vec![TxOut {
468				value: amount,
469				script_pubkey: builder.funding_script_pubkey(),
470			}],
471		};
472		let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
473		assert_eq!(utxo.to_string(), "8c4b87af4ce8456bbd682859959ba64b95d5425d761a367f4f20b8ffccb1bde0:0");
474		let builder = builder.set_funding_details(amount, utxo).generate_user_nonces();
475
476		// server
477		let cosign = {
478			let server_builder = BoardBuilder::new_for_cosign(
479				builder.user_pubkey, expiry, server_pubkey, exit_delta, amount, utxo, *builder.user_pub_nonce(),
480			);
481			server_builder.server_cosign(&server_key)
482		};
483
484		// user
485		assert!(builder.verify_cosign_response(&cosign));
486		let vtxo = builder.build_vtxo(&cosign, &user_key).unwrap();
487
488		encoding_roundtrip(&vtxo);
489
490		vtxo.validate(&funding_tx).unwrap();
491	}
492
493	/// Helper to create a valid vtxo and funding tx for testing new_from_vtxo
494	fn create_board_vtxo() -> (Vtxo, Transaction, Keypair, Keypair) {
495		let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
496		let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
497
498		let amount = Amount::from_btc(1.5).unwrap();
499		let expiry = 100_000;
500		let server_pubkey = server_key.public_key();
501		let exit_delta = 24;
502
503		let builder = BoardBuilder::new(
504			user_key.public_key(), expiry, server_pubkey, exit_delta,
505		);
506		let funding_tx = Transaction {
507			version: transaction::Version::TWO,
508			lock_time: absolute::LockTime::ZERO,
509			input: vec![],
510			output: vec![TxOut {
511				value: amount,
512				script_pubkey: builder.funding_script_pubkey(),
513			}],
514		};
515		let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
516		let builder = builder.set_funding_details(amount, utxo).generate_user_nonces();
517
518		let cosign = {
519			let server_builder = BoardBuilder::new_for_cosign(
520				builder.user_pubkey, expiry, server_pubkey, exit_delta, amount, utxo, *builder.user_pub_nonce(),
521			);
522			server_builder.server_cosign(&server_key)
523		};
524
525		let vtxo = builder.build_vtxo(&cosign, &user_key).unwrap();
526		(vtxo, funding_tx, user_key, server_key)
527	}
528
529	#[test]
530	fn test_new_from_vtxo_success() {
531		let (vtxo, funding_tx, _, server_key) = create_board_vtxo();
532
533		// Should succeed with correct inputs
534		let result = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, server_key.public_key());
535		assert!(result.is_ok());
536	}
537
538	#[test]
539	fn test_new_from_vtxo_txid_mismatch() {
540		let (vtxo, funding_tx, _, server_key) = create_board_vtxo();
541
542		// Create a different funding tx with wrong txid
543		let wrong_funding_tx = Transaction {
544			version: transaction::Version::TWO,
545			lock_time: absolute::LockTime::ZERO,
546			input: vec![],
547			output: vec![TxOut {
548				value: Amount::from_btc(2.0).unwrap(), // Different amount = different txid
549				script_pubkey: funding_tx.output[0].script_pubkey.clone(),
550			}],
551		};
552
553		let result = BoardBuilder::new_from_vtxo(&vtxo, &wrong_funding_tx, server_key.public_key());
554		assert!(matches!(
555			result,
556			Err(BoardFromVtxoError::FundingTxMismatch { expected, got })
557			if expected == vtxo.chain_anchor().txid && got == wrong_funding_tx.compute_txid()
558		));
559	}
560
561	#[test]
562	fn test_new_from_vtxo_server_pubkey_mismatch() {
563		let (vtxo, funding_tx, _, _) = create_board_vtxo();
564
565		// Use a different server pubkey
566		let wrong_server_key = Keypair::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
567
568		let result = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, wrong_server_key.public_key());
569		assert!(matches!(
570			result,
571			Err(BoardFromVtxoError::ServerPubkeyMismatch { expected, got })
572			if expected == wrong_server_key.public_key() && got == vtxo.server_pubkey()
573		));
574	}
575
576	#[test]
577	fn test_new_from_vtxo_vtxoid_mismatch() {
578		// This test verifies that BoardBuilder::new_from_vtxo detects when the
579		// vtxo's point doesn't match the computed exit tx output.
580		//
581		// Note: It is not the responsibility of new_from_vtxo to validate that
582		// the vtxo's point is correct in the first place. That validation
583		// happens in Vtxo::validate. This check ensures internal consistency
584		// when reconstructing the board from a vtxo.
585		let (mut vtxo, funding_tx, _, server_key) = create_board_vtxo();
586
587		// Tamper with the vtxo's point to cause a mismatch
588		let original_point = vtxo.point;
589		vtxo.point = OutPoint::new(vtxo.point.txid, vtxo.point.vout + 1);
590
591		let result = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, server_key.public_key());
592		assert!(matches!(
593			result,
594			Err(BoardFromVtxoError::VtxoIdMismatch { expected, got })
595			if expected == original_point && got == vtxo.point
596		));
597	}
598}
599