Skip to main content

ark/
offboard.rs

1//!
2//! # Offboard mechanism using connector-swaps
3//!
4//!
5//! ## Connector VTXOs
6//!
7//! We create internal "ServerVtxo"s for the connector outputs. Because they must not
8//! be swept before they are no longer required (i.e. when the input VTXO expires),
9//! we use the expiry height on the VTXO to indicate when they can be swept.
10//!
11
12use 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
29/// The output index of the offboard output in the offboard tx
30pub const OFFBOARD_TX_OFFBOARD_VOUT: usize = 0;
31/// The output index of the connector output in the offboard tx
32pub const OFFBOARD_TX_CONNECTOR_VOUT: usize = 1;
33
34/// Additional number of blocks after the input VTXO expiry we wait to sweep connectors
35const 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/// Contains information regarding an offboard that a client would like to perform.
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
44pub struct OffboardRequest {
45	/// The destination for the [TxOut].
46	#[serde(with = "bitcoin_ext::serde::encodable")]
47	pub script_pubkey: ScriptBuf,
48	/// The target amount in sats.
49	#[serde(rename = "amount_sat", with = "bitcoin::amount::serde::as_sat")]
50	pub net_amount: Amount,
51	/// Determines whether fees should be added onto the given amount or deducted from the gross
52	/// amount.
53	pub deduct_fees_from_gross_amount: bool,
54	/// What fee rate was used when calculating the fee for the offboard.
55	#[serde(rename = "fee_rate_kwu")]
56	pub fee_rate: FeeRate,
57}
58
59impl OffboardRequest {
60	/// Validate that the offboard has a valid script.
61	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	/// Convert into a tx output.
69	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		// We need:
118		// - each input vtxo spent by forfeit
119		// for not single:
120		// - connector root output spent by connector tx
121
122		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	/// Create a new [OffboardForfeitContext] with given input VTXOs and offboard tx
144	///
145	/// Number of input VTXOs must not be zero.
146	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	/// Validate offboard tx matches offboard request
152	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		// for the user we only need to check that there are enough connectors
175		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	/// Sign forfeit transactions for all input VTXOs
189	///
190	/// Provide the keys for the VTXO pubkeys in order of the input VTXOs.
191	///
192	/// Panics if wrong number of keys or nonces, or if [Self::validate_offboard_tx]
193	/// would have returned an error. The caller should call that method first.
194	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			// here we will create a deterministic intermediate connector tx and
222			// sign forfeit txs with the outputs of that tx
223
224			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			// The forfeit txs spend the connector tx's outputs, which carry a
230			// single dust each; the offboard tx's connector output carries the
231			// combined sum. The sighash commits to the prevout values, so we
232			// must use the actual connector tx output here.
233			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	/// Check the user's partial signatures and finalize the forfeit txs
255	///
256	/// Panics if wrong number of secret nonces or partial signatures, or if [Self::validate_offboard_tx]
257	/// would have returned an error. The caller should call that method first.
258	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			// here we will create a deterministic intermediate connector tx and
303			// sign forfeit txs with the outputs of that tx
304
305			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				// The connector fanout tx spends the offboard's connector output, a
313				// key-path-only p2tr for the connector key. Sign it; otherwise it would
314				// be stored/broadcast with an empty witness and rejected by the mempool.
315				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(), // should be the same, any will do
333				self.input_vtxos.len(),
334			));
335
336			// The forfeit txs spend the connector tx's outputs, which carry a
337			// single dust each; the offboard tx's connector output carries the
338			// combined sum. The sighash commits to the prevout values, so we
339			// must use the actual connector tx output here.
340			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
391/// Create the connector VTXO for the connector used to offboard a single VTXO
392///
393/// This connector is just a single 330 sat output on the offboard tx.
394fn 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		// NB they are the same here because this VTXO goes straight onchain
401		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
413/// Create the connector VTXO for the fanout output into multi connector tx
414///
415/// This connector is the fanout output on the offboard tx and is spent by the fanout
416/// tx that creates a connector for each input.
417fn 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		// NB they are the same here because this VTXO goes straight onchain
426		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
439/// Create the connector VTXO on the connector fanout tx
440///
441/// This connector is an output of the connector fanout tx.
442fn 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
503/// Check the user's partial signature, then finalize the forfeit tx
504///
505/// Returns `None` only if the user's partial signature is invalid.
506fn 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		// NB it is cheaper to check final schnorr signature than partial sig, so
528		// it is customary to do that insted
529
530		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				// also accumulate the connector dust
613				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				// the delivery goes first
677				req.to_txout(),
678				// then a connector
679				TxOut {
680					script_pubkey: conn_spk.clone(),
681					value: P2TR_DUST * 2,
682				},
683				// then maybe change
684				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		// The forfeit txs must be valid against the prevouts that will actually
713		// exist on-chain: each spends an output of the connector fanout tx,
714		// which carries a single dust, not the combined fanout root output on
715		// the offboard tx. Taproot sighashes commit to all prevout amounts, so
716		// signing against the wrong value makes the forfeits consensus-invalid.
717		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}