use std::borrow::Borrow;
use bitcoin::{
Amount, FeeRate, OutPoint, ScriptBuf, Sequence, TapSighashType, Transaction, TxIn, TxOut, Txid, Witness
};
use bitcoin::hashes::Hash;
use bitcoin::hex::DisplayHex;
use bitcoin::secp256k1::{schnorr, Keypair, PublicKey};
use bitcoin::sighash::{Prevouts, SighashCache};
use bitcoin_ext::{fee, BlockDelta, BlockHeight, KeypairExt, TxOutExt, P2TR_DUST};
use crate::{musig, ServerVtxo, ServerVtxoPolicy, Vtxo, VtxoId, SECP};
use crate::connectors::construct_multi_connector_tx;
use crate::vtxo::{Bare, Full};
pub const OFFBOARD_TX_OFFBOARD_VOUT: usize = 0;
pub const OFFBOARD_TX_CONNECTOR_VOUT: usize = 1;
const CONNECTOR_EXPIRY_DELTA: BlockDelta = 144;
#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
#[error("invalid offboard request: {0}")]
pub struct InvalidOffboardRequestError(&'static str);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
pub struct OffboardRequest {
#[serde(with = "bitcoin_ext::serde::encodable")]
pub script_pubkey: ScriptBuf,
#[serde(rename = "amount_sat", with = "bitcoin::amount::serde::as_sat")]
pub net_amount: Amount,
pub deduct_fees_from_gross_amount: bool,
#[serde(rename = "fee_rate_kwu")]
pub fee_rate: FeeRate,
}
impl OffboardRequest {
pub fn validate(&self) -> Result<(), InvalidOffboardRequestError> {
if !self.to_txout().is_standard() {
return Err(InvalidOffboardRequestError("non-standard output"));
}
Ok(())
}
pub fn to_txout(&self) -> TxOut {
TxOut {
script_pubkey: self.script_pubkey.clone(),
value: self.net_amount,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
#[error("invalid offboard transaction: {0}")]
pub struct InvalidOffboardTxError(String);
impl<S: Into<String>> From<S> for InvalidOffboardTxError {
fn from(v: S) -> Self {
Self(v.into())
}
}
impl From<InvalidOffboardRequestError> for InvalidOffboardTxError {
fn from(e: InvalidOffboardRequestError) -> Self {
InvalidOffboardTxError(format!("invalid offboard request: {:#}", e))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
#[error("invalid partial signature for VTXO {vtxo}")]
pub struct InvalidUserPartialSignatureError {
pub vtxo: VtxoId,
}
pub struct OffboardForfeitSignatures {
pub public_nonces: Vec<musig::PublicNonce>,
pub partial_signatures: Vec<musig::PartialSignature>,
}
pub struct OffboardForfeitResult {
pub forfeit_txs: Vec<Transaction>,
pub forfeit_vtxos: Vec<ServerVtxo>,
pub connector_tx: Option<Transaction>,
pub connector_vtxos: Vec<ServerVtxo>,
}
impl OffboardForfeitResult {
pub fn spend_info<'a>(
&'a self,
inputs: impl Iterator<Item = VtxoId> + 'a,
offboard_txid: Txid,
) -> impl Iterator<Item = (VtxoId, Txid)> + 'a {
let vtxos_to_ff = inputs.zip(self.forfeit_txs.iter().map(|t| t.compute_txid()));
let connector = if let Some(ref conn_tx) = self.connector_tx {
Some((OutPoint::new(offboard_txid, 1).into(), conn_tx.compute_txid()))
} else {
None
};
vtxos_to_ff.chain(connector)
}
}
pub struct OffboardForfeitContext<'a, V> {
input_vtxos: &'a [V],
offboard_tx: &'a Transaction,
}
impl<'a, V> OffboardForfeitContext<'a, V>
where
V: AsRef<Vtxo<Full>>,
{
pub fn new(input_vtxos: &'a [V], offboard_tx: &'a Transaction) -> Self {
assert_ne!(input_vtxos.len(), 0, "no input VTXOs");
Self { input_vtxos, offboard_tx }
}
pub fn validate_offboard_tx(
&self,
req: &OffboardRequest,
) -> Result<(), InvalidOffboardTxError> {
let offb_txout = self.offboard_tx.output.get(OFFBOARD_TX_OFFBOARD_VOUT)
.ok_or("missing offboard output")?;
let exp_txout = req.to_txout();
if exp_txout.script_pubkey != offb_txout.script_pubkey {
return Err(format!(
"offboard output scriptPubkey doesn't match: got={}, expected={}",
offb_txout.script_pubkey.as_bytes().as_hex(),
exp_txout.script_pubkey.as_bytes().as_hex(),
).into());
}
if exp_txout.value != offb_txout.value {
return Err(format!(
"offboard output amount doesn't match: got={}, expected={}",
offb_txout.value, exp_txout.value,
).into());
}
let conn_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
.ok_or("missing connector output")?;
let required_conn_value = P2TR_DUST * self.input_vtxos.len() as u64;
if conn_txout.value != required_conn_value {
return Err(format!(
"insufficient connector amount: got={}, need={}",
conn_txout.value, required_conn_value,
).into());
}
Ok(())
}
pub fn user_sign_forfeits(
&self,
keys: &[impl Borrow<Keypair>],
server_nonces: &[musig::PublicNonce],
) -> OffboardForfeitSignatures {
assert_eq!(self.input_vtxos.len(), keys.len(), "wrong number of keys");
assert_eq!(self.input_vtxos.len(), server_nonces.len(), "wrong number of nonces");
assert_ne!(self.input_vtxos.len(), 0, "no inputs");
let mut pub_nonces = Vec::with_capacity(self.input_vtxos.len());
let mut part_sigs = Vec::with_capacity(self.input_vtxos.len());
let offboard_txid = self.offboard_tx.compute_txid();
let connector_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
let connector_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
.expect("invalid offboard tx");
if self.input_vtxos.len() == 1 {
let (nonce, sig) = user_sign_vtxo_forfeit_input(
self.input_vtxos[0].as_ref(),
keys[0].borrow(),
connector_prev,
connector_txout,
&server_nonces[0],
);
pub_nonces.push(nonce);
part_sigs.push(sig);
} else {
let connector_tx = construct_multi_connector_tx(
connector_prev, self.input_vtxos.len(), &connector_txout.script_pubkey,
);
let connector_txid = connector_tx.compute_txid();
let iter = self.input_vtxos.iter().zip(keys).zip(server_nonces);
for (i, ((vtxo, key), server_nonce)) in iter.enumerate() {
let connector = OutPoint::new(connector_txid, i as u32);
let (nonce, sig) = user_sign_vtxo_forfeit_input(
vtxo.as_ref(), key.borrow(), connector, connector_txout, server_nonce,
);
pub_nonces.push(nonce);
part_sigs.push(sig);
}
}
OffboardForfeitSignatures {
public_nonces: pub_nonces,
partial_signatures: part_sigs,
}
}
pub fn finish(
&self,
server_key: &Keypair,
connector_key: &Keypair,
server_pub_nonces: &[musig::PublicNonce],
server_sec_nonces: Vec<musig::SecretNonce>,
user_pub_nonces: &[musig::PublicNonce],
user_partial_sigs: &[musig::PartialSignature],
) -> Result<OffboardForfeitResult, InvalidUserPartialSignatureError> {
assert_eq!(self.input_vtxos.len(), server_pub_nonces.len());
assert_eq!(self.input_vtxos.len(), server_sec_nonces.len());
assert_eq!(self.input_vtxos.len(), user_pub_nonces.len());
assert_eq!(self.input_vtxos.len(), user_partial_sigs.len());
assert_ne!(self.input_vtxos.len(), 0, "no inputs");
let offboard_txid = self.offboard_tx.compute_txid();
let connector_prev = OutPoint::new(offboard_txid, OFFBOARD_TX_CONNECTOR_VOUT as u32);
let connector_txout = self.offboard_tx.output.get(OFFBOARD_TX_CONNECTOR_VOUT)
.expect("invalid offboard tx");
let tweaked_connector_key = connector_key.for_keyspend_only(&*SECP);
let mut ret = OffboardForfeitResult {
forfeit_txs: Vec::with_capacity(self.input_vtxos.len()),
forfeit_vtxos: Vec::with_capacity(self.input_vtxos.len()),
connector_tx: None,
connector_vtxos: Vec::new(),
};
if self.input_vtxos.len() == 1 {
let vtxo = self.input_vtxos[0].as_ref();
let tx = server_check_finalize_forfeit_tx(
vtxo,
server_key,
&tweaked_connector_key,
connector_prev,
connector_txout,
(&server_pub_nonces[0], server_sec_nonces.into_iter().next().unwrap()),
&user_pub_nonces[0],
&user_partial_sigs[0],
).ok_or_else(|| InvalidUserPartialSignatureError { vtxo: vtxo.id() })?;
ret.forfeit_vtxos = vec![construct_forfeit_vtxo(vtxo, &tx)];
ret.forfeit_txs.push(tx);
ret.connector_vtxos = vec![construct_connector_vtxo_single(vtxo, offboard_txid)];
} else {
let connector_tx = construct_multi_connector_tx(
connector_prev, self.input_vtxos.len(), &connector_txout.script_pubkey,
);
let connector_txid = connector_tx.compute_txid();
ret.connector_tx = Some(connector_tx);
ret.connector_vtxos = Vec::with_capacity(self.input_vtxos.len() + 1);
ret.connector_vtxos.push(construct_connector_vtxo_fanout_root(
offboard_txid,
self.input_vtxos.iter().map(|v| v.as_ref().expiry_height()).max().unwrap(),
self.input_vtxos[0].as_ref().server_pubkey(), self.input_vtxos.len(),
));
let iter = self.input_vtxos.iter()
.zip(server_pub_nonces)
.zip(server_sec_nonces)
.zip(user_pub_nonces)
.zip(user_partial_sigs);
for (i, ((((vtxo, server_pub), server_sec), user_pub), user_part)) in iter.enumerate() {
let vtxo = vtxo.as_ref();
let connector = OutPoint::new(connector_txid, i as u32);
let tx = server_check_finalize_forfeit_tx(
vtxo,
server_key,
&tweaked_connector_key,
connector,
connector_txout,
(server_pub, server_sec),
user_pub,
user_part,
).ok_or_else(|| InvalidUserPartialSignatureError { vtxo: vtxo.as_ref().id() })?;
ret.forfeit_vtxos.push(construct_forfeit_vtxo(vtxo, &tx));
ret.forfeit_txs.push(tx);
ret.connector_vtxos.push(construct_connector_vtxo_fanout_leaf(
vtxo, i, offboard_txid, connector_txid,
));
}
}
Ok(ret)
}
}
fn construct_forfeit_vtxo<G>(
input: &Vtxo<G>,
forfeit_tx: &Transaction,
) -> ServerVtxo<Bare> {
ServerVtxo {
point: OutPoint::new(forfeit_tx.compute_txid(), 0),
policy: ServerVtxoPolicy::ServerOwned,
amount: input.amount,
anchor_point: input.anchor_point,
server_pubkey: input.server_pubkey,
expiry_height: input.expiry_height,
exit_delta: input.exit_delta,
genesis: Bare,
}
}
fn construct_connector_vtxo_single<G>(
input: &Vtxo<G>,
offboard_txid: Txid,
) -> ServerVtxo<Bare> {
let point = OutPoint::new(offboard_txid, 1);
ServerVtxo {
anchor_point: point.clone(),
point: point,
policy: ServerVtxoPolicy::ServerOwned,
amount: P2TR_DUST,
server_pubkey: input.server_pubkey,
expiry_height: input.expiry_height + CONNECTOR_EXPIRY_DELTA as u32,
exit_delta: 0,
genesis: Bare,
}
}
fn construct_connector_vtxo_fanout_root(
offboard_txid: Txid,
max_expiry_height: BlockHeight,
server_pubkey: PublicKey,
nb_vtxos: usize,
) -> ServerVtxo<Bare> {
let point = OutPoint::new(offboard_txid, 1);
ServerVtxo {
anchor_point: point.clone(),
point: point,
policy: ServerVtxoPolicy::ServerOwned,
amount: P2TR_DUST * nb_vtxos as u64,
server_pubkey: server_pubkey,
expiry_height: max_expiry_height + CONNECTOR_EXPIRY_DELTA as u32,
exit_delta: 0,
genesis: Bare,
}
}
fn construct_connector_vtxo_fanout_leaf<G>(
input: &Vtxo<G>,
input_idx: usize,
offboard_txid: Txid,
connector_txid: Txid,
) -> ServerVtxo<Bare> {
ServerVtxo {
point: OutPoint::new(connector_txid, input_idx as u32),
anchor_point: OutPoint::new(offboard_txid, 1),
policy: ServerVtxoPolicy::ServerOwned,
amount: P2TR_DUST,
server_pubkey: input.server_pubkey,
expiry_height: input.expiry_height + CONNECTOR_EXPIRY_DELTA as u32,
exit_delta: 0,
genesis: Bare,
}
}
fn user_sign_vtxo_forfeit_input<G: Sync + Send>(
vtxo: &Vtxo<G>,
key: &Keypair,
connector: OutPoint,
connector_txout: &TxOut,
server_nonce: &musig::PublicNonce,
) -> (musig::PublicNonce, musig::PartialSignature) {
let tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
let mut shc = SighashCache::new(&tx);
let prevouts = [&vtxo.txout(), &connector_txout];
let sighash = shc.taproot_key_spend_signature_hash(
0, &Prevouts::All(&prevouts), TapSighashType::Default,
).expect("provided all prevouts");
let tweak = vtxo.output_taproot().tap_tweak().to_byte_array();
let (pub_nonce, partial_sig) = musig::deterministic_partial_sign(
key,
[vtxo.server_pubkey()],
&[server_nonce],
sighash.to_byte_array(),
Some(tweak),
);
debug_assert!({
let (key_agg, _) = musig::tweaked_key_agg(
[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
);
let agg_nonce = musig::nonce_agg(&[&pub_nonce, server_nonce]);
let ff_session = musig::Session::new(
&key_agg,
agg_nonce,
&sighash.to_byte_array(),
);
ff_session.partial_verify(
&key_agg,
&partial_sig,
&pub_nonce,
musig::pubkey_to(vtxo.user_pubkey()),
)
}, "invalid partial offboard forfeit signature");
(pub_nonce, partial_sig)
}
fn server_check_finalize_forfeit_tx<G: Sync + Send>(
vtxo: &Vtxo<G>,
server_key: &Keypair,
tweaked_connector_key: &Keypair,
connector: OutPoint,
connector_txout: &TxOut,
server_nonces: (&musig::PublicNonce, musig::SecretNonce),
user_nonce: &musig::PublicNonce,
user_partial_sig: &musig::PartialSignature,
) -> Option<Transaction> {
let mut tx = create_offboard_forfeit_tx(vtxo, connector, None, None);
let mut shc = SighashCache::new(&tx);
let prevouts = [&vtxo.txout(), &connector_txout];
let vtxo_sig = {
let sighash = shc.taproot_key_spend_signature_hash(
0, &Prevouts::All(&prevouts), TapSighashType::Default,
).expect("provided all prevouts");
let vtxo_taproot = vtxo.output_taproot();
let tweak = vtxo_taproot.tap_tweak().to_byte_array();
let agg_nonce = musig::nonce_agg(&[user_nonce, server_nonces.0]);
let (_our_part_sig, final_sig) = musig::partial_sign(
[vtxo.user_pubkey(), vtxo.server_pubkey()],
agg_nonce,
server_key,
server_nonces.1,
sighash.to_byte_array(),
Some(tweak),
Some(&[user_partial_sig]),
);
debug_assert!({
let (key_agg, _) = musig::tweaked_key_agg(
[vtxo.user_pubkey(), vtxo.server_pubkey()], tweak,
);
let ff_session = musig::Session::new(
&key_agg,
agg_nonce,
&sighash.to_byte_array(),
);
ff_session.partial_verify(
&key_agg,
&_our_part_sig,
server_nonces.0,
musig::pubkey_to(vtxo.server_pubkey()),
)
}, "invalid partial offboard forfeit signature");
let final_sig = final_sig.expect("we provided other sigs");
SECP.verify_schnorr(
&final_sig, &sighash.into(), vtxo_taproot.output_key().as_x_only_public_key(),
).ok()?;
final_sig
};
let conn_sig = {
let sighash = shc.taproot_key_spend_signature_hash(
1, &Prevouts::All(&prevouts), TapSighashType::Default,
).expect("provided all prevouts");
SECP.sign_schnorr_with_aux_rand(&sighash.into(), tweaked_connector_key, &rand::random())
};
tx.input[0].witness = Witness::from_slice(&[&vtxo_sig[..]]);
tx.input[1].witness = Witness::from_slice(&[&conn_sig[..]]);
debug_assert_eq!(tx,
create_offboard_forfeit_tx(vtxo, connector, Some(&vtxo_sig), Some(&conn_sig)),
);
#[cfg(test)]
{
let prevs = [vtxo.txout(), connector_txout.clone()];
if let Err(e) = crate::test_util::verify_tx(&prevs, 0, &tx) {
println!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
panic!("forfeit tx for VTXO {} failed: {}", vtxo.id(), e);
}
}
Some(tx)
}
fn create_offboard_forfeit_tx<G: Sync + Send>(
vtxo: &Vtxo<G>,
connector: OutPoint,
vtxo_sig: Option<&schnorr::Signature>,
conn_sig: Option<&schnorr::Signature>,
) -> Transaction {
Transaction {
version: bitcoin::transaction::Version(3),
lock_time: bitcoin::absolute::LockTime::ZERO,
input: vec![
TxIn {
previous_output: vtxo.point(),
sequence: Sequence::MAX,
script_sig: ScriptBuf::new(),
witness: vtxo_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
},
TxIn {
previous_output: connector,
sequence: Sequence::MAX,
script_sig: ScriptBuf::new(),
witness: conn_sig.map(|s| Witness::from_slice(&[&s[..]])).unwrap_or_default(),
},
],
output: vec![
TxOut {
value: vtxo.amount() + P2TR_DUST,
script_pubkey: ScriptBuf::new_p2tr(
&*SECP, vtxo.server_pubkey().x_only_public_key().0, None,
),
},
fee::fee_anchor(),
],
}
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use bitcoin::hex::FromHex;
use bitcoin::secp256k1::PublicKey;
use crate::test_util::dummy::{random_utxo, DummyTestVtxoSpec};
use super::*;
#[test]
fn test_offboard_forfeit() {
let server_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
let req_pk = PublicKey::from_str(
"02271fba79f590251099b07fa0393b4c55d5e50cd8fca2e2822b619f8aabf93b74",
).unwrap();
let req = OffboardRequest {
script_pubkey: ScriptBuf::new_p2tr(&*SECP, req_pk.x_only_public_key().0, None),
net_amount: Amount::ONE_BTC,
deduct_fees_from_gross_amount: true,
fee_rate: FeeRate::from_sat_per_kwu(100),
};
let input1_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
let (_, input1) = DummyTestVtxoSpec {
user_keypair: input1_key,
server_keypair: server_key,
..Default::default()
}.build();
let input2_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
let (_, input2) = DummyTestVtxoSpec {
user_keypair: input2_key,
server_keypair: server_key,
..Default::default()
}.build();
let conn_key = Keypair::new(&*SECP, &mut bitcoin::secp256k1::rand::thread_rng());
let conn_spk = ScriptBuf::new_p2tr(
&*SECP, conn_key.public_key().x_only_public_key().0, None,
);
let change_amt = Amount::ONE_BTC * 2;
let offboard_tx = Transaction {
version: bitcoin::transaction::Version(3),
lock_time: bitcoin::absolute::LockTime::ZERO,
input: vec![
TxIn {
previous_output: random_utxo(),
sequence: Sequence::MAX,
script_sig: ScriptBuf::new(),
witness: Witness::new(),
},
],
output: vec![
req.to_txout(),
TxOut {
script_pubkey: conn_spk.clone(),
value: P2TR_DUST * 2,
},
TxOut {
script_pubkey: ScriptBuf::from_bytes(Vec::<u8>::from_hex(
"512077243a077f583b197d36caac516b0c7e4319c7b6a2316c25972f44dfbf20fd09"
).unwrap()),
value: change_amt,
},
],
};
let inputs = [&input1, &input2];
let ctx = OffboardForfeitContext::new(&inputs, &offboard_tx);
ctx.validate_offboard_tx(&req).unwrap();
let (server_sec_nonces, server_pub_nonces) = (0..2).map(|_| {
musig::nonce_pair(&server_key)
}).collect::<(Vec<_>, Vec<_>)>();
let user_sigs = ctx.user_sign_forfeits(&[&input1_key, &input2_key], &server_pub_nonces);
ctx.finish(
&server_key,
&conn_key,
&server_pub_nonces,
server_sec_nonces,
&user_sigs.public_nonces,
&user_sigs.partial_signatures,
).unwrap();
}
}