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
20pub const OFFBOARD_TX_OFFBOARD_VOUT: usize = 0;
22pub 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
32pub struct OffboardRequest {
33 #[serde(with = "bitcoin_ext::serde::encodable")]
35 pub script_pubkey: ScriptBuf,
36 #[serde(rename = "amount_sat", with = "bitcoin::amount::serde::as_sat")]
38 pub net_amount: Amount,
39 pub deduct_fees_from_gross_amount: bool,
42 #[serde(rename = "fee_rate_kwu")]
44 pub fee_rate: FeeRate,
45}
46
47impl OffboardRequest {
48 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 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 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 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 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 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 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 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 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 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 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
325fn 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 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 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 req.to_txout(),
500 TxOut {
502 script_pubkey: conn_spk.clone(),
503 value: P2TR_DUST * 2,
504 },
505 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}