ark/
offboard.rs

1
2use std::borrow::Borrow;
3
4use bitcoin::{
5	Amount, FeeRate, OutPoint, ScriptBuf, Sequence, TapSighashType, Transaction, TxIn, TxOut,
6	Witness,
7};
8use bitcoin::hashes::Hash;
9use bitcoin::hex::DisplayHex;
10use bitcoin::secp256k1::{Keypair, schnorr};
11use bitcoin::sighash::{Prevouts, SighashCache};
12
13use bitcoin_ext::{fee, KeypairExt, TxOutExt, P2TR_DUST};
14
15use crate::connectors::construct_multi_connector_tx;
16use crate::vtxo::Full;
17use crate::{musig, Vtxo, VtxoId, SECP};
18
19
20/// The output index of the offboard output in the offboard tx
21pub const OFFBOARD_TX_OFFBOARD_VOUT: usize = 0;
22/// The output index of the connector output in the offboard tx
23pub const OFFBOARD_TX_CONNECTOR_VOUT: usize = 1;
24
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
27#[error("invalid offboard request: {0}")]
28pub struct InvalidOffboardRequestError(&'static str);
29
30/// Contains information regarding an offboard that a client would like to perform.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
32pub struct OffboardRequest {
33	/// The destination for the [TxOut].
34	#[serde(with = "bitcoin_ext::serde::encodable")]
35	pub script_pubkey: ScriptBuf,
36	/// The target amount in sats.
37	#[serde(rename = "amount_sat", with = "bitcoin::amount::serde::as_sat")]
38	pub net_amount: Amount,
39	/// Determines whether fees should be added onto the given amount or deducted from the gross
40	/// amount.
41	pub deduct_fees_from_gross_amount: bool,
42	/// What fee rate was used when calculating the fee for the offboard.
43	#[serde(rename = "fee_rate_kwu")]
44	pub fee_rate: FeeRate,
45}
46
47impl OffboardRequest {
48	/// Validate that the offboard has a valid script.
49	pub fn validate(&self) -> Result<(), InvalidOffboardRequestError> {
50		if !self.to_txout().is_standard() {
51			return Err(InvalidOffboardRequestError("non-standard output"));
52		}
53		Ok(())
54	}
55
56	/// Convert into a tx output.
57	pub fn to_txout(&self) -> TxOut {
58		TxOut {
59			script_pubkey: self.script_pubkey.clone(),
60			value: self.net_amount,
61		}
62	}
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
66#[error("invalid offboard transaction: {0}")]
67pub struct InvalidOffboardTxError(String);
68
69impl<S: Into<String>> From<S> for InvalidOffboardTxError {
70	fn from(v: S) -> Self {
71	    Self(v.into())
72	}
73}
74
75impl From<InvalidOffboardRequestError> for InvalidOffboardTxError {
76	fn from(e: InvalidOffboardRequestError) -> Self {
77		InvalidOffboardTxError(format!("invalid offboard request: {:#}", e))
78	}
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
82#[error("invalid partial signature for VTXO {vtxo}")]
83pub struct InvalidUserPartialSignatureError {
84	pub vtxo: VtxoId,
85}
86
87pub struct OffboardForfeitSignatures {
88	pub public_nonces: Vec<musig::PublicNonce>,
89	pub partial_signatures: Vec<musig::PartialSignature>,
90}
91
92pub struct OffboardForfeitContext<'a, V> {
93	input_vtxos: &'a [V],
94	offboard_tx: &'a Transaction,
95}
96
97impl<'a, V> OffboardForfeitContext<'a, V>
98where
99	V: AsRef<Vtxo<Full>>,
100{
101	/// Create a new [OffboardForfeitContext] with given input VTXOs and offboard tx
102	///
103	/// Number of input VTXOs must not be zero.
104	pub fn new(input_vtxos: &'a [V], offboard_tx: &'a Transaction) -> Self {
105		assert_ne!(input_vtxos.len(), 0, "no input VTXOs");
106		Self { input_vtxos, offboard_tx }
107	}
108
109	/// Validate offboard tx matches offboard request
110	pub fn validate_offboard_tx(
111		&self,
112		req: &OffboardRequest,
113	) -> Result<(), InvalidOffboardTxError> {
114		let offb_txout = self.offboard_tx.output.get(OFFBOARD_TX_OFFBOARD_VOUT)
115			.ok_or("missing offboard output")?;
116		let exp_txout = req.to_txout();
117
118		if exp_txout.script_pubkey != offb_txout.script_pubkey {
119			return Err(format!(
120				"offboard output scriptPubkey doesn't match: got={}, expected={}",
121				offb_txout.script_pubkey.as_bytes().as_hex(),
122				exp_txout.script_pubkey.as_bytes().as_hex(),
123			).into());
124		}
125		if exp_txout.value != offb_txout.value {
126			return Err(format!(
127				"offboard output amount doesn't match: got={}, expected={}",
128				offb_txout.value, exp_txout.value,
129			).into());
130		}
131
132		// for the user we only need to check that there are enough connectors
133		let conn_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
134			.ok_or("missing connector output")?;
135		let required_conn_value = P2TR_DUST * self.input_vtxos.len() as u64;
136		if conn_txout.value != required_conn_value {
137			return Err(format!(
138				"insufficient connector amount: got={}, need={}",
139				conn_txout.value, required_conn_value,
140			).into());
141		}
142
143		Ok(())
144	}
145
146	/// Sign forfeit transactions for all input VTXOs
147	///
148	/// Provide the keys for the VTXO pubkeys in order of the input VTXOs.
149	///
150	/// Panics if wrong number of keys or nonces, or if [Self::validate_offboard_tx]
151	/// would have returned an error. The caller should call that method first.
152	pub fn user_sign_forfeits(
153		&self,
154		keys: &[impl Borrow<Keypair>],
155		server_nonces: &[musig::PublicNonce],
156	) -> OffboardForfeitSignatures {
157		assert_eq!(self.input_vtxos.len(), keys.len(), "wrong number of keys");
158		assert_eq!(self.input_vtxos.len(), server_nonces.len(), "wrong number of nonces");
159		assert_ne!(self.input_vtxos.len(), 0, "no inputs");
160
161		let mut pub_nonces = Vec::with_capacity(self.input_vtxos.len());
162		let mut part_sigs = Vec::with_capacity(self.input_vtxos.len());
163		let offboard_txid = self.offboard_tx.compute_txid();
164		let connector_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
165		let connector_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
166			.expect("invalid offboard tx");
167
168		if self.input_vtxos.len() == 1 {
169			let (nonce, sig) = user_sign_vtxo_forfeit_input(
170				self.input_vtxos[0].as_ref(),
171				keys[0].borrow(),
172				connector_prev,
173				connector_txout,
174				&server_nonces[0],
175			);
176			pub_nonces.push(nonce);
177			part_sigs.push(sig);
178		} else {
179			// here we will create a deterministic intermediate connector tx and
180			// sign forfeit txs with the outputs of that tx
181
182			let connector_tx = construct_multi_connector_tx(
183				connector_prev, self.input_vtxos.len(), &connector_txout.script_pubkey,
184			);
185			let connector_txid = connector_tx.compute_txid();
186
187			// NB all connector txouts are identical, we copy the one from the offboard tx
188			let iter = self.input_vtxos.iter().zip(keys).zip(server_nonces);
189			for (i, ((vtxo, key), server_nonce)) in iter.enumerate() {
190				let connector = OutPoint::new(connector_txid, i as u32);
191				let (nonce, sig) = user_sign_vtxo_forfeit_input(
192					vtxo.as_ref(), key.borrow(), connector, connector_txout, server_nonce,
193				);
194				pub_nonces.push(nonce);
195				part_sigs.push(sig);
196			}
197		}
198
199		OffboardForfeitSignatures {
200			public_nonces: pub_nonces,
201			partial_signatures: part_sigs,
202		}
203	}
204
205	/// Check the user's partial signatures and finalize the forfeit txs
206	///
207	/// Panics if wrong number of secret nonces or partial signatures, or if [Self::validate_offboard_tx]
208	/// would have returned an error. The caller should call that method first.
209	pub fn check_finalize_transactions(
210		&self,
211		server_key: &Keypair,
212		connector_key: &Keypair,
213		server_pub_nonces: &[musig::PublicNonce],
214		server_sec_nonces: Vec<musig::SecretNonce>,
215		user_pub_nonces: &[musig::PublicNonce],
216		user_partial_sigs: &[musig::PartialSignature],
217	) -> Result<Vec<Transaction>, InvalidUserPartialSignatureError> {
218		assert_eq!(self.input_vtxos.len(), server_pub_nonces.len());
219		assert_eq!(self.input_vtxos.len(), server_sec_nonces.len());
220		assert_eq!(self.input_vtxos.len(), user_pub_nonces.len());
221		assert_eq!(self.input_vtxos.len(), user_partial_sigs.len());
222		assert_ne!(self.input_vtxos.len(), 0, "no inputs");
223
224		let offboard_txid = self.offboard_tx.compute_txid();
225		let connector_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
226		let connector_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
227			.expect("invalid offboard tx");
228		let tweaked_connector_key = connector_key.for_keyspend(&*SECP);
229
230		let mut ret = Vec::with_capacity(self.input_vtxos.len());
231		if self.input_vtxos.len() == 1 {
232			let vtxo = self.input_vtxos[0].as_ref();
233			let tx = server_check_finalize_forfeit_tx(
234				vtxo,
235				server_key,
236				&tweaked_connector_key,
237				connector_prev,
238				connector_txout,
239				(&server_pub_nonces[0], server_sec_nonces.into_iter().next().unwrap()),
240				&user_pub_nonces[0],
241				&user_partial_sigs[0],
242			).ok_or_else(|| InvalidUserPartialSignatureError { vtxo: vtxo.id() })?;
243			ret.push(tx);
244		} else {
245			// here we will create a deterministic intermediate connector tx and
246			// sign forfeit txs with the outputs of that tx
247
248			let connector_tx = construct_multi_connector_tx(
249				connector_prev, self.input_vtxos.len(), &connector_txout.script_pubkey,
250			);
251			let connector_txid = connector_tx.compute_txid();
252
253			// NB all connector txouts are identical, we copy the one from the offboard tx
254			let iter = self.input_vtxos.iter()
255				.zip(server_pub_nonces)
256				.zip(server_sec_nonces)
257				.zip(user_pub_nonces)
258				.zip(user_partial_sigs);
259			for (i, ((((vtxo, server_pub), server_sec), user_pub), user_part)) in iter.enumerate() {
260				let connector = OutPoint::new(connector_txid, i as u32);
261				match server_check_finalize_forfeit_tx(
262					vtxo.as_ref(),
263					server_key,
264					&tweaked_connector_key,
265					connector,
266					connector_txout,
267					(server_pub, server_sec),
268					user_pub,
269					user_part,
270				) {
271					Some(tx) => ret.push(tx),
272					None => return Err(InvalidUserPartialSignatureError {
273						vtxo: vtxo.as_ref().id(),
274					}),
275				}
276			}
277		}
278
279		Ok(ret)
280	}
281}
282
283fn user_sign_vtxo_forfeit_input<G: Sync + Send>(
284	vtxo: &Vtxo<G>,
285	key: &Keypair,
286	connector: OutPoint,
287	connector_txout: &TxOut,
288	server_nonce: &musig::PublicNonce,
289) -> (musig::PublicNonce, musig::PartialSignature) {
290	let tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
291	let mut shc = SighashCache::new(&tx);
292	let prevouts = [&vtxo.txout(), &connector_txout];
293	let sighash = shc.taproot_key_spend_signature_hash(
294		0, &Prevouts::All(&prevouts), TapSighashType::Default,
295	).expect("provided all prevouts");
296	let tweak = vtxo.output_taproot().tap_tweak().to_byte_array();
297	let (pub_nonce, partial_sig) = musig::deterministic_partial_sign(
298		key,
299		[vtxo.server_pubkey()],
300		&[server_nonce],
301		sighash.to_byte_array(),
302		Some(tweak),
303	);
304	debug_assert!({
305		let (key_agg, _) = musig::tweaked_key_agg(
306			[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
307		);
308		let agg_nonce = musig::nonce_agg(&[&pub_nonce, server_nonce]);
309		let ff_session = musig::Session::new(
310			&key_agg,
311			agg_nonce,
312			&sighash.to_byte_array(),
313		);
314		ff_session.partial_verify(
315			&key_agg,
316			&partial_sig,
317			&pub_nonce,
318			musig::pubkey_to(vtxo.user_pubkey()),
319		)
320	}, "invalid partial offboard forfeit signature");
321
322	(pub_nonce, partial_sig)
323}
324
325/// Check the user's partial signature, then finalize the forfeit tx
326///
327/// Returns `None` only if the user's partial signature is invalid.
328fn server_check_finalize_forfeit_tx<G: Sync + Send>(
329	vtxo: &Vtxo<G>,
330	server_key: &Keypair,
331	tweaked_connector_key: &Keypair,
332	connector: OutPoint,
333	connector_txout: &TxOut,
334	server_nonces: (&musig::PublicNonce, musig::SecretNonce),
335	user_nonce: &musig::PublicNonce,
336	user_partial_sig: &musig::PartialSignature,
337) -> Option<Transaction> {
338	let mut tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
339	let mut shc = SighashCache::new(&tx);
340	let prevouts = [&vtxo.txout(), &connector_txout];
341	let vtxo_sig = {
342		let sighash = shc.taproot_key_spend_signature_hash(
343			0, &Prevouts::All(&prevouts), TapSighashType::Default,
344		).expect("provided all prevouts");
345		let vtxo_taproot = vtxo.output_taproot();
346		let tweak = vtxo_taproot.tap_tweak().to_byte_array();
347		let agg_nonce = musig::nonce_agg(&[user_nonce, server_nonces.0]);
348
349		// NB it is cheaper to check final schnorr signature than partial sig, so
350		// it is customary to do that insted
351
352		let (_our_part_sig, final_sig) = musig::partial_sign(
353			[vtxo.user_pubkey(), vtxo.server_pubkey()],
354			agg_nonce,
355			server_key,
356			server_nonces.1,
357			sighash.to_byte_array(),
358			Some(tweak),
359			Some(&[user_partial_sig]),
360		);
361		debug_assert!({
362			let (key_agg, _) = musig::tweaked_key_agg(
363				[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
364			);
365			let ff_session = musig::Session::new(
366				&key_agg,
367				agg_nonce,
368				&sighash.to_byte_array(),
369			);
370			ff_session.partial_verify(
371				&key_agg,
372				&_our_part_sig,
373				server_nonces.0,
374				musig::pubkey_to(vtxo.server_pubkey()),
375			)
376		}, "invalid partial offboard forfeit signature");
377		let final_sig = final_sig.expect("we provided other sigs");
378		SECP.verify_schnorr(
379			&final_sig, &sighash.into(), vtxo_taproot.output_key().as_x_only_public_key(),
380		).ok()?;
381		final_sig
382	};
383
384	let conn_sig = {
385		let sighash = shc.taproot_key_spend_signature_hash(
386			1, &Prevouts::All(&prevouts), TapSighashType::Default,
387		).expect("provided all prevouts");
388		SECP.sign_schnorr_with_aux_rand(&sighash.into(), tweaked_connector_key, &rand::random())
389	};
390
391	tx.input[0].witness = Witness::from_slice(&[&vtxo_sig[..]]);
392	tx.input[1].witness = Witness::from_slice(&[&conn_sig[..]]);
393	debug_assert_eq!(tx,
394		create_offboard_forfeit_tx(vtxo, connector, Some(&vtxo_sig), Some(&conn_sig)),
395	);
396
397	#[cfg(test)]
398	{
399		let prevs = [vtxo.txout(), connector_txout.clone()];
400		if let Err(e) = crate::test_util::verify_tx(&prevs, 0, &tx) {
401			println!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
402			panic!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
403		}
404	}
405
406	Some(tx)
407}
408
409fn create_offboard_forfeit_tx<G: Sync + Send>(
410	vtxo: &Vtxo<G>,
411	connector: OutPoint,
412	vtxo_sig: Option<&schnorr::Signature>,
413	conn_sig: Option<&schnorr::Signature>,
414) -> Transaction {
415	Transaction {
416		version: bitcoin::transaction::Version(3),
417		lock_time: bitcoin::absolute::LockTime::ZERO,
418		input: vec![
419			TxIn {
420				previous_output: vtxo.point(),
421				sequence: Sequence::MAX,
422				script_sig: ScriptBuf::new(),
423				witness: vtxo_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
424			},
425			TxIn {
426				previous_output: connector,
427				sequence: Sequence::MAX,
428				script_sig: ScriptBuf::new(),
429				witness: conn_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
430			},
431		],
432		output: vec![
433			TxOut {
434				// also accumulate the connector dust
435				value: vtxo.amount() + P2TR_DUST,
436				script_pubkey: ScriptBuf::new_p2tr(
437					&*SECP, vtxo.server_pubkey().x_only_public_key().0, None,
438				),
439			},
440			fee::fee_anchor(),
441		],
442	}
443}
444
445#[cfg(test)]
446mod test {
447	use std::str::FromStr;
448	use bitcoin::hex::FromHex;
449	use bitcoin::secp256k1::PublicKey;
450	use crate::test_util::dummy::{random_utxo, DummyTestVtxoSpec};
451	use super::*;
452
453	#[test]
454	fn test_offboard_forfeit() {
455		let server_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
456
457		let req_pk = PublicKey::from_str(
458			"02271fba79f590251099b07fa0393b4c55d5e50cd8fca2e2822b619f8aabf93b74",
459		).unwrap();
460		let req = OffboardRequest {
461			script_pubkey: ScriptBuf::new_p2tr(&*SECP, req_pk.x_only_public_key().0, None),
462			net_amount: Amount::ONE_BTC,
463			deduct_fees_from_gross_amount: true,
464			fee_rate: FeeRate::from_sat_per_kwu(100),
465		};
466
467		let input1_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
468		let (_, input1) = DummyTestVtxoSpec {
469			user_keypair: input1_key,
470			server_keypair: server_key,
471			..Default::default()
472		}.build();
473		let input2_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
474		let (_, input2) = DummyTestVtxoSpec {
475			user_keypair: input2_key,
476			server_keypair: server_key,
477			..Default::default()
478		}.build();
479
480		let conn_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
481		let conn_spk = ScriptBuf::new_p2tr(
482			&*SECP, conn_key.public_key().x_only_public_key().0, None,
483		);
484
485		let change_amt = Amount::ONE_BTC * 2;
486		let offboard_tx = Transaction {
487			version: bitcoin::transaction::Version(3),
488			lock_time: bitcoin::absolute::LockTime::ZERO,
489			input: vec![
490				TxIn {
491					previous_output: random_utxo(),
492					sequence: Sequence::MAX,
493					script_sig: ScriptBuf::new(),
494					witness: Witness::new(),
495				},
496			],
497			output: vec![
498				// the delivery goes first
499				req.to_txout(),
500				// then a connector
501				TxOut {
502					script_pubkey: conn_spk.clone(),
503					value: P2TR_DUST * 2,
504				},
505				// then maybe change
506				TxOut {
507					script_pubkey: ScriptBuf::from_bytes(Vec::<u8>::from_hex(
508						"512077243a077f583b197d36caac516b0c7e4319c7b6a2316c25972f44dfbf20fd09"
509					).unwrap()),
510					value: change_amt,
511				},
512			],
513		};
514
515		let inputs = [&input1, &input2];
516		let ctx = OffboardForfeitContext::new(&inputs, &offboard_tx);
517		ctx.validate_offboard_tx(&req).unwrap();
518
519		let (server_sec_nonces, server_pub_nonces) = (0..2).map(|_| {
520			musig::nonce_pair(&server_key)
521		}).collect::<(Vec<_>, Vec<_>)>();
522
523		let user_sigs = ctx.user_sign_forfeits(&[&input1_key, &input2_key], &server_pub_nonces);
524
525		ctx.check_finalize_transactions(
526			&server_key,
527			&conn_key,
528			&server_pub_nonces,
529			server_sec_nonces,
530			&user_sigs.public_nonces,
531			&user_sigs.partial_signatures,
532		).unwrap();
533	}
534}