1use 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
31pub 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#[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 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 pub trait BuilderState: sealed::Sealed {}
84
85 pub struct Preparing;
87 impl BuilderState for Preparing {}
88
89 pub struct CanGenerateNonces;
92 impl BuilderState for CanGenerateNonces {}
93
94 pub struct ServerCanCosign;
96 impl BuilderState for ServerCanCosign {}
97
98 pub struct CanFinish;
101 impl BuilderState for CanFinish {}
102
103 pub trait CanSign: BuilderState {}
106 impl CanSign for ServerCanCosign {}
107 impl CanSign for CanFinish {}
108
109 pub trait HasFundingDetails: BuilderState {}
111 impl HasFundingDetails for CanGenerateNonces {}
112 impl HasFundingDetails for ServerCanCosign {}
113 impl HasFundingDetails for CanFinish {}
114}
115
116#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
453 let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
454
455 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 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 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 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 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 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(), 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 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 let (mut vtxo, funding_tx, _, server_key) = create_board_vtxo();
586
587 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