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,
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
30/// The output index of the offboard output in the offboard tx
31pub const OFFBOARD_TX_OFFBOARD_VOUT: usize = 0;
32/// The output index of the connector output in the offboard tx
33pub const OFFBOARD_TX_CONNECTOR_VOUT: usize = 1;
34
35/// Additional number of blocks after the input VTXO expiry we wait to sweep connectors
36const 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/// Contains information regarding an offboard that a client would like to perform.
50#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
51pub struct OffboardRequest {
52	/// The destination for the [TxOut].
53	#[serde(with = "bitcoin_ext::serde::encodable")]
54	pub script_pubkey: ScriptBuf,
55	/// The target amount in sats.
56	#[serde(rename = "amount_sat", with = "bitcoin::amount::serde::as_sat")]
57	pub net_amount: Amount,
58	/// Determines whether fees should be added onto the given amount or deducted from the gross
59	/// amount.
60	pub deduct_fees_from_gross_amount: bool,
61	/// What fee rate was used when calculating the fee for the offboard.
62	#[serde(rename = "fee_rate_kwu")]
63	pub fee_rate: FeeRate,
64}
65
66impl OffboardRequest {
67	/// Validate that the offboard has a valid script.
68	pub fn validate(&self) -> Result<(), InvalidOffboardRequestError> {
69		Ok(self.to_txout().check_standard()?)
70	}
71
72	/// Convert into a tx output.
73	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		// We need:
122		// - each input vtxo spent by forfeit
123		// for not single:
124		// - connector root output spent by connector tx
125
126		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
143/// Construction and validation work with any VTXO representation: they
144/// only need the number of inputs, so the client can validate a prepared
145/// offboard tx with bare wallet vtxos. Only signing and finishing need
146/// the full form; those methods are bounded on `AsRef<Vtxo<Full>>` below.
147impl<'a, V> OffboardForfeitContext<'a, V> {
148	/// Create a new [OffboardForfeitContext] with given input VTXOs and offboard tx
149	///
150	/// Number of input VTXOs must not be zero.
151	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	/// Validate offboard tx matches offboard request
157	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		// for the user we only need to check that there are enough connectors
180		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	/// Sign forfeit transactions for all input VTXOs
199	///
200	/// Provide the keys for the VTXO pubkeys in order of the input VTXOs.
201	///
202	/// Panics if wrong number of keys or nonces, or if [Self::validate_offboard_tx]
203	/// would have returned an error. The caller should call that method first.
204	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			// here we will create a deterministic intermediate connector tx and
232			// sign forfeit txs with the outputs of that tx
233
234			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			// The forfeit txs spend the connector tx's outputs, which carry a
242			// single dust each; the offboard tx's connector output carries the
243			// combined sum. The sighash commits to the prevout values, so we
244			// must use the actual connector tx output here.
245			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	/// Check the user's partial signatures and finalize the forfeit txs
267	///
268	/// Panics if wrong number of secret nonces or partial signatures, or if [Self::validate_offboard_tx]
269	/// would have returned an error. The caller should call that method first.
270	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			// here we will create a deterministic intermediate connector tx and
315			// sign forfeit txs with the outputs of that tx
316
317			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				// The connector fanout tx spends the offboard's connector output, a
325				// key-path-only p2tr for the connector key. Sign it; otherwise it would
326				// be stored/broadcast with an empty witness and rejected by the mempool.
327				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(), // should be the same, any will do
345				self.input_vtxos.len(),
346			));
347
348			// The forfeit txs spend the connector tx's outputs, which carry a
349			// single dust each; the offboard tx's connector output carries the
350			// combined sum. The sighash commits to the prevout values, so we
351			// must use the actual connector tx output here.
352			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
403/// Create the connector VTXO for the connector used to offboard a single VTXO
404///
405/// This connector is just a single 330 sat output on the offboard tx.
406fn 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		// NB they are the same here because this VTXO goes straight onchain
413		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
425/// Create the connector VTXO for the fanout output into multi connector tx
426///
427/// This connector is the fanout output on the offboard tx and is spent by the fanout
428/// tx that creates a connector for each input.
429fn 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		// NB they are the same here because this VTXO goes straight onchain
438		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
451/// Create the connector VTXO on the connector fanout tx
452///
453/// This connector is an output of the connector fanout tx.
454fn 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
515/// Check the user's partial signature, then finalize the forfeit tx
516///
517/// Returns `None` only if the user's partial signature is invalid.
518fn 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		// NB it is cheaper to check final schnorr signature than partial sig, so
540		// it is customary to do that insted
541
542		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				// also accumulate the connector dust
625				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				// the delivery goes first
689				req.to_txout(),
690				// then a connector
691				TxOut {
692					script_pubkey: conn_spk.clone(),
693					value: P2TR_DUST * 2,
694				},
695				// then maybe change
696				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		// The forfeit txs must be valid against the prevouts that will actually
725		// exist on-chain: each spends an output of the connector fanout tx,
726		// which carries a single dust, not the combined fanout root output on
727		// the offboard tx. Taproot sighashes commit to all prevout amounts, so
728		// signing against the wrong value makes the forfeits consensus-invalid.
729		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}