diff --git a/Cargo.lock b/Cargo.lock index 8b2551e..135922c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,9 +314,9 @@ dependencies = [ [[package]] name = "musig2" -version = "0.0.10" +version = "0.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0852115edbe44f8e258b61383dfded95a8c8d214a8d10e2711f1353789bf0b23" +checksum = "bed08befaac75bfb31ca5e87678c4e8490bcd21d0c98ccb4f12f4065a7567e83" dependencies = [ "base16ct", "hmac", diff --git a/Cargo.toml b/Cargo.toml index 8b6fd94..65150b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ exclude = ["/img"] [dependencies] bitcoin = { version = "0.31.1", default-features = false, features = ["std", "serde"] } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } -musig2 = { version = "0.0.10", default-features = false, features = ["secp256k1", "rand", "serde"] } +musig2 = { version = "0.0.11", default-features = false, features = ["secp256k1", "rand", "serde"] } rand = { version = "0.8.5", default-features = false } secp = { version = "0.2.3", default-features = false, features = ["serde"] } secp256k1 = { version = "0.28.2", default-features = false, features = ["global-context"] } diff --git a/src/lib.rs b/src/lib.rs index 1aacdf1..354f273 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -745,6 +745,19 @@ impl SignedContract { Ok(split_tx) } + /// Returns prevout information for the market maker to create and sign a + /// funding-close transaction. + /// + /// See [SignedContract::sign_funding_close_tx_input] for more info on the funding-close TX. + pub fn funding_close_tx_input_and_prevout(&self) -> (TxIn, TxOut) { + let input = TxIn { + previous_output: self.dlc.funding_outpoint(), + ..TxIn::default() + }; + let prevout = self.dlc.funding_output(); + (input, prevout) + } + /// Returns prevout information for the market maker to create and sign an /// outcome-reclaim transaction. /// @@ -886,14 +899,16 @@ impl SignedContract { .input_weight_for_sellback_tx() } - /// Returns an input weight prediction value for the witnessed input of a split-close TX. - /// All split-close TX inputs have the same weight, which is + /// Returns an input weight prediction value for the witnessed input of a close TX (any close TX). + /// All close TX inputs have the same weight, which is /// [`InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH`]. /// + /// See [SignedContract::sign_funding_close_tx_input] for more info on the funding-close TX. + /// See [SignedContract::sign_outcome_close_tx_input] for more info on the outcome-close TX. /// See [SignedContract::sign_split_close_tx_input] for more info on the split-close TX. /// /// TODO: this should be a constant alias. - pub fn split_close_tx_input_weight(&self) -> InputWeightPrediction { + pub fn close_tx_input_weight(&self) -> InputWeightPrediction { InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH } @@ -966,6 +981,61 @@ impl SignedContract { ) } + /// Sign a cooperative closing transaction which spends the funding transaction output + /// back to the market maker's control. + /// + /// The funding-close TX should be used if all ticket-bearing winners have been paid + /// out-of-band, and all players including non-winners are cooperative. Once the winners + /// have their payouts, they can send their secret keys to the market maker to let him + /// reclaim all the on-chain capital. Non-winners, once they realize they are not + /// in a position to earn any money from the DLC, can surrender their secret keys to + /// the market maker to cooperate and thereby enable the most efficient possible on-chain + /// resolution of the DLC. + /// + /// The market maker can only publish a funding-close TX once all players including non-winners + /// have given the market maker their secret keys, which they should only do if they have been + /// paid out completely, or won't be paid out ever. + pub fn sign_funding_close_tx_input>( + &self, + close_tx: &mut Transaction, + input_index: usize, + prevouts: &Prevouts, + market_maker_secret_key: impl Into, + player_secret_keys: &BTreeMap, + ) -> Result<(), Error> { + let market_maker_secret_key = market_maker_secret_key.into(); + if market_maker_secret_key.base_point_mul() != self.dlc.params.market_maker.pubkey { + return Err(Error); + } + + // Confirm we're signing the correct input + let (mut expected_input, expected_prevout) = self.funding_close_tx_input_and_prevout(); + + // The caller can use whatever sequence they want. + expected_input.sequence = close_tx.input.get(input_index).ok_or(Error)?.sequence; + + check_input_matches_expected( + close_tx, + prevouts, + input_index, + &expected_input, + &expected_prevout, + )?; + + let funding_spend_info = self.dlc.outcome_tx_build.funding_spend_info(); + + let witness = funding_spend_info.witness_tx_close( + close_tx, + input_index, + prevouts, + market_maker_secret_key, + player_secret_keys, + )?; + + close_tx.input[input_index].witness = witness; + Ok(()) + } + /// Sign a cooperative closing transaction which spends the outcome transaction output /// back to the market maker's control. /// diff --git a/src/regtest.rs b/src/regtest.rs index 5b8e86d..40252ee 100644 --- a/src/regtest.rs +++ b/src/regtest.rs @@ -6,7 +6,7 @@ use bitcoincore_rpc::{jsonrpc::serde_json, Auth, Client as BitcoinClient, RpcApi use serial_test::serial; use bitcoin::{ - blockdata::transaction::{predict_weight, InputWeightPrediction}, + blockdata::transaction::predict_weight, hashes::Hash, key::TweakedPublicKey, locktime::absolute::LockTime, @@ -791,7 +791,7 @@ fn ticketed_dlc_individual_sellback() { script_pubkey: manager.script_pubkey_market_maker(), value: { let close_tx_weight = predict_weight( - [InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH], + [manager.contract.close_tx_input_weight()], [P2TR_SCRIPT_PUBKEY_SIZE], ); let fee = close_tx_weight * FeeRate::from_sat_per_vb_unchecked(20); @@ -856,7 +856,7 @@ fn ticketed_dlc_all_winners_cooperate() { script_pubkey: manager.script_pubkey_market_maker(), value: { let close_tx_weight = predict_weight( - [InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH], + [manager.contract.close_tx_input_weight()], [P2TR_SCRIPT_PUBKEY_SIZE], ); let fee = close_tx_weight * FeeRate::from_sat_per_vb_unchecked(20); @@ -997,7 +997,7 @@ fn ticketed_dlc_market_maker_reclaims_outcome_tx() { #[test] #[serial] -fn ticketed_dlc_contract_expiry_with_on_chain_resolution() { +fn ticketed_dlc_contract_expiry_on_chain_resolution() { let manager = SimulationManager::new(); // The contract expires, paying out to dave. @@ -1131,7 +1131,7 @@ fn ticketed_dlc_contract_expiry_with_on_chain_resolution() { #[test] #[serial] -fn ticketed_dlc_contract_expiry_cooperative_close() { +fn ticketed_dlc_contract_expiry_all_winners_cooperate() { let manager = SimulationManager::new(); // The contract expires, paying out to dave. @@ -1174,7 +1174,7 @@ fn ticketed_dlc_contract_expiry_cooperative_close() { script_pubkey: manager.script_pubkey_market_maker(), value: { let close_tx_weight = predict_weight( - [InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH], + [manager.contract.close_tx_input_weight()], [P2TR_SCRIPT_PUBKEY_SIZE], ); let fee = close_tx_weight * FeeRate::from_sat_per_vb_unchecked(20); @@ -1202,6 +1202,64 @@ fn ticketed_dlc_contract_expiry_cooperative_close() { .expect("failed to broadcast outcome close TX"); } +#[test] +#[serial] +fn ticketed_dlc_all_players_cooperate() { + let manager = SimulationManager::new(); + + // The oracle attests to outcome 0, paying out to Alice Bob and Carol. + // Alice, Bob, Carol, and Dave all cooperate. + // + // Alice, Bob and Carol sell their payout preimages to the market maker, + // and surrender their secret signing keys once they receive the payout. + // Dave stands to earn nothing, so he surrenders his private key immediately. + // The market maker can now sweep the funding output back to his control. + // This is the most efficient on-chain recovery path possible. + // + // It is possible for one of the players to publish the split TX, which + // double spends the funding TX, but even if the split TX is confirmed, + // the market maker can then immediately sweep the output of the split TX anyway. + let (close_tx_input, close_tx_prevout) = manager.contract.funding_close_tx_input_and_prevout(); + let mut close_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![close_tx_input], + output: vec![TxOut { + script_pubkey: manager.script_pubkey_market_maker(), + value: { + let close_tx_weight = predict_weight( + [manager.contract.close_tx_input_weight()], + [P2TR_SCRIPT_PUBKEY_SIZE], + ); + let fee = close_tx_weight * FeeRate::from_sat_per_vb_unchecked(20); + close_tx_prevout.value - fee + }, + }], + }; + + manager + .contract + .sign_funding_close_tx_input( + &mut close_tx, + 0, // input index + &Prevouts::All(&[close_tx_prevout]), + manager.market_maker_seckey, + &BTreeMap::from([ + (manager.alice.player.pubkey, manager.alice.seckey), + (manager.bob.player.pubkey, manager.bob.seckey), + (manager.carol.player.pubkey, manager.carol.seckey), + (manager.dave.player.pubkey, manager.dave.seckey), + ]), + ) + .expect("failed to sign funding close TX"); + + // The close TX can be broadcast immediately. + manager + .rpc + .send_raw_transaction(&close_tx) + .expect("failed to broadcast funding close TX"); +} + // This stress-test confirms that signing large ticketed DLCs is plausible, // if computationally expensive. #[test] diff --git a/src/spend_info/funding.rs b/src/spend_info/funding.rs index 1a2fe23..41dc061 100644 --- a/src/spend_info/funding.rs +++ b/src/spend_info/funding.rs @@ -1,15 +1,17 @@ use bitcoin::{ sighash::{Prevouts, SighashCache}, - Amount, ScriptBuf, TapSighash, TapSighashType, Transaction, TxOut, + Amount, ScriptBuf, TapSighash, TapSighashType, Transaction, TxOut, Witness, }; -use musig2::KeyAggContext; -use secp::Point; +use musig2::{CompactSignature, KeyAggContext}; +use secp::{Point, Scalar}; use crate::{ errors::Error, parties::{MarketMaker, Player}, }; +use std::{borrow::Borrow, collections::BTreeMap}; + #[derive(Clone, Eq, PartialEq)] pub(crate) struct FundingSpendInfo { key_agg_ctx: KeyAggContext, @@ -72,4 +74,43 @@ impl FundingSpendInfo { TapSighashType::Default, ) } + + /// Derive the witness for a cooperative closing transaction which spends from + /// the funding transaction. The market maker must provide the secret keys + /// for all of the winning players involved in the whole DLC. + pub(crate) fn witness_tx_close>( + &self, + close_tx: &Transaction, + input_index: usize, + prevouts: &Prevouts, + market_maker_secret_key: Scalar, + player_secret_keys: &BTreeMap, + ) -> Result { + let mm_pubkey = market_maker_secret_key.base_point_mul(); + let sighash = SighashCache::new(close_tx).taproot_key_spend_signature_hash( + input_index, + prevouts, + TapSighashType::Default, + )?; + + let ordered_seckeys: Vec = self + .key_agg_ctx + .pubkeys() + .into_iter() + .map(|&pubkey| { + if pubkey == mm_pubkey { + Ok(market_maker_secret_key) + } else { + player_secret_keys.get(&pubkey).ok_or(Error).copied() + } + }) + .collect::>()?; + + let group_seckey: Scalar = self.key_agg_ctx.aggregated_seckey(ordered_seckeys)?; + + let signature: CompactSignature = musig2::deterministic::sign_solo(group_seckey, sighash); + + let witness = Witness::from_slice(&[signature.serialize()]); + Ok(witness) + } }