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_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
207		let connector_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_prev,
215				connector_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_prev, self.input_vtxos.len(), &connector_txout.script_pubkey,
226			);
227			let connector_txid = connector_tx.compute_txid();
228
229			// NB all connector txouts are identical, we copy the one from the offboard tx
230			let iter = self.input_vtxos.iter().zip(keys).zip(server_nonces);
231			for (i, ((vtxo, key), server_nonce)) in iter.enumerate() {
232				let connector = OutPoint::new(connector_txid, i as u32);
233				let (nonce, sig) = user_sign_vtxo_forfeit_input(
234					vtxo.as_ref(), key.borrow(), connector, connector_txout, server_nonce,
235				);
236				pub_nonces.push(nonce);
237				part_sigs.push(sig);
238			}
239		}
240
241		OffboardForfeitSignatures {
242			public_nonces: pub_nonces,
243			partial_signatures: part_sigs,
244		}
245	}
246
247	/// Check the user's partial signatures and finalize the forfeit txs
248	///
249	/// Panics if wrong number of secret nonces or partial signatures, or if [Self::validate_offboard_tx]
250	/// would have returned an error. The caller should call that method first.
251	pub fn finish(
252		&self,
253		server_key: &Keypair,
254		connector_key: &Keypair,
255		server_pub_nonces: &[musig::PublicNonce],
256		server_sec_nonces: Vec<musig::SecretNonce>,
257		user_pub_nonces: &[musig::PublicNonce],
258		user_partial_sigs: &[musig::PartialSignature],
259	) -> Result<OffboardForfeitResult, InvalidUserPartialSignatureError> {
260		assert_eq!(self.input_vtxos.len(), server_pub_nonces.len());
261		assert_eq!(self.input_vtxos.len(), server_sec_nonces.len());
262		assert_eq!(self.input_vtxos.len(), user_pub_nonces.len());
263		assert_eq!(self.input_vtxos.len(), user_partial_sigs.len());
264		assert_ne!(self.input_vtxos.len(), 0, "no inputs");
265
266		let offboard_txid = self.offboard_tx.compute_txid();
267		let connector_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
268		let connector_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
269			.expect("invalid offboard tx");
270		let tweaked_connector_key = connector_key.for_keyspend_only(&*SECP);
271
272		let mut ret = OffboardForfeitResult {
273			forfeit_txs: Vec::with_capacity(self.input_vtxos.len()),
274			forfeit_vtxos: Vec::with_capacity(self.input_vtxos.len()),
275			connector_tx: None,
276			connector_vtxos: Vec::new(),
277		};
278
279		if self.input_vtxos.len() == 1 {
280			let vtxo = self.input_vtxos[0].as_ref();
281			let tx = server_check_finalize_forfeit_tx(
282				vtxo,
283				server_key,
284				&tweaked_connector_key,
285				connector_prev,
286				connector_txout,
287				(&server_pub_nonces[0], server_sec_nonces.into_iter().next().unwrap()),
288				&user_pub_nonces[0],
289				&user_partial_sigs[0],
290			).ok_or_else(|| InvalidUserPartialSignatureError { vtxo: vtxo.id() })?;
291			ret.forfeit_vtxos = vec![construct_forfeit_vtxo(vtxo, &tx)];
292			ret.forfeit_txs.push(tx);
293			ret.connector_vtxos = vec![construct_connector_vtxo_single(vtxo, offboard_txid)];
294		} else {
295			// here we will create a deterministic intermediate connector tx and
296			// sign forfeit txs with the outputs of that tx
297
298			let connector_tx = construct_multi_connector_tx(
299				connector_prev, self.input_vtxos.len(), &connector_txout.script_pubkey,
300			);
301			let connector_txid = connector_tx.compute_txid();
302
303			ret.connector_tx = Some(connector_tx);
304			ret.connector_vtxos = Vec::with_capacity(self.input_vtxos.len() + 1);
305			ret.connector_vtxos.push(construct_connector_vtxo_fanout_root(
306				offboard_txid,
307				self.input_vtxos.iter().map(|v| v.as_ref().expiry_height()).max().unwrap(),
308				self.input_vtxos[0].as_ref().server_pubkey(), // should be the same, any will do
309				self.input_vtxos.len(),
310			));
311
312			// NB all connector txouts are identical, we copy the one from the offboard tx
313			let iter = self.input_vtxos.iter()
314				.zip(server_pub_nonces)
315				.zip(server_sec_nonces)
316				.zip(user_pub_nonces)
317				.zip(user_partial_sigs);
318			for (i, ((((vtxo, server_pub), server_sec), user_pub), user_part)) in iter.enumerate() {
319				let vtxo = vtxo.as_ref();
320				let connector = OutPoint::new(connector_txid, i as u32);
321				let tx = server_check_finalize_forfeit_tx(
322					vtxo,
323					server_key,
324					&tweaked_connector_key,
325					connector,
326					connector_txout,
327					(server_pub, server_sec),
328					user_pub,
329					user_part,
330				).ok_or_else(|| InvalidUserPartialSignatureError { vtxo: vtxo.as_ref().id() })?;
331
332				ret.forfeit_vtxos.push(construct_forfeit_vtxo(vtxo, &tx));
333				ret.forfeit_txs.push(tx);
334				ret.connector_vtxos.push(construct_connector_vtxo_fanout_leaf(
335					vtxo, i, offboard_txid, connector_txid,
336				));
337			}
338		}
339
340		Ok(ret)
341	}
342}
343
344fn construct_forfeit_vtxo<G>(
345	input: &Vtxo<G>,
346	forfeit_tx: &Transaction,
347) -> ServerVtxo<Bare> {
348	ServerVtxo {
349		point: OutPoint::new(forfeit_tx.compute_txid(), 0),
350		policy: ServerVtxoPolicy::ServerOwned,
351		amount: input.amount,
352		anchor_point: input.anchor_point,
353		server_pubkey: input.server_pubkey,
354		expiry_height: input.expiry_height,
355		exit_delta: input.exit_delta,
356		genesis: Bare,
357	}
358}
359
360/// Create the connector VTXO for the connector used to offboard a single VTXO
361///
362/// This connector is just a single 330 sat output on the offboard tx.
363fn construct_connector_vtxo_single<G>(
364	input: &Vtxo<G>,
365	offboard_txid: Txid,
366) -> ServerVtxo<Bare> {
367	let point = OutPoint::new(offboard_txid, 1);
368	ServerVtxo {
369		// NB they are the same here because this VTXO goes straight onchain
370		anchor_point: point.clone(),
371		point: point,
372		policy: ServerVtxoPolicy::ServerOwned,
373		amount: P2TR_DUST,
374		server_pubkey: input.server_pubkey,
375		expiry_height: input.expiry_height + CONNECTOR_EXPIRY_DELTA as u32,
376		exit_delta: 0,
377		genesis: Bare,
378	}
379}
380
381/// Create the connector VTXO for the fanout output into multi connector tx
382///
383/// This connector is the fanout output on the offboard tx and is spent by the fanout
384/// tx that creates a connector for each input.
385fn construct_connector_vtxo_fanout_root(
386	offboard_txid: Txid,
387	max_expiry_height: BlockHeight,
388	server_pubkey: PublicKey,
389	nb_vtxos: usize,
390) -> ServerVtxo<Bare> {
391	let point = OutPoint::new(offboard_txid, 1);
392	ServerVtxo {
393		// NB they are the same here because this VTXO goes straight onchain
394		anchor_point: point.clone(),
395		point: point,
396		policy: ServerVtxoPolicy::ServerOwned,
397		amount: P2TR_DUST * nb_vtxos as u64,
398		server_pubkey: server_pubkey,
399		expiry_height: max_expiry_height + CONNECTOR_EXPIRY_DELTA as u32,
400		exit_delta: 0,
401		genesis: Bare,
402	}
403}
404
405/// Create the connector VTXO on the connector fanout tx
406///
407/// This connector is an output of the connector fanout tx.
408fn construct_connector_vtxo_fanout_leaf<G>(
409	input: &Vtxo<G>,
410	input_idx: usize,
411	offboard_txid: Txid,
412	connector_txid: Txid,
413) -> ServerVtxo<Bare> {
414	ServerVtxo {
415		point: OutPoint::new(connector_txid, input_idx as u32),
416		anchor_point: OutPoint::new(offboard_txid, 1),
417		policy: ServerVtxoPolicy::ServerOwned,
418		amount: P2TR_DUST,
419		server_pubkey: input.server_pubkey,
420		expiry_height: input.expiry_height + CONNECTOR_EXPIRY_DELTA as u32,
421		exit_delta: 0,
422		genesis: Bare,
423	}
424}
425
426fn user_sign_vtxo_forfeit_input<G: Sync + Send>(
427	vtxo: &Vtxo<G>,
428	key: &Keypair,
429	connector: OutPoint,
430	connector_txout: &TxOut,
431	server_nonce: &musig::PublicNonce,
432) -> (musig::PublicNonce, musig::PartialSignature) {
433	let tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
434	let mut shc = SighashCache::new(&tx);
435	let prevouts = [&vtxo.txout(), &connector_txout];
436	let sighash = shc.taproot_key_spend_signature_hash(
437		0, &Prevouts::All(&prevouts), TapSighashType::Default,
438	).expect("provided all prevouts");
439	let tweak = vtxo.output_taproot().tap_tweak().to_byte_array();
440	let (pub_nonce, partial_sig) = musig::deterministic_partial_sign(
441		key,
442		[vtxo.server_pubkey()],
443		&[server_nonce],
444		sighash.to_byte_array(),
445		Some(tweak),
446	);
447	debug_assert!({
448		let (key_agg, _) = musig::tweaked_key_agg(
449			[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
450		);
451		let agg_nonce = musig::nonce_agg(&[&pub_nonce, server_nonce]);
452		let ff_session = musig::Session::new(
453			&key_agg,
454			agg_nonce,
455			&sighash.to_byte_array(),
456		);
457		ff_session.partial_verify(
458			&key_agg,
459			&partial_sig,
460			&pub_nonce,
461			musig::pubkey_to(vtxo.user_pubkey()),
462		)
463	}, "invalid partial offboard forfeit signature");
464
465	(pub_nonce, partial_sig)
466}
467
468/// Check the user's partial signature, then finalize the forfeit tx
469///
470/// Returns `None` only if the user's partial signature is invalid.
471fn server_check_finalize_forfeit_tx<G: Sync + Send>(
472	vtxo: &Vtxo<G>,
473	server_key: &Keypair,
474	tweaked_connector_key: &Keypair,
475	connector: OutPoint,
476	connector_txout: &TxOut,
477	server_nonces: (&musig::PublicNonce, musig::SecretNonce),
478	user_nonce: &musig::PublicNonce,
479	user_partial_sig: &musig::PartialSignature,
480) -> Option<Transaction> {
481	let mut tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
482	let mut shc = SighashCache::new(&tx);
483	let prevouts = [&vtxo.txout(), &connector_txout];
484	let vtxo_sig = {
485		let sighash = shc.taproot_key_spend_signature_hash(
486			0, &Prevouts::All(&prevouts), TapSighashType::Default,
487		).expect("provided all prevouts");
488		let vtxo_taproot = vtxo.output_taproot();
489		let tweak = vtxo_taproot.tap_tweak().to_byte_array();
490		let agg_nonce = musig::nonce_agg(&[user_nonce, server_nonces.0]);
491
492		// NB it is cheaper to check final schnorr signature than partial sig, so
493		// it is customary to do that insted
494
495		let (_our_part_sig, final_sig) = musig::partial_sign(
496			[vtxo.user_pubkey(), vtxo.server_pubkey()],
497			agg_nonce,
498			server_key,
499			server_nonces.1,
500			sighash.to_byte_array(),
501			Some(tweak),
502			Some(&[user_partial_sig]),
503		);
504		debug_assert!({
505			let (key_agg, _) = musig::tweaked_key_agg(
506				[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
507			);
508			let ff_session = musig::Session::new(
509				&key_agg,
510				agg_nonce,
511				&sighash.to_byte_array(),
512			);
513			ff_session.partial_verify(
514				&key_agg,
515				&_our_part_sig,
516				server_nonces.0,
517				musig::pubkey_to(vtxo.server_pubkey()),
518			)
519		}, "invalid partial offboard forfeit signature");
520		let final_sig = final_sig.expect("we provided other sigs");
521		SECP.verify_schnorr(
522			&final_sig, &sighash.into(), vtxo_taproot.output_key().as_x_only_public_key(),
523		).ok()?;
524		final_sig
525	};
526
527	let conn_sig = {
528		let sighash = shc.taproot_key_spend_signature_hash(
529			1, &Prevouts::All(&prevouts), TapSighashType::Default,
530		).expect("provided all prevouts");
531		SECP.sign_schnorr_with_aux_rand(&sighash.into(), tweaked_connector_key, &rand::random())
532	};
533
534	tx.input[0].witness = Witness::from_slice(&[&vtxo_sig[..]]);
535	tx.input[1].witness = Witness::from_slice(&[&conn_sig[..]]);
536	debug_assert_eq!(tx,
537		create_offboard_forfeit_tx(vtxo, connector, Some(&vtxo_sig), Some(&conn_sig)),
538	);
539
540	#[cfg(test)]
541	{
542		let prevs = [vtxo.txout(), connector_txout.clone()];
543		if let Err(e) = crate::test_util::verify_tx(&prevs, 0, &tx) {
544			println!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
545			panic!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
546		}
547	}
548
549	Some(tx)
550}
551
552fn create_offboard_forfeit_tx<G: Sync + Send>(
553	vtxo: &Vtxo<G>,
554	connector: OutPoint,
555	vtxo_sig: Option<&schnorr::Signature>,
556	conn_sig: Option<&schnorr::Signature>,
557) -> Transaction {
558	Transaction {
559		version: bitcoin::transaction::Version(3),
560		lock_time: bitcoin::absolute::LockTime::ZERO,
561		input: vec![
562			TxIn {
563				previous_output: vtxo.point(),
564				sequence: Sequence::MAX,
565				script_sig: ScriptBuf::new(),
566				witness: vtxo_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
567			},
568			TxIn {
569				previous_output: connector,
570				sequence: Sequence::MAX,
571				script_sig: ScriptBuf::new(),
572				witness: conn_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
573			},
574		],
575		output: vec![
576			TxOut {
577				// also accumulate the connector dust
578				value: vtxo.amount() + P2TR_DUST,
579				script_pubkey: ScriptBuf::new_p2tr(
580					&*SECP, vtxo.server_pubkey().x_only_public_key().0, None,
581				),
582			},
583			fee::fee_anchor(),
584		],
585	}
586}
587
588#[cfg(test)]
589mod test {
590	use std::str::FromStr;
591	use bitcoin::hex::FromHex;
592	use bitcoin::secp256k1::PublicKey;
593	use crate::test_util::dummy::{random_utxo, DummyTestVtxoSpec};
594	use super::*;
595
596	#[test]
597	fn test_offboard_forfeit() {
598		let server_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
599
600		let req_pk = PublicKey::from_str(
601			"02271fba79f590251099b07fa0393b4c55d5e50cd8fca2e2822b619f8aabf93b74",
602		).unwrap();
603		let req = OffboardRequest {
604			script_pubkey: ScriptBuf::new_p2tr(&*SECP, req_pk.x_only_public_key().0, None),
605			net_amount: Amount::ONE_BTC,
606			deduct_fees_from_gross_amount: true,
607			fee_rate: FeeRate::from_sat_per_kwu(100),
608		};
609
610		let input1_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
611		let (_, input1) = DummyTestVtxoSpec {
612			user_keypair: input1_key,
613			server_keypair: server_key,
614			..Default::default()
615		}.build();
616		let input2_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
617		let (_, input2) = DummyTestVtxoSpec {
618			user_keypair: input2_key,
619			server_keypair: server_key,
620			..Default::default()
621		}.build();
622
623		let conn_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
624		let conn_spk = ScriptBuf::new_p2tr(
625			&*SECP, conn_key.public_key().x_only_public_key().0, None,
626		);
627
628		let change_amt = Amount::ONE_BTC * 2;
629		let offboard_tx = Transaction {
630			version: bitcoin::transaction::Version(3),
631			lock_time: bitcoin::absolute::LockTime::ZERO,
632			input: vec![
633				TxIn {
634					previous_output: random_utxo(),
635					sequence: Sequence::MAX,
636					script_sig: ScriptBuf::new(),
637					witness: Witness::new(),
638				},
639			],
640			output: vec![
641				// the delivery goes first
642				req.to_txout(),
643				// then a connector
644				TxOut {
645					script_pubkey: conn_spk.clone(),
646					value: P2TR_DUST * 2,
647				},
648				// then maybe change
649				TxOut {
650					script_pubkey: ScriptBuf::from_bytes(Vec::<u8>::from_hex(
651						"512077243a077f583b197d36caac516b0c7e4319c7b6a2316c25972f44dfbf20fd09"
652					).unwrap()),
653					value: change_amt,
654				},
655			],
656		};
657
658		let inputs = [&input1, &input2];
659		let ctx = OffboardForfeitContext::new(&inputs, &offboard_tx);
660		ctx.validate_offboard_tx(&req).unwrap();
661
662		let (server_sec_nonces, server_pub_nonces) = (0..2).map(|_| {
663			musig::nonce_pair(&server_key)
664		}).collect::<(Vec<_>, Vec<_>)>();
665
666		let user_sigs = ctx.user_sign_forfeits(&[&input1_key, &input2_key], &server_pub_nonces);
667
668		ctx.finish(
669			&server_key,
670			&conn_key,
671			&server_pub_nonces,
672			server_sec_nonces,
673			&user_sigs.public_nonces,
674			&user_sigs.partial_signatures,
675		).unwrap();
676	}
677}