use std::marker::PhantomData;
use bitcoin::sighash::{self, SighashCache};
use bitcoin::taproot::TaprootSpendInfo;
use bitcoin::{Amount, OutPoint, ScriptBuf, TapSighash, Transaction, TxOut, Txid};
use bitcoin::hashes::Hash;
use bitcoin::secp256k1::{Keypair, PublicKey};
use bitcoin_ext::{BlockDelta, BlockHeight, TaprootSpendInfoExt};
use crate::error::IncorrectSigningKeyError;
use crate::{musig, scripts, SECP};
use crate::tree::signed::cosign_taproot;
use crate::vtxo::{self, Full, Vtxo, VtxoId, VtxoPolicy, ServerVtxo, ServerVtxoPolicy, GenesisItem, GenesisTransition};
use self::state::BuilderState;
pub const BOARD_FUNDING_TX_VTXO_VOUT: u32 = 0;
#[derive(Debug)]
struct ExitData {
sighash: TapSighash,
funding_taproot: TaprootSpendInfo,
tx: Transaction,
txid: Txid,
}
fn compute_exit_data(
user_pubkey: PublicKey,
server_pubkey: PublicKey,
expiry_height: BlockHeight,
exit_delta: BlockDelta,
amount: Amount,
fee: Amount,
utxo: OutPoint,
) -> ExitData {
let combined_pubkey = musig::combine_keys([user_pubkey, server_pubkey])
.x_only_public_key().0;
let funding_taproot = cosign_taproot(combined_pubkey, server_pubkey, expiry_height);
let funding_txout = TxOut {
value: amount,
script_pubkey: funding_taproot.script_pubkey(),
};
let exit_taproot = VtxoPolicy::new_pubkey(user_pubkey)
.taproot(server_pubkey, exit_delta, expiry_height);
let exit_txout = TxOut {
value: amount - fee,
script_pubkey: exit_taproot.script_pubkey(),
};
let tx = vtxo::create_exit_tx(utxo, exit_txout, None, fee);
let sighash = SighashCache::new(&tx).taproot_key_spend_signature_hash(
0, &sighash::Prevouts::All(&[funding_txout]), sighash::TapSighashType::Default,
).expect("matching prevouts");
let txid = tx.compute_txid();
ExitData { sighash, funding_taproot, tx, txid }
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum BoardFundingError {
#[error("fee larger than amount: amount {amount}, fee {fee}")]
FeeHigherThanAmount {
amount: Amount,
fee: Amount,
},
#[error("amount is zero")]
ZeroAmount,
#[error("amount after fee is <= 0: amount {amount}, fee {fee}")]
ZeroAmountAfterFee {
amount: Amount,
fee: Amount,
},
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum BoardFromVtxoError {
#[error("funding txid mismatch: expected {expected}, got {got}")]
FundingTxMismatch {
expected: Txid,
got: Txid,
},
#[error("server pubkey mismatch: expected {expected}, got {got}")]
ServerPubkeyMismatch {
expected: PublicKey,
got: PublicKey,
},
#[error("vtxo id mismatch: expected {expected}, got {got}")]
VtxoIdMismatch {
expected: OutPoint,
got: OutPoint,
},
#[error("incorrect number of genesis items {genesis_count}, should be 1")]
IncorrectGenesisItemCount {
genesis_count: usize,
},
}
#[derive(Debug)]
pub struct BoardCosignResponse {
pub pub_nonce: musig::PublicNonce,
pub partial_signature: musig::PartialSignature,
}
pub mod state {
mod sealed {
pub trait Sealed {}
impl Sealed for super::Preparing {}
impl Sealed for super::CanGenerateNonces {}
impl Sealed for super::ServerCanCosign {}
impl Sealed for super::CanFinish {}
}
pub trait BuilderState: sealed::Sealed {}
pub struct Preparing;
impl BuilderState for Preparing {}
pub struct CanGenerateNonces;
impl BuilderState for CanGenerateNonces {}
pub struct ServerCanCosign;
impl BuilderState for ServerCanCosign {}
pub struct CanFinish;
impl BuilderState for CanFinish {}
pub trait CanSign: BuilderState {}
impl CanSign for ServerCanCosign {}
impl CanSign for CanFinish {}
pub trait HasFundingDetails: BuilderState {}
impl HasFundingDetails for CanGenerateNonces {}
impl HasFundingDetails for ServerCanCosign {}
impl HasFundingDetails for CanFinish {}
}
#[derive(Debug)]
pub struct BoardBuilder<S: BuilderState> {
pub user_pubkey: PublicKey,
pub expiry_height: BlockHeight,
pub server_pubkey: PublicKey,
pub exit_delta: BlockDelta,
amount: Option<Amount>,
fee: Option<Amount>,
utxo: Option<OutPoint>,
user_pub_nonce: Option<musig::PublicNonce>,
user_sec_nonce: Option<musig::SecretNonce>,
exit_data: Option<ExitData>,
_state: PhantomData<S>,
}
impl<S: BuilderState> BoardBuilder<S> {
pub fn funding_script_pubkey(&self) -> ScriptBuf {
let combined_pubkey = musig::combine_keys([self.user_pubkey, self.server_pubkey])
.x_only_public_key().0;
cosign_taproot(combined_pubkey, self.server_pubkey, self.expiry_height).script_pubkey()
}
fn to_state<S2: BuilderState>(self) -> BoardBuilder<S2> {
BoardBuilder {
user_pubkey: self.user_pubkey,
expiry_height: self.expiry_height,
server_pubkey: self.server_pubkey,
exit_delta: self.exit_delta,
amount: self.amount,
utxo: self.utxo,
fee: self.fee,
user_pub_nonce: self.user_pub_nonce,
user_sec_nonce: self.user_sec_nonce,
exit_data: self.exit_data,
_state: PhantomData,
}
}
}
impl BoardBuilder<state::Preparing> {
pub fn new(
user_pubkey: PublicKey,
expiry_height: BlockHeight,
server_pubkey: PublicKey,
exit_delta: BlockDelta,
) -> BoardBuilder<state::Preparing> {
BoardBuilder {
user_pubkey, expiry_height, server_pubkey, exit_delta,
amount: None,
utxo: None,
fee: None,
user_pub_nonce: None,
user_sec_nonce: None,
exit_data: None,
_state: PhantomData,
}
}
pub fn set_funding_details(
mut self,
amount: Amount,
fee: Amount,
utxo: OutPoint,
) -> Result<BoardBuilder<state::CanGenerateNonces>, BoardFundingError> {
if amount == Amount::ZERO {
return Err(BoardFundingError::ZeroAmount);
} else if fee > amount {
return Err(BoardFundingError::FeeHigherThanAmount { amount, fee });
} else if amount - fee == Amount::ZERO {
return Err(BoardFundingError::ZeroAmountAfterFee { amount, fee });
}
let exit_data = compute_exit_data(
self.user_pubkey, self.server_pubkey, self.expiry_height,
self.exit_delta, amount, fee, utxo,
);
self.amount = Some(amount);
self.utxo = Some(utxo);
self.fee = Some(fee);
self.exit_data = Some(exit_data);
Ok(self.to_state())
}
}
impl BoardBuilder<state::CanGenerateNonces> {
pub fn generate_user_nonces(mut self) -> BoardBuilder<state::CanFinish> {
let exit_data = self.exit_data.as_ref().expect("state invariant");
let funding_taproot = &exit_data.funding_taproot;
let exit_sighash = exit_data.sighash;
let (agg, _) = musig::tweaked_key_agg(
[self.user_pubkey, self.server_pubkey],
funding_taproot.tap_tweak().to_byte_array(),
);
let (sec_nonce, pub_nonce) = agg.nonce_gen(
musig::SessionSecretRand::assume_unique_per_nonce_gen(rand::random()),
musig::pubkey_to(self.user_pubkey),
&exit_sighash.to_byte_array(),
None,
);
self.user_pub_nonce = Some(pub_nonce);
self.user_sec_nonce = Some(sec_nonce);
self.to_state()
}
pub fn new_from_vtxo(
vtxo: &Vtxo<Full>,
funding_tx: &Transaction,
server_pubkey: PublicKey,
) -> Result<Self, BoardFromVtxoError> {
if vtxo.chain_anchor().txid != funding_tx.compute_txid() {
return Err(BoardFromVtxoError::FundingTxMismatch {
expected: vtxo.chain_anchor().txid,
got: funding_tx.compute_txid(),
})
}
if vtxo.server_pubkey() != server_pubkey {
return Err(BoardFromVtxoError::ServerPubkeyMismatch {
expected: server_pubkey,
got: vtxo.server_pubkey(),
})
}
if vtxo.genesis.items.len() != 1 {
return Err(BoardFromVtxoError::IncorrectGenesisItemCount {
genesis_count: vtxo.genesis.items.len(),
});
}
let fee = vtxo.genesis.items.first().unwrap().fee_amount;
let exit_data = compute_exit_data(
vtxo.user_pubkey(),
server_pubkey,
vtxo.expiry_height,
vtxo.exit_delta,
vtxo.amount() + fee,
fee,
vtxo.chain_anchor(),
);
let expected_vtxo_id = OutPoint::new(exit_data.txid, BOARD_FUNDING_TX_VTXO_VOUT);
if vtxo.point() != expected_vtxo_id {
return Err(BoardFromVtxoError::VtxoIdMismatch {
expected: expected_vtxo_id,
got: vtxo.point(),
})
}
Ok(Self {
user_pub_nonce: None,
user_sec_nonce: None,
amount: Some(vtxo.amount() + fee),
fee: Some(fee),
user_pubkey: vtxo.user_pubkey(),
server_pubkey,
expiry_height: vtxo.expiry_height,
exit_delta: vtxo.exit_delta,
utxo: Some(vtxo.chain_anchor()),
exit_data: Some(exit_data),
_state: PhantomData,
})
}
pub fn exit_tx(&self) -> &Transaction {
&self.exit_data.as_ref().expect("state invariant").tx
}
pub fn exit_txid(&self) -> Txid {
self.exit_data.as_ref().expect("state invariant").txid
}
pub fn build_internal_unsigned_vtxos(&self) -> Vec<ServerVtxo<Full>> {
let amount = self.amount.expect("state invariant");
let fee = self.fee.expect("state invariant");
let exit_data = self.exit_data.as_ref().expect("state invariant");
let exit_txid = exit_data.txid;
let tap_tweak = exit_data.funding_taproot.tap_tweak();
let combined_pubkey = musig::combine_keys([self.user_pubkey, self.server_pubkey])
.x_only_public_key().0;
let expiry_policy = ServerVtxoPolicy::new_expiry(combined_pubkey);
vec![
Vtxo {
policy: expiry_policy,
amount: amount,
expiry_height: self.expiry_height,
server_pubkey: self.server_pubkey,
exit_delta: self.exit_delta,
anchor_point: self.utxo.expect("state invariant"),
genesis: Full { items: vec![] },
point: self.utxo.expect("state invariant"),
},
Vtxo {
policy: ServerVtxoPolicy::User(VtxoPolicy::new_pubkey(self.user_pubkey)),
amount: amount - fee,
expiry_height: self.expiry_height,
server_pubkey: self.server_pubkey,
exit_delta: self.exit_delta,
anchor_point: self.utxo.expect("state invariant"),
genesis: Full {
items: vec![
GenesisItem {
transition: GenesisTransition::new_arkoor(
vec![self.user_pubkey],
tap_tweak,
None,
),
output_idx: 0,
other_outputs: vec![],
fee_amount: fee,
}
],
},
point: OutPoint::new(exit_txid, BOARD_FUNDING_TX_VTXO_VOUT),
},
]
}
pub fn spend_info(&self) -> Vec<(VtxoId, Txid)> {
let exit_txid = self.exit_data.as_ref().expect("state invariant").txid;
vec![(self.utxo.expect("state invariant").into(), exit_txid)]
}
}
impl<S: state::CanSign> BoardBuilder<S> {
pub fn user_pub_nonce(&self) -> &musig::PublicNonce {
self.user_pub_nonce.as_ref().expect("state invariant")
}
}
impl BoardBuilder<state::ServerCanCosign> {
pub fn new_for_cosign(
user_pubkey: PublicKey,
expiry_height: BlockHeight,
server_pubkey: PublicKey,
exit_delta: BlockDelta,
amount: Amount,
fee: Amount,
utxo: OutPoint,
user_pub_nonce: musig::PublicNonce,
) -> BoardBuilder<state::ServerCanCosign> {
let exit_data = compute_exit_data(
user_pubkey, server_pubkey, expiry_height, exit_delta, amount, fee, utxo,
);
BoardBuilder {
user_pubkey, expiry_height, server_pubkey, exit_delta,
amount: Some(amount),
fee: Some(fee),
utxo: Some(utxo),
user_pub_nonce: Some(user_pub_nonce),
user_sec_nonce: None,
exit_data: Some(exit_data),
_state: PhantomData,
}
}
pub fn server_cosign(&self, key: &Keypair) -> BoardCosignResponse {
let exit_data = self.exit_data.as_ref().expect("state invariant");
let sighash = exit_data.sighash;
let taproot = &exit_data.funding_taproot;
let (pub_nonce, partial_signature) = musig::deterministic_partial_sign(
key,
[self.user_pubkey],
&[&self.user_pub_nonce()],
sighash.to_byte_array(),
Some(taproot.tap_tweak().to_byte_array()),
);
BoardCosignResponse { pub_nonce, partial_signature }
}
}
impl BoardBuilder<state::CanFinish> {
pub fn verify_cosign_response(&self, server_cosign: &BoardCosignResponse) -> bool {
let exit_data = self.exit_data.as_ref().expect("state invariant");
let sighash = exit_data.sighash;
let taproot = &exit_data.funding_taproot;
scripts::verify_partial_sig(
sighash,
taproot.tap_tweak(),
(self.server_pubkey, &server_cosign.pub_nonce),
(self.user_pubkey, self.user_pub_nonce()),
&server_cosign.partial_signature
)
}
pub fn build_vtxo(
mut self,
server_cosign: &BoardCosignResponse,
user_key: &Keypair,
) -> Result<Vtxo<Full>, IncorrectSigningKeyError> {
if user_key.public_key() != self.user_pubkey {
return Err(IncorrectSigningKeyError {
required: Some(self.user_pubkey),
provided: user_key.public_key(),
});
}
let exit_data = self.exit_data.as_ref().expect("state invariant");
let sighash = exit_data.sighash;
let taproot = &exit_data.funding_taproot;
let exit_txid = exit_data.txid;
let agg_nonce = musig::nonce_agg(&[&self.user_pub_nonce(), &server_cosign.pub_nonce]);
let (user_sig, final_sig) = musig::partial_sign(
[self.user_pubkey, self.server_pubkey],
agg_nonce,
user_key,
self.user_sec_nonce.take().expect("state invariant"),
sighash.to_byte_array(),
Some(taproot.tap_tweak().to_byte_array()),
Some(&[&server_cosign.partial_signature]),
);
debug_assert!(
scripts::verify_partial_sig(
sighash,
taproot.tap_tweak(),
(self.user_pubkey, self.user_pub_nonce()),
(self.server_pubkey, &server_cosign.pub_nonce),
&user_sig,
),
"invalid board partial exit tx signature produced",
);
let final_sig = final_sig.expect("we provided the other sig");
debug_assert!(
SECP.verify_schnorr(
&final_sig, &sighash.into(), &taproot.output_key().to_x_only_public_key(),
).is_ok(),
"invalid board exit tx signature produced",
);
let amount = self.amount.expect("state invariant");
let fee = self.fee.expect("state invariant");
let vtxo_amount = amount.checked_sub(fee).expect("fee cannot exceed amount");
Ok(Vtxo {
amount: vtxo_amount,
expiry_height: self.expiry_height,
server_pubkey: self.server_pubkey,
exit_delta: self.exit_delta,
anchor_point: self.utxo.expect("state invariant"),
genesis: Full {
items: vec![GenesisItem {
transition: GenesisTransition::new_cosigned(
vec![self.user_pubkey, self.server_pubkey],
Some(final_sig),
),
output_idx: 0,
other_outputs: vec![],
fee_amount: fee,
}],
},
policy: VtxoPolicy::new_pubkey(self.user_pubkey),
point: OutPoint::new(exit_txid, BOARD_FUNDING_TX_VTXO_VOUT),
})
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("board funding tx validation error: {0}")]
pub struct BoardFundingTxValidationError(String);
#[cfg(test)]
mod test {
use std::str::FromStr;
use bitcoin::{absolute, transaction, Amount};
use crate::test_util::encoding_roundtrip;
use super::*;
#[test]
fn test_board_builder() {
let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
let amount = Amount::from_btc(1.5).unwrap();
let fee = Amount::from_btc(0.1).unwrap();
let expiry = 100_000;
let server_pubkey = server_key.public_key();
let exit_delta = 24;
let builder = BoardBuilder::new(
user_key.public_key(), expiry, server_pubkey, exit_delta,
);
let funding_tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
value: amount,
script_pubkey: builder.funding_script_pubkey(),
}],
};
let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
assert_eq!(utxo.to_string(), "8c4b87af4ce8456bbd682859959ba64b95d5425d761a367f4f20b8ffccb1bde0:0");
let builder = builder.set_funding_details(amount, fee, utxo).unwrap().generate_user_nonces();
let cosign = {
let server_builder = BoardBuilder::new_for_cosign(
builder.user_pubkey, expiry, server_pubkey, exit_delta, amount, fee, utxo, *builder.user_pub_nonce(),
);
server_builder.server_cosign(&server_key)
};
assert!(builder.verify_cosign_response(&cosign));
let vtxo = builder.build_vtxo(&cosign, &user_key).unwrap();
encoding_roundtrip(&vtxo);
vtxo.validate(&funding_tx).unwrap();
}
fn create_board_vtxo() -> (Vtxo<Full>, Transaction, Keypair, Keypair) {
let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
let amount = Amount::from_btc(1.5).unwrap();
let fee = Amount::from_btc(0.1).unwrap();
let expiry = 100_000;
let server_pubkey = server_key.public_key();
let exit_delta = 24;
let builder = BoardBuilder::new(
user_key.public_key(), expiry, server_pubkey, exit_delta,
);
let funding_tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
value: amount,
script_pubkey: builder.funding_script_pubkey(),
}],
};
let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
let builder = builder.set_funding_details(amount, fee, utxo).unwrap().generate_user_nonces();
let cosign = {
let server_builder = BoardBuilder::new_for_cosign(
builder.user_pubkey, expiry, server_pubkey, exit_delta, amount, fee, utxo, *builder.user_pub_nonce(),
);
server_builder.server_cosign(&server_key)
};
let vtxo = builder.build_vtxo(&cosign, &user_key).unwrap();
(vtxo, funding_tx, user_key, server_key)
}
#[test]
fn test_new_from_vtxo_success() {
let (vtxo, funding_tx, _, server_key) = create_board_vtxo();
vtxo.validate(&funding_tx).unwrap();
println!("amount: {}", vtxo.amount());
let builder = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, server_key.public_key())
.expect("Is valid");
let server_vtxos = builder.build_internal_unsigned_vtxos();
assert_eq!(server_vtxos.len(), 2);
assert!(matches!(server_vtxos[0].policy(), ServerVtxoPolicy::Expiry(..)));
assert!(matches!(server_vtxos[1].policy(), ServerVtxoPolicy::User(VtxoPolicy::Pubkey {..})));
assert_eq!(server_vtxos[1].id(), vtxo.id());
assert_eq!(server_vtxos[1].txout(), vtxo.txout());
assert_eq!(server_vtxos[0].txout(), funding_tx.output[0]);
assert_eq!(
server_vtxos[1].transactions().nth(0).unwrap().tx.compute_txid(),
vtxo.transactions().nth(0).unwrap().tx.compute_txid(),
);
}
#[test]
fn test_new_from_vtxo_txid_mismatch() {
let (vtxo, funding_tx, _, server_key) = create_board_vtxo();
let wrong_funding_tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
value: Amount::from_btc(2.0).unwrap(), script_pubkey: funding_tx.output[0].script_pubkey.clone(),
}],
};
let result = BoardBuilder::new_from_vtxo(&vtxo, &wrong_funding_tx, server_key.public_key());
assert!(matches!(
result,
Err(BoardFromVtxoError::FundingTxMismatch { expected, got })
if expected == vtxo.chain_anchor().txid && got == wrong_funding_tx.compute_txid()
));
}
#[test]
fn test_new_from_vtxo_server_pubkey_mismatch() {
let (vtxo, funding_tx, _, _) = create_board_vtxo();
let wrong_server_key = Keypair::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
let result = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, wrong_server_key.public_key());
assert!(matches!(
result,
Err(BoardFromVtxoError::ServerPubkeyMismatch { expected, got })
if expected == wrong_server_key.public_key() && got == vtxo.server_pubkey()
));
}
#[test]
fn test_new_from_vtxo_vtxoid_mismatch() {
let (mut vtxo, funding_tx, _, server_key) = create_board_vtxo();
let original_point = vtxo.point;
vtxo.point = OutPoint::new(vtxo.point.txid, vtxo.point.vout + 1);
let result = BoardBuilder::new_from_vtxo(&vtxo, &funding_tx, server_key.public_key());
assert!(matches!(
result,
Err(BoardFromVtxoError::VtxoIdMismatch { expected, got })
if expected == original_point && got == vtxo.point
));
}
#[test]
fn test_board_funding_error() {
fn new_builder_with_funding_details(amount: Amount, fee: Amount) -> Result<BoardBuilder<state::CanGenerateNonces>, BoardFundingError> {
let user_key = Keypair::from_str("5255d132d6ec7d4fc2a41c8f0018bb14343489ddd0344025cc60c7aa2b3fda6a").unwrap();
let server_key = Keypair::from_str("1fb316e653eec61de11c6b794636d230379509389215df1ceb520b65313e5426").unwrap();
let expiry = 100_000;
let server_pubkey = server_key.public_key();
let exit_delta = 24;
let builder = BoardBuilder::new(
user_key.public_key(), expiry, server_pubkey, exit_delta,
);
let funding_tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
value: amount,
script_pubkey: builder.funding_script_pubkey(),
}],
};
let utxo = OutPoint::new(funding_tx.compute_txid(), 0);
builder.set_funding_details(amount, fee, utxo)
}
let fee = Amount::ONE_BTC;
let zero_amount_err = new_builder_with_funding_details(Amount::ZERO, fee).err();
assert_eq!(zero_amount_err, Some(BoardFundingError::ZeroAmount));
let fee_higher_err = new_builder_with_funding_details(Amount::ONE_SAT, fee).err();
assert_eq!(fee_higher_err, Some(BoardFundingError::FeeHigherThanAmount { amount: Amount::ONE_SAT, fee }));
let zero_amount_after_fee_err = new_builder_with_funding_details(fee, fee).err();
assert_eq!(zero_amount_after_fee_err, Some(BoardFundingError::ZeroAmountAfterFee { amount: fee, fee }));
}
}