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