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().saturating_add(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.checked_add(CONNECTOR_EXPIRY_DELTA as u32)
376			.expect("expiry_height + CONNECTOR_EXPIRY_DELTA fits in u32 by MAX_BLOCK_HEIGHT invariant"),
377		exit_delta: 0,
378		genesis: Bare,
379	}
380}
381
382/// Create the connector VTXO for the fanout output into multi connector tx
383///
384/// This connector is the fanout output on the offboard tx and is spent by the fanout
385/// tx that creates a connector for each input.
386fn construct_connector_vtxo_fanout_root(
387	offboard_txid: Txid,
388	max_expiry_height: BlockHeight,
389	server_pubkey: PublicKey,
390	nb_vtxos: usize,
391) -> ServerVtxo<Bare> {
392	let point = OutPoint::new(offboard_txid, 1);
393	ServerVtxo {
394		// NB they are the same here because this VTXO goes straight onchain
395		anchor_point: point.clone(),
396		point: point,
397		policy: ServerVtxoPolicy::ServerOwned,
398		amount: P2TR_DUST.checked_mul(nb_vtxos as u64)
399			.expect("P2TR_DUST * nb_vtxos fits in u64 by VTXO-count and dust bounds"),
400		server_pubkey: server_pubkey,
401		expiry_height: max_expiry_height.checked_add(CONNECTOR_EXPIRY_DELTA as u32)
402			.expect("max_expiry_height + CONNECTOR_EXPIRY_DELTA fits in u32 by MAX_BLOCK_HEIGHT invariant"),
403		exit_delta: 0,
404		genesis: Bare,
405	}
406}
407
408/// Create the connector VTXO on the connector fanout tx
409///
410/// This connector is an output of the connector fanout tx.
411fn construct_connector_vtxo_fanout_leaf<G>(
412	input: &Vtxo<G>,
413	input_idx: usize,
414	offboard_txid: Txid,
415	connector_txid: Txid,
416) -> ServerVtxo<Bare> {
417	ServerVtxo {
418		point: OutPoint::new(connector_txid, input_idx as u32),
419		anchor_point: OutPoint::new(offboard_txid, 1),
420		policy: ServerVtxoPolicy::ServerOwned,
421		amount: P2TR_DUST,
422		server_pubkey: input.server_pubkey,
423		expiry_height: input.expiry_height.checked_add(CONNECTOR_EXPIRY_DELTA as u32)
424			.expect("expiry_height + CONNECTOR_EXPIRY_DELTA fits in u32 by MAX_BLOCK_HEIGHT invariant"),
425		exit_delta: 0,
426		genesis: Bare,
427	}
428}
429
430fn user_sign_vtxo_forfeit_input<G: Sync + Send>(
431	vtxo: &Vtxo<G>,
432	key: &Keypair,
433	connector: OutPoint,
434	connector_txout: &TxOut,
435	server_nonce: &musig::PublicNonce,
436) -> (musig::PublicNonce, musig::PartialSignature) {
437	let tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
438	let mut shc = SighashCache::new(&tx);
439	let prevouts = [&vtxo.txout(), &connector_txout];
440	let sighash = shc.taproot_key_spend_signature_hash(
441		0, &Prevouts::All(&prevouts), TapSighashType::Default,
442	).expect("provided all prevouts");
443	let tweak = vtxo.output_taproot().tap_tweak().to_byte_array();
444	let (pub_nonce, partial_sig) = musig::deterministic_partial_sign(
445		key,
446		[vtxo.server_pubkey()],
447		&[server_nonce],
448		sighash.to_byte_array(),
449		Some(tweak),
450	);
451	debug_assert!({
452		let (key_agg, _) = musig::tweaked_key_agg(
453			[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
454		);
455		let agg_nonce = musig::nonce_agg(&[&pub_nonce, server_nonce]);
456		let ff_session = musig::Session::new(
457			&key_agg,
458			agg_nonce,
459			&sighash.to_byte_array(),
460		);
461		ff_session.partial_verify(
462			&key_agg,
463			&partial_sig,
464			&pub_nonce,
465			musig::pubkey_to(vtxo.user_pubkey()),
466		)
467	}, "invalid partial offboard forfeit signature");
468
469	(pub_nonce, partial_sig)
470}
471
472/// Check the user's partial signature, then finalize the forfeit tx
473///
474/// Returns `None` only if the user's partial signature is invalid.
475fn server_check_finalize_forfeit_tx<G: Sync + Send>(
476	vtxo: &Vtxo<G>,
477	server_key: &Keypair,
478	tweaked_connector_key: &Keypair,
479	connector: OutPoint,
480	connector_txout: &TxOut,
481	server_nonces: (&musig::PublicNonce, musig::SecretNonce),
482	user_nonce: &musig::PublicNonce,
483	user_partial_sig: &musig::PartialSignature,
484) -> Option<Transaction> {
485	let mut tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
486	let mut shc = SighashCache::new(&tx);
487	let prevouts = [&vtxo.txout(), &connector_txout];
488	let vtxo_sig = {
489		let sighash = shc.taproot_key_spend_signature_hash(
490			0, &Prevouts::All(&prevouts), TapSighashType::Default,
491		).expect("provided all prevouts");
492		let vtxo_taproot = vtxo.output_taproot();
493		let tweak = vtxo_taproot.tap_tweak().to_byte_array();
494		let agg_nonce = musig::nonce_agg(&[user_nonce, server_nonces.0]);
495
496		// NB it is cheaper to check final schnorr signature than partial sig, so
497		// it is customary to do that insted
498
499		let (_our_part_sig, final_sig) = musig::partial_sign(
500			[vtxo.user_pubkey(), vtxo.server_pubkey()],
501			agg_nonce,
502			server_key,
503			server_nonces.1,
504			sighash.to_byte_array(),
505			Some(tweak),
506			Some(&[user_partial_sig]),
507		);
508		debug_assert!({
509			let (key_agg, _) = musig::tweaked_key_agg(
510				[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
511			);
512			let ff_session = musig::Session::new(
513				&key_agg,
514				agg_nonce,
515				&sighash.to_byte_array(),
516			);
517			ff_session.partial_verify(
518				&key_agg,
519				&_our_part_sig,
520				server_nonces.0,
521				musig::pubkey_to(vtxo.server_pubkey()),
522			)
523		}, "invalid partial offboard forfeit signature");
524		let final_sig = final_sig.expect("we provided other sigs");
525		SECP.verify_schnorr(
526			&final_sig, &sighash.into(), vtxo_taproot.output_key().as_x_only_public_key(),
527		).ok()?;
528		final_sig
529	};
530
531	let conn_sig = {
532		let sighash = shc.taproot_key_spend_signature_hash(
533			1, &Prevouts::All(&prevouts), TapSighashType::Default,
534		).expect("provided all prevouts");
535		SECP.sign_schnorr_with_aux_rand(&sighash.into(), tweaked_connector_key, &rand::random())
536	};
537
538	tx.input[0].witness = Witness::from_slice(&[&vtxo_sig[..]]);
539	tx.input[1].witness = Witness::from_slice(&[&conn_sig[..]]);
540	debug_assert_eq!(tx,
541		create_offboard_forfeit_tx(vtxo, connector, Some(&vtxo_sig), Some(&conn_sig)),
542	);
543
544	#[cfg(test)]
545	{
546		let prevs = [vtxo.txout(), connector_txout.clone()];
547		if let Err(e) = crate::test_util::verify_tx(&prevs, 0, &tx) {
548			println!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
549			panic!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
550		}
551	}
552
553	Some(tx)
554}
555
556fn create_offboard_forfeit_tx<G: Sync + Send>(
557	vtxo: &Vtxo<G>,
558	connector: OutPoint,
559	vtxo_sig: Option<&schnorr::Signature>,
560	conn_sig: Option<&schnorr::Signature>,
561) -> Transaction {
562	Transaction {
563		version: bitcoin::transaction::Version(3),
564		lock_time: bitcoin::absolute::LockTime::ZERO,
565		input: vec![
566			TxIn {
567				previous_output: vtxo.point(),
568				sequence: Sequence::MAX,
569				script_sig: ScriptBuf::new(),
570				witness: vtxo_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
571			},
572			TxIn {
573				previous_output: connector,
574				sequence: Sequence::MAX,
575				script_sig: ScriptBuf::new(),
576				witness: conn_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
577			},
578		],
579		output: vec![
580			TxOut {
581				// also accumulate the connector dust
582				value: vtxo.amount() + P2TR_DUST,
583				script_pubkey: ScriptBuf::new_p2tr(
584					&*SECP, vtxo.server_pubkey().x_only_public_key().0, None,
585				),
586			},
587			fee::fee_anchor(),
588		],
589	}
590}
591
592#[cfg(test)]
593mod test {
594	use std::str::FromStr;
595	use bitcoin::hex::FromHex;
596	use bitcoin::secp256k1::PublicKey;
597	use crate::test_util::dummy::{random_utxo, DummyTestVtxoSpec};
598	use super::*;
599
600	#[test]
601	fn test_offboard_forfeit() {
602		let server_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
603
604		let req_pk = PublicKey::from_str(
605			"02271fba79f590251099b07fa0393b4c55d5e50cd8fca2e2822b619f8aabf93b74",
606		).unwrap();
607		let req = OffboardRequest {
608			script_pubkey: ScriptBuf::new_p2tr(&*SECP, req_pk.x_only_public_key().0, None),
609			net_amount: Amount::ONE_BTC,
610			deduct_fees_from_gross_amount: true,
611			fee_rate: FeeRate::from_sat_per_kwu(100),
612		};
613
614		let input1_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
615		let (_, input1) = DummyTestVtxoSpec {
616			user_keypair: input1_key,
617			server_keypair: server_key,
618			..Default::default()
619		}.build();
620		let input2_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
621		let (_, input2) = DummyTestVtxoSpec {
622			user_keypair: input2_key,
623			server_keypair: server_key,
624			..Default::default()
625		}.build();
626
627		let conn_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
628		let conn_spk = ScriptBuf::new_p2tr(
629			&*SECP, conn_key.public_key().x_only_public_key().0, None,
630		);
631
632		let change_amt = Amount::ONE_BTC * 2;
633		let offboard_tx = Transaction {
634			version: bitcoin::transaction::Version(3),
635			lock_time: bitcoin::absolute::LockTime::ZERO,
636			input: vec![
637				TxIn {
638					previous_output: random_utxo(),
639					sequence: Sequence::MAX,
640					script_sig: ScriptBuf::new(),
641					witness: Witness::new(),
642				},
643			],
644			output: vec![
645				// the delivery goes first
646				req.to_txout(),
647				// then a connector
648				TxOut {
649					script_pubkey: conn_spk.clone(),
650					value: P2TR_DUST * 2,
651				},
652				// then maybe change
653				TxOut {
654					script_pubkey: ScriptBuf::from_bytes(Vec::<u8>::from_hex(
655						"512077243a077f583b197d36caac516b0c7e4319c7b6a2316c25972f44dfbf20fd09"
656					).unwrap()),
657					value: change_amt,
658				},
659			],
660		};
661
662		let inputs = [&input1, &input2];
663		let ctx = OffboardForfeitContext::new(&inputs, &offboard_tx);
664		ctx.validate_offboard_tx(&req).unwrap();
665
666		let (server_sec_nonces, server_pub_nonces) = (0..2).map(|_| {
667			musig::nonce_pair(&server_key)
668		}).collect::<(Vec<_>, Vec<_>)>();
669
670		let user_sigs = ctx.user_sign_forfeits(&[&input1_key, &input2_key], &server_pub_nonces);
671
672		ctx.finish(
673			&server_key,
674			&conn_key,
675			&server_pub_nonces,
676			server_sec_nonces,
677			&user_sigs.public_nonces,
678			&user_sigs.partial_signatures,
679		).unwrap();
680	}
681}