1use std::borrow::Borrow;
13
14use bitcoin::{
15 Amount, FeeRate, OutPoint, ScriptBuf, Sequence, TapSighashType, Transaction, TxIn, TxOut, Txid,
16 Witness,
17};
18use bitcoin::hashes::Hash;
19use bitcoin::hex::DisplayHex;
20use bitcoin::secp256k1::{schnorr, Keypair, PublicKey};
21use bitcoin::sighash::{Prevouts, SighashCache};
22
23use bitcoin_ext::{fee, BlockDelta, BlockHeight, KeypairExt, NonStandardOutput, TxOutExt, P2TR_DUST};
24
25use crate::{musig, ServerVtxo, ServerVtxoPolicy, Vtxo, VtxoId, SECP};
26use crate::connectors::construct_multi_connector_fanout_tx;
27use crate::vtxo::{Bare, Full};
28
29
30pub const OFFBOARD_TX_OFFBOARD_VOUT: usize = 0;
32pub const OFFBOARD_TX_CONNECTOR_VOUT: usize = 1;
34
35const CONNECTOR_EXPIRY_DELTA: BlockDelta = 144;
37
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
40#[error("invalid offboard request: {0}")]
41pub struct InvalidOffboardRequestError(String);
42
43impl From<NonStandardOutput> for InvalidOffboardRequestError {
44 fn from(err: NonStandardOutput) -> Self {
45 Self(format!("{:#}", err))
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
51pub struct OffboardRequest {
52 #[serde(with = "bitcoin_ext::serde::encodable")]
54 pub script_pubkey: ScriptBuf,
55 #[serde(rename = "amount_sat", with = "bitcoin::amount::serde::as_sat")]
57 pub net_amount: Amount,
58 pub deduct_fees_from_gross_amount: bool,
61 #[serde(rename = "fee_rate_kwu")]
63 pub fee_rate: FeeRate,
64}
65
66impl OffboardRequest {
67 pub fn validate(&self) -> Result<(), InvalidOffboardRequestError> {
69 Ok(self.to_txout().check_standard()?)
70 }
71
72 pub fn to_txout(&self) -> TxOut {
74 TxOut {
75 script_pubkey: self.script_pubkey.clone(),
76 value: self.net_amount,
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
82#[error("invalid offboard transaction: {0}")]
83pub struct InvalidOffboardTxError(String);
84
85impl<S: Into<String>> From<S> for InvalidOffboardTxError {
86 fn from(v: S) -> Self {
87 Self(v.into())
88 }
89}
90
91impl From<InvalidOffboardRequestError> for InvalidOffboardTxError {
92 fn from(e: InvalidOffboardRequestError) -> Self {
93 InvalidOffboardTxError(format!("invalid offboard request: {:#}", e))
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
98#[error("invalid partial signature for VTXO {vtxo}")]
99pub struct InvalidUserPartialSignatureError {
100 pub vtxo: VtxoId,
101}
102
103pub struct OffboardForfeitSignatures {
104 pub public_nonces: Vec<musig::PublicNonce>,
105 pub partial_signatures: Vec<musig::PartialSignature>,
106}
107
108pub struct OffboardForfeitResult {
109 pub forfeit_txs: Vec<Transaction>,
110 pub forfeit_vtxos: Vec<ServerVtxo>,
111 pub connector_tx: Option<Transaction>,
112 pub connector_vtxos: Vec<ServerVtxo>,
113}
114
115impl OffboardForfeitResult {
116 pub fn spend_info<'a>(
117 &'a self,
118 inputs: impl Iterator<Item = VtxoId> + 'a,
119 offboard_txid: Txid,
120 ) -> impl Iterator<Item = (VtxoId, Txid)> + 'a {
121 let vtxos_to_ff = inputs.zip(self.forfeit_txs.iter().map(|t| t.compute_txid()));
127
128 let connector = if let Some(ref conn_tx) = self.connector_tx {
129 Some((OutPoint::new(offboard_txid, 1).into(), conn_tx.compute_txid()))
130 } else {
131 None
132 };
133
134 vtxos_to_ff.chain(connector)
135 }
136}
137
138pub struct OffboardForfeitContext<'a, V> {
139 input_vtxos: &'a [V],
140 offboard_tx: &'a Transaction,
141}
142
143impl<'a, V> OffboardForfeitContext<'a, V> {
148 pub fn new(input_vtxos: &'a [V], offboard_tx: &'a Transaction) -> Self {
152 assert_ne!(input_vtxos.len(), 0, "no input VTXOs");
153 Self { input_vtxos, offboard_tx }
154 }
155
156 pub fn validate_offboard_tx(
158 &self,
159 req: &OffboardRequest,
160 ) -> Result<(), InvalidOffboardTxError> {
161 let offb_txout = self.offboard_tx.output.get(OFFBOARD_TX_OFFBOARD_VOUT)
162 .ok_or("missing offboard output")?;
163 let exp_txout = req.to_txout();
164
165 if exp_txout.script_pubkey != offb_txout.script_pubkey {
166 return Err(format!(
167 "offboard output scriptPubkey doesn't match: got={}, expected={}",
168 offb_txout.script_pubkey.as_bytes().as_hex(),
169 exp_txout.script_pubkey.as_bytes().as_hex(),
170 ).into());
171 }
172 if exp_txout.value != offb_txout.value {
173 return Err(format!(
174 "offboard output amount doesn't match: got={}, expected={}",
175 offb_txout.value, exp_txout.value,
176 ).into());
177 }
178
179 let conn_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
181 .ok_or("missing connector output")?;
182 let required_conn_value = P2TR_DUST * self.input_vtxos.len() as u64;
183 if conn_txout.value != required_conn_value {
184 return Err(format!(
185 "insufficient connector amount: got={}, need={}",
186 conn_txout.value, required_conn_value,
187 ).into());
188 }
189
190 Ok(())
191 }
192}
193
194impl<'a, V> OffboardForfeitContext<'a, V>
195where
196 V: AsRef<Vtxo<Full>>,
197{
198 pub fn user_sign_forfeits(
205 &self,
206 keys: &[impl Borrow<Keypair>],
207 server_nonces: &[musig::PublicNonce],
208 ) -> OffboardForfeitSignatures {
209 assert_eq!(self.input_vtxos.len(), keys.len(), "wrong number of keys");
210 assert_eq!(self.input_vtxos.len(), server_nonces.len(), "wrong number of nonces");
211 assert_ne!(self.input_vtxos.len(), 0, "no inputs");
212
213 let mut pub_nonces = Vec::with_capacity(self.input_vtxos.len());
214 let mut part_sigs = Vec::with_capacity(self.input_vtxos.len());
215 let offboard_txid = self.offboard_tx.compute_txid();
216 let connector_fanout_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
217 let connector_fanout_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
218 .expect("invalid offboard tx");
219
220 if self.input_vtxos.len() == 1 {
221 let (nonce, sig) = user_sign_vtxo_forfeit_input(
222 self.input_vtxos[0].as_ref(),
223 keys[0].borrow(),
224 connector_fanout_prev,
225 connector_fanout_txout,
226 &server_nonces[0],
227 );
228 pub_nonces.push(nonce);
229 part_sigs.push(sig);
230 } else {
231 let connector_tx = construct_multi_connector_fanout_tx(
235 connector_fanout_prev,
236 self.input_vtxos.len(),
237 &connector_fanout_txout.script_pubkey,
238 );
239 let connector_txid = connector_tx.compute_txid();
240
241 let connector_txout = TxOut {
246 script_pubkey: connector_fanout_txout.script_pubkey.clone(),
247 value: P2TR_DUST,
248 };
249 let iter = self.input_vtxos.iter().zip(keys).zip(server_nonces);
250 for (i, ((vtxo, key), server_nonce)) in iter.enumerate() {
251 let connector = OutPoint::new(connector_txid, u32::try_from(i).expect("connector index fits in u32"));
252 let (nonce, sig) = user_sign_vtxo_forfeit_input(
253 vtxo.as_ref(), key.borrow(), connector, &connector_txout, server_nonce,
254 );
255 pub_nonces.push(nonce);
256 part_sigs.push(sig);
257 }
258 }
259
260 OffboardForfeitSignatures {
261 public_nonces: pub_nonces,
262 partial_signatures: part_sigs,
263 }
264 }
265
266 pub fn finish(
271 &self,
272 server_key: &Keypair,
273 connector_key: &Keypair,
274 server_pub_nonces: &[musig::PublicNonce],
275 server_sec_nonces: Vec<musig::SecretNonce>,
276 user_pub_nonces: &[musig::PublicNonce],
277 user_partial_sigs: &[musig::PartialSignature],
278 ) -> Result<OffboardForfeitResult, InvalidUserPartialSignatureError> {
279 assert_eq!(self.input_vtxos.len(), server_pub_nonces.len());
280 assert_eq!(self.input_vtxos.len(), server_sec_nonces.len());
281 assert_eq!(self.input_vtxos.len(), user_pub_nonces.len());
282 assert_eq!(self.input_vtxos.len(), user_partial_sigs.len());
283 assert_ne!(self.input_vtxos.len(), 0, "no inputs");
284
285 let offboard_txid = self.offboard_tx.compute_txid();
286 let connector_fanout_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
287 let connector_fanout_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
288 .expect("invalid offboard tx");
289 let tweaked_connector_key = connector_key.for_keyspend_only(&*SECP);
290
291 let mut ret = OffboardForfeitResult {
292 forfeit_txs: Vec::with_capacity(self.input_vtxos.len()),
293 forfeit_vtxos: Vec::with_capacity(self.input_vtxos.len()),
294 connector_tx: None,
295 connector_vtxos: Vec::new(),
296 };
297
298 if self.input_vtxos.len() == 1 {
299 let vtxo = self.input_vtxos[0].as_ref();
300 let tx = server_check_finalize_forfeit_tx(
301 vtxo,
302 server_key,
303 &tweaked_connector_key,
304 connector_fanout_prev,
305 connector_fanout_txout,
306 (&server_pub_nonces[0], server_sec_nonces.into_iter().next().unwrap()),
307 &user_pub_nonces[0],
308 &user_partial_sigs[0],
309 ).ok_or_else(|| InvalidUserPartialSignatureError { vtxo: vtxo.id() })?;
310 ret.forfeit_vtxos = vec![construct_forfeit_vtxo(vtxo, &tx)];
311 ret.forfeit_txs.push(tx);
312 ret.connector_vtxos = vec![construct_connector_vtxo_single(vtxo, offboard_txid)];
313 } else {
314 let connector_tx = {
318 let mut tx = construct_multi_connector_fanout_tx(
319 connector_fanout_prev,
320 self.input_vtxos.len(),
321 &connector_fanout_txout.script_pubkey,
322 );
323
324 let sighash = SighashCache::new(&tx).taproot_key_spend_signature_hash(
328 0, &Prevouts::All(&[connector_fanout_txout]), TapSighashType::Default,
329 ).expect("provided the connector prevout");
330 let sig = SECP.sign_schnorr_with_aux_rand(
331 &sighash.into(), &tweaked_connector_key, &rand::random(),
332 );
333 tx.input[0].witness = Witness::from_slice(&[&sig[..]]);
334
335 tx
336 };
337 let connector_txid = connector_tx.compute_txid();
338
339 ret.connector_tx = Some(connector_tx);
340 ret.connector_vtxos = Vec::with_capacity(self.input_vtxos.len().saturating_add(1));
341 ret.connector_vtxos.push(construct_connector_vtxo_fanout_root(
342 offboard_txid,
343 self.input_vtxos.iter().map(|v| v.as_ref().expiry_height()).max().unwrap(),
344 self.input_vtxos[0].as_ref().server_pubkey(), self.input_vtxos.len(),
346 ));
347
348 let connector_txout = TxOut {
353 script_pubkey: connector_fanout_txout.script_pubkey.clone(),
354 value: P2TR_DUST,
355 };
356 let iter = self.input_vtxos.iter()
357 .zip(server_pub_nonces)
358 .zip(server_sec_nonces)
359 .zip(user_pub_nonces)
360 .zip(user_partial_sigs);
361 for (i, ((((vtxo, server_pub), server_sec), user_pub), user_part)) in iter.enumerate() {
362 let vtxo = vtxo.as_ref();
363 let connector = OutPoint::new(connector_txid, u32::try_from(i).expect("connector index fits in u32"));
364 let tx = server_check_finalize_forfeit_tx(
365 vtxo,
366 server_key,
367 &tweaked_connector_key,
368 connector,
369 &connector_txout,
370 (server_pub, server_sec),
371 user_pub,
372 user_part,
373 ).ok_or_else(|| InvalidUserPartialSignatureError { vtxo: vtxo.as_ref().id() })?;
374
375 ret.forfeit_vtxos.push(construct_forfeit_vtxo(vtxo, &tx));
376 ret.forfeit_txs.push(tx);
377 ret.connector_vtxos.push(construct_connector_vtxo_fanout_leaf(
378 vtxo, i, offboard_txid, connector_txid,
379 ));
380 }
381 }
382
383 Ok(ret)
384 }
385}
386
387fn construct_forfeit_vtxo<G>(
388 input: &Vtxo<G>,
389 forfeit_tx: &Transaction,
390) -> ServerVtxo<Bare> {
391 ServerVtxo {
392 point: OutPoint::new(forfeit_tx.compute_txid(), 0),
393 policy: ServerVtxoPolicy::ServerOwned,
394 amount: input.amount,
395 anchor_point: input.anchor_point,
396 server_pubkey: input.server_pubkey,
397 expiry_height: input.expiry_height,
398 exit_delta: input.exit_delta,
399 genesis: Bare,
400 }
401}
402
403fn construct_connector_vtxo_single<G>(
407 input: &Vtxo<G>,
408 offboard_txid: Txid,
409) -> ServerVtxo<Bare> {
410 let point = OutPoint::new(offboard_txid, 1);
411 ServerVtxo {
412 anchor_point: point.clone(),
414 point: point,
415 policy: ServerVtxoPolicy::ServerOwned,
416 amount: P2TR_DUST,
417 server_pubkey: input.server_pubkey,
418 expiry_height: input.expiry_height.checked_add(CONNECTOR_EXPIRY_DELTA as u32)
419 .expect("expiry_height + CONNECTOR_EXPIRY_DELTA fits in u32 by MAX_BLOCK_HEIGHT invariant"),
420 exit_delta: 0,
421 genesis: Bare,
422 }
423}
424
425fn construct_connector_vtxo_fanout_root(
430 offboard_txid: Txid,
431 max_expiry_height: BlockHeight,
432 server_pubkey: PublicKey,
433 nb_vtxos: usize,
434) -> ServerVtxo<Bare> {
435 let point = OutPoint::new(offboard_txid, 1);
436 ServerVtxo {
437 anchor_point: point.clone(),
439 point: point,
440 policy: ServerVtxoPolicy::ServerOwned,
441 amount: P2TR_DUST.checked_mul(nb_vtxos as u64)
442 .expect("P2TR_DUST * nb_vtxos fits in u64 by VTXO-count and dust bounds"),
443 server_pubkey: server_pubkey,
444 expiry_height: max_expiry_height.checked_add(CONNECTOR_EXPIRY_DELTA as u32)
445 .expect("max_expiry_height + CONNECTOR_EXPIRY_DELTA fits in u32 by MAX_BLOCK_HEIGHT invariant"),
446 exit_delta: 0,
447 genesis: Bare,
448 }
449}
450
451fn construct_connector_vtxo_fanout_leaf<G>(
455 input: &Vtxo<G>,
456 input_idx: usize,
457 offboard_txid: Txid,
458 connector_txid: Txid,
459) -> ServerVtxo<Bare> {
460 ServerVtxo {
461 point: OutPoint::new(connector_txid, u32::try_from(input_idx).expect("input index fits in u32")),
462 anchor_point: OutPoint::new(offboard_txid, 1),
463 policy: ServerVtxoPolicy::ServerOwned,
464 amount: P2TR_DUST,
465 server_pubkey: input.server_pubkey,
466 expiry_height: input.expiry_height.checked_add(CONNECTOR_EXPIRY_DELTA as u32)
467 .expect("expiry_height + CONNECTOR_EXPIRY_DELTA fits in u32 by MAX_BLOCK_HEIGHT invariant"),
468 exit_delta: 0,
469 genesis: Bare,
470 }
471}
472
473fn user_sign_vtxo_forfeit_input<G: Sync + Send>(
474 vtxo: &Vtxo<G>,
475 key: &Keypair,
476 connector: OutPoint,
477 connector_txout: &TxOut,
478 server_nonce: &musig::PublicNonce,
479) -> (musig::PublicNonce, musig::PartialSignature) {
480 let tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
481 let mut shc = SighashCache::new(&tx);
482 let prevouts = [&vtxo.txout(), &connector_txout];
483 let sighash = shc.taproot_key_spend_signature_hash(
484 0, &Prevouts::All(&prevouts), TapSighashType::Default,
485 ).expect("provided all prevouts");
486 let tweak = vtxo.output_taproot().tap_tweak().to_byte_array();
487 let (pub_nonce, partial_sig) = musig::deterministic_partial_sign(
488 key,
489 [vtxo.server_pubkey()],
490 &[server_nonce],
491 sighash.to_byte_array(),
492 Some(tweak),
493 );
494 debug_assert!({
495 let (key_agg, _) = musig::tweaked_key_agg(
496 [vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
497 );
498 let agg_nonce = musig::nonce_agg(&[&pub_nonce, server_nonce]);
499 let ff_session = musig::Session::new(
500 &key_agg,
501 agg_nonce,
502 &sighash.to_byte_array(),
503 );
504 ff_session.partial_verify(
505 &key_agg,
506 &partial_sig,
507 &pub_nonce,
508 musig::pubkey_to(vtxo.user_pubkey()),
509 )
510 }, "invalid partial offboard forfeit signature");
511
512 (pub_nonce, partial_sig)
513}
514
515fn server_check_finalize_forfeit_tx<G: Sync + Send>(
519 vtxo: &Vtxo<G>,
520 server_key: &Keypair,
521 tweaked_connector_key: &Keypair,
522 connector: OutPoint,
523 connector_txout: &TxOut,
524 server_nonces: (&musig::PublicNonce, musig::SecretNonce),
525 user_nonce: &musig::PublicNonce,
526 user_partial_sig: &musig::PartialSignature,
527) -> Option<Transaction> {
528 let mut tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
529 let mut shc = SighashCache::new(&tx);
530 let prevouts = [&vtxo.txout(), &connector_txout];
531 let vtxo_sig = {
532 let sighash = shc.taproot_key_spend_signature_hash(
533 0, &Prevouts::All(&prevouts), TapSighashType::Default,
534 ).expect("provided all prevouts");
535 let vtxo_taproot = vtxo.output_taproot();
536 let tweak = vtxo_taproot.tap_tweak().to_byte_array();
537 let agg_nonce = musig::nonce_agg(&[user_nonce, server_nonces.0]);
538
539 let (_our_part_sig, final_sig) = musig::partial_sign(
543 [vtxo.user_pubkey(), vtxo.server_pubkey()],
544 agg_nonce,
545 server_key,
546 server_nonces.1,
547 sighash.to_byte_array(),
548 Some(tweak),
549 Some(&[user_partial_sig]),
550 );
551 debug_assert!({
552 let (key_agg, _) = musig::tweaked_key_agg(
553 [vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
554 );
555 let ff_session = musig::Session::new(
556 &key_agg,
557 agg_nonce,
558 &sighash.to_byte_array(),
559 );
560 ff_session.partial_verify(
561 &key_agg,
562 &_our_part_sig,
563 server_nonces.0,
564 musig::pubkey_to(vtxo.server_pubkey()),
565 )
566 }, "invalid partial offboard forfeit signature");
567 let final_sig = final_sig.expect("we provided other sigs");
568 SECP.verify_schnorr(
569 &final_sig, &sighash.into(), vtxo_taproot.output_key().as_x_only_public_key(),
570 ).ok()?;
571 final_sig
572 };
573
574 let conn_sig = {
575 let sighash = shc.taproot_key_spend_signature_hash(
576 1, &Prevouts::All(&prevouts), TapSighashType::Default,
577 ).expect("provided all prevouts");
578 SECP.sign_schnorr_with_aux_rand(&sighash.into(), tweaked_connector_key, &rand::random())
579 };
580
581 tx.input[0].witness = Witness::from_slice(&[&vtxo_sig[..]]);
582 tx.input[1].witness = Witness::from_slice(&[&conn_sig[..]]);
583 debug_assert_eq!(tx,
584 create_offboard_forfeit_tx(vtxo, connector, Some(&vtxo_sig), Some(&conn_sig)),
585 );
586
587 #[cfg(test)]
588 {
589 let prevs = [vtxo.txout(), connector_txout.clone()];
590 if let Err(e) = crate::test_util::verify_tx(&prevs, 0, &tx) {
591 println!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
592 panic!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
593 }
594 }
595
596 Some(tx)
597}
598
599fn create_offboard_forfeit_tx<G: Sync + Send>(
600 vtxo: &Vtxo<G>,
601 connector: OutPoint,
602 vtxo_sig: Option<&schnorr::Signature>,
603 conn_sig: Option<&schnorr::Signature>,
604) -> Transaction {
605 Transaction {
606 version: bitcoin::transaction::Version(3),
607 lock_time: bitcoin::absolute::LockTime::ZERO,
608 input: vec![
609 TxIn {
610 previous_output: vtxo.point(),
611 sequence: Sequence::MAX,
612 script_sig: ScriptBuf::new(),
613 witness: vtxo_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
614 },
615 TxIn {
616 previous_output: connector,
617 sequence: Sequence::MAX,
618 script_sig: ScriptBuf::new(),
619 witness: conn_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
620 },
621 ],
622 output: vec![
623 TxOut {
624 value: vtxo.amount() + P2TR_DUST,
626 script_pubkey: ScriptBuf::new_p2tr(
627 &*SECP, vtxo.server_pubkey().x_only_public_key().0, None,
628 ),
629 },
630 fee::fee_anchor(),
631 ],
632 }
633}
634
635#[cfg(test)]
636mod test {
637 use std::str::FromStr;
638 use bitcoin::hex::FromHex;
639 use bitcoin::secp256k1::PublicKey;
640 use crate::test_util::dummy::{random_utxo, DummyTestVtxoSpec};
641 use super::*;
642
643 #[test]
644 fn test_offboard_forfeit() {
645 let server_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
646
647 let req_pk = PublicKey::from_str(
648 "02271fba79f590251099b07fa0393b4c55d5e50cd8fca2e2822b619f8aabf93b74",
649 ).unwrap();
650 let req = OffboardRequest {
651 script_pubkey: ScriptBuf::new_p2tr(&*SECP, req_pk.x_only_public_key().0, None),
652 net_amount: Amount::ONE_BTC,
653 deduct_fees_from_gross_amount: true,
654 fee_rate: FeeRate::from_sat_per_kwu(100),
655 };
656
657 let input1_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
658 let (_, input1) = DummyTestVtxoSpec {
659 user_keypair: input1_key,
660 server_keypair: server_key,
661 ..Default::default()
662 }.build();
663 let input2_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
664 let (_, input2) = DummyTestVtxoSpec {
665 user_keypair: input2_key,
666 server_keypair: server_key,
667 ..Default::default()
668 }.build();
669
670 let conn_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
671 let conn_spk = ScriptBuf::new_p2tr(
672 &*SECP, conn_key.public_key().x_only_public_key().0, None,
673 );
674
675 let change_amt = Amount::ONE_BTC * 2;
676 let offboard_tx = Transaction {
677 version: bitcoin::transaction::Version(3),
678 lock_time: bitcoin::absolute::LockTime::ZERO,
679 input: vec![
680 TxIn {
681 previous_output: random_utxo(),
682 sequence: Sequence::MAX,
683 script_sig: ScriptBuf::new(),
684 witness: Witness::new(),
685 },
686 ],
687 output: vec![
688 req.to_txout(),
690 TxOut {
692 script_pubkey: conn_spk.clone(),
693 value: P2TR_DUST * 2,
694 },
695 TxOut {
697 script_pubkey: ScriptBuf::from_bytes(Vec::<u8>::from_hex(
698 "512077243a077f583b197d36caac516b0c7e4319c7b6a2316c25972f44dfbf20fd09"
699 ).unwrap()),
700 value: change_amt,
701 },
702 ],
703 };
704
705 let inputs = [&input1, &input2];
706 let ctx = OffboardForfeitContext::new(&inputs, &offboard_tx);
707 ctx.validate_offboard_tx(&req).unwrap();
708
709 let (server_sec_nonces, server_pub_nonces) = (0..2).map(|_| {
710 musig::nonce_pair(&server_key)
711 }).collect::<(Vec<_>, Vec<_>)>();
712
713 let user_sigs = ctx.user_sign_forfeits(&[&input1_key, &input2_key], &server_pub_nonces);
714
715 let result = ctx.finish(
716 &server_key,
717 &conn_key,
718 &server_pub_nonces,
719 server_sec_nonces,
720 &user_sigs.public_nonces,
721 &user_sigs.partial_signatures,
722 ).unwrap();
723
724 let connector_tx = result.connector_tx.as_ref()
730 .expect("multi-input offboard must have a connector fanout tx");
731 let connector_txid = connector_tx.compute_txid();
732 for (i, (vtxo, forfeit_tx)) in inputs.iter().zip(&result.forfeit_txs).enumerate() {
733 assert_eq!(
734 forfeit_tx.input[1].previous_output,
735 OutPoint::new(connector_txid, i as u32),
736 "forfeit tx {} doesn't spend its fanout connector", i,
737 );
738 let real_prevouts = [vtxo.txout(), connector_tx.output[i].clone()];
739 crate::test_util::verify_tx(&real_prevouts, 0, forfeit_tx)
740 .expect(&format!("forfeit tx {} vtxo input invalid against real connector prevout", i));
741 crate::test_util::verify_tx(&real_prevouts, 1, forfeit_tx)
742 .expect(&format!("forfeit tx {} connector input invalid against real connector prevout", i));
743 }
744 }
745}