commit 179fa8c47ef879932bdb3f1ea4d0d57827676ae8 Author: conduition Date: Wed Feb 14 20:29:44 2024 +0000 initial working code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0172c70 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,278 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "bech32" +version = "0.10.0-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" + +[[package]] +name = "bitcoin" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd00f3c09b5f21fb357abe32d29946eb8bb7a0862bae62c0b5e4a692acbbe73c" +dependencies = [ + "bech32", + "bitcoin-internals", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals", + "hex-conservative", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed443af458ccb6d81c1e7e661545f94d3176752fb1df2f543b902a1e0f51e2" + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "libc" +version = "0.2.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" + +[[package]] +name = "musig2" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f43e7abc6e724a2c8caf0e558ab838d71e70826f98df85da6a607243efbc83f" +dependencies = [ + "base16ct", + "once_cell", + "secp", + "secp256k1", + "sha2", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "secp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787bed714ffe1439d1f8ce84e2ceed8daf6d5737f5422108a5de88030fc597b6" +dependencies = [ + "base16ct", + "once_cell", + "secp256k1", + "subtle", +] + +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "ticketed-dlc" +version = "0.1.0" +dependencies = [ + "bitcoin", + "hex", + "musig2", + "rand", + "secp", + "secp256k1", + "sha2", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a6ad2de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ticketed-dlc" +version = "0.1.0" +edition = "2021" +authors = ["conduition "] +description = "Ticketed Discreet Log Contracts" +readme = "README.md" +license = "Unlicense" +repository = "https://github.com/conduition/ticketed-dlc" +keywords = ["dlc", "smart", "contract", "ticket", "auction"] + +[dependencies] +bitcoin = { version = "0.31.1" } +hex = "0.4.3" +musig2 = { version = "0.0.4" } +rand = "0.8.5" +secp = { version = "0.2.1" } +secp256k1 = { version = "0.28.2", features = ["global-context"] } +sha2 = "0.10.8" + +[package.metadata.docs.rs] +all-features = true diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..8985bab --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,35 @@ +/// TODO actual error types. +#[derive(Debug, Clone)] +pub struct Error; + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("generic error") + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(_: musig2::errors::KeyAggError) -> Self { + Error + } +} + +impl From for Error { + fn from(_: musig2::errors::TweakError) -> Self { + Error + } +} + +impl From for Error { + fn from(_: bitcoin::taproot::TaprootBuilderError) -> Self { + Error + } +} + +impl From for Error { + fn from(_: bitcoin::taproot::IncompleteBuilderError) -> Self { + Error + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d1f6448 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1128 @@ +mod errors; + +use bitcoin::{ + absolute::LockTime, + key::constants::SCHNORR_SIGNATURE_SIZE, + opcodes::all::*, + script::ScriptBuf, + sighash::{Prevouts, SighashCache, TapSighashType}, + taproot::{ + LeafVersion, TaprootSpendInfo, TAPROOT_CONTROL_BASE_SIZE, TAPROOT_CONTROL_NODE_SIZE, + }, + transaction::InputWeightPrediction, + Amount, FeeRate, OutPoint, Sequence, TapSighash, Transaction, TxIn, TxOut, Witness, +}; +use errors::Error; +use musig2::KeyAggContext; +use secp::{MaybePoint, MaybeScalar, Point, Scalar}; +use secp256k1::XOnlyPublicKey; +use sha2::Digest as _; +use std::collections::BTreeMap; + +const P2TR_SCRIPT_PUBKEY_LEN: usize = 34; + +/// The agent who provides the on-chain capital to facilitate the ticketed DLC. +/// Could be one of the players in the DLC, or could be a neutral 3rd party +/// who wishes to profit by leveraging their capital. +#[derive(Debug, Clone)] +pub struct MarketMaker { + pub pubkey: Point, +} + +/// Compute the SHA256 hash of some input data. +pub fn sha256(input: &[u8]) -> [u8; 32] { + sha2::Sha256::new().chain_update(input).finalize().into() +} + +/// The size for ticket preimages. +/// +/// Using lower values is more efficient on-chain, but less secure against +/// brute force attacks. +pub const TICKET_PREIMAGE_SIZE: usize = 32; + +/// A handy type-alias for ticket preimages. We use random 32 byte preimages +/// for best compatibility with lightning network clients. +pub type TicketPreimage = [u8; TICKET_PREIMAGE_SIZE]; + +pub fn preimage_random(rng: &mut R) -> TicketPreimage { + let mut preimage = [0u8; TICKET_PREIMAGE_SIZE]; + rng.fill_bytes(&mut preimage); + preimage +} + +/// Parse a preimage from a hex string. +pub fn preimage_from_hex(s: &str) -> Result { + let mut preimage = [0u8; TICKET_PREIMAGE_SIZE]; + hex::decode_to_slice(s, &mut preimage)?; + Ok(preimage) +} + +/// A player in a ticketed DLC. Each player is identified by a public key, +/// but also by their ticket hash. If a player can learn the preimage of +/// their ticket hash (usually by purchasing it via Lightning), they can +/// claim winnings from DLC outcomes. +/// +/// The same pubkey can participate in the same ticketed DLC under different +/// ticket hashes, so players might share common pubkeys. However, for the +/// economics of the contract to work, every player should be allocated +/// their own completely unique ticket hash. +#[derive(Debug, Clone, Copy, Ord, PartialOrd, Hash, Eq, PartialEq)] +pub struct Player { + /// A public key controlled by the player. + pub pubkey: Point, + + /// The ticket hashes used for HTLCs. To buy into the DLC, players must + /// purchase the preimages of these hashes. + pub ticket_hash: [u8; 32], +} + +/// An oracle's announcement of a future event. +#[derive(Debug, Clone)] +pub struct EventAnnouncment { + /// The signing oracle's pubkey + pub oracle_pubkey: Point, + + /// The `R` point with which the oracle promises to attest to this event. + pub nonce_point: Point, + + /// Naive but easy. + pub outcome_messages: Vec>, + + /// The unix timestamp beyond which the oracle is considered to have gone AWOL. + pub expiry: u32, +} + +impl EventAnnouncment { + /// Computes the oracle's locking point for the given outcome index. + pub fn outcome_lock_point(&self, index: usize) -> Option { + let msg = &self.outcome_messages.get(index)?; + + let e: MaybeScalar = musig2::compute_challenge_hash_tweak( + &self.nonce_point.serialize_xonly(), + &self.oracle_pubkey, + msg, + ); + + // S = R + eD + Some(self.nonce_point + e * self.oracle_pubkey) + } + + /// Computes the oracle's unllocking scalar - the discrete log of the + /// locking point - for the given outcome index. + pub fn outcome_secret( + &self, + index: usize, + oracle_seckey: Scalar, + secnonce: Scalar, + ) -> Option { + if oracle_seckey.base_point_mul() != self.oracle_pubkey + || secnonce.base_point_mul() != self.nonce_point + { + return None; + } + + let msg = &self.outcome_messages.get(index)?; + let e: MaybeScalar = musig2::compute_challenge_hash_tweak( + &self.nonce_point.serialize_xonly(), + &self.oracle_pubkey, + msg, + ); + Some(secnonce + e * oracle_seckey) + } +} + +/// Represents a mapping of player to payout weight for a given outcome. +/// +/// A player's payout is proportional to the size of their payout weight +/// in comparison to the payout weights of all other winners. +pub type PayoutWeights = BTreeMap; + +#[derive(Debug, Clone)] +pub struct FundingSpendInfo<'e> { + key_agg_ctx: KeyAggContext, + event: &'e EventAnnouncment, +} + +impl<'e> FundingSpendInfo<'e> { + fn new(key_agg_ctx: KeyAggContext, event: &'e EventAnnouncment) -> FundingSpendInfo<'e> { + FundingSpendInfo { key_agg_ctx, event } + } + + /// Return a reference to the [`KeyAggContext`] used to spend the multisig funding output. + pub fn key_agg_ctx(&self) -> &KeyAggContext { + &self.key_agg_ctx + } + + /// Returns the TX locking script for funding the ticketed DLC multisig. + pub fn script_pubkey(&self) -> ScriptBuf { + ScriptBuf::new_p2tr( + secp256k1::SECP256K1, + self.key_agg_ctx.aggregated_pubkey(), + None, + ) + } + + /// Compute the signature hash for a given outcome transaction. + pub fn sighash_tx_outcome( + &self, + outcome_tx: &Transaction, + funding_value: Amount, + ) -> Result { + let funding_prevouts = [TxOut { + script_pubkey: self.script_pubkey(), + value: funding_value, + }]; + + SighashCache::new(outcome_tx).taproot_key_spend_signature_hash( + 0, + &Prevouts::All(&funding_prevouts), + TapSighashType::Default, + ) + } +} + +/// Represents a taproot contract which encodes spending conditions for +/// the given outcome index's outcome TX. This tree is meant to encumber joint +/// signatures on the split transaction. Any winning player should be able to +/// broadcast the split transaction, but only if they know their ticket preimage. +/// The market maker should be able to freely spend the money if no ticketholder +/// can publish the split TX before a timeout. +/// +/// Since we're using hashlocks and not PTLCs here, we unfortunately need a +/// tapscript leaf for every winner, and since tapscript signatures must commit +/// to the leaf, the winners must construct distinct musig2 signatures for each +/// leaf. This must be repeated for every outcome. With `n` outcomes and `w` +/// winners per outcome, we must create a total of `n * w` signatures. +/// +/// Once PTLCs are available, we can instead sign the split transaction once +/// and distribute adaptor-signatures to each player, encrypted under the +/// player's ticket point. +#[derive(Debug, Clone)] +pub struct OutcomeSpendInfo { + key_agg_ctx: KeyAggContext, + spend_info: TaprootSpendInfo, + winner_split_scripts: BTreeMap, + reclaim_script: ScriptBuf, +} + +impl OutcomeSpendInfo { + fn new>( + winners: W, + market_maker: &MarketMaker, + block_delta: u16, + ) -> Result { + let winners: Vec = winners.into_iter().collect(); + let mut pubkeys: Vec = [market_maker.pubkey] + .into_iter() + .chain(winners.iter().map(|w| w.pubkey)) + .collect(); + pubkeys.sort(); + let untweaked_ctx = KeyAggContext::new(pubkeys)?; + let joint_outcome_pubkey: Point = untweaked_ctx.aggregated_pubkey(); + + let winner_split_scripts: BTreeMap = winners + .iter() + .map(|&winner| { + // The winner split script, used by winning players to spend + // the outcome transaction using the split transaction. + // + // Input: + let script = bitcoin::script::Builder::new() + // Check ticket preimage: OP_SHA256 OP_EQUALVERIFY + .push_opcode(OP_SHA256) + .push_slice(winner.ticket_hash) + .push_opcode(OP_EQUALVERIFY) + // Check joint signature: OP_CHECKSIG + .push_slice(joint_outcome_pubkey.serialize_xonly()) + .push_opcode(OP_CHECKSIG) + // Don't need OP_CSV. + // Sequence number is enforced by multisig key: split TX is pre-signed. + .into_script(); + + (winner, script) + }) + .collect(); + + // The reclaim script, used by the market maker to recover their capital + // if none of the winning players bought their ticket preimages. + let reclaim_script = bitcoin::script::Builder::new() + // Check relative locktime: <2*delta> OP_CSV OP_DROP + .push_int(2 * block_delta as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + // Check signature from market maker: OP_CHECKSIG + .push_slice(market_maker.pubkey.serialize_xonly()) + .push_opcode(OP_CHECKSIG) + .into_script(); + + let weighted_script_leaves = winner_split_scripts + .values() + .cloned() + .map(|script| (1, script)) + .chain([(999999999, reclaim_script.clone())]); // reclaim script gets highest priority + + let tr_spend_info = TaprootSpendInfo::with_huffman_tree( + secp256k1::SECP256K1, + joint_outcome_pubkey.into(), + weighted_script_leaves, + )?; + + let tweaked_ctx = untweaked_ctx.with_taproot_tweak( + tr_spend_info + .merkle_root() + .expect("should always have merkle root") + .as_ref(), + )?; + + let outcome_spend_info = OutcomeSpendInfo { + key_agg_ctx: tweaked_ctx, + spend_info: tr_spend_info, + winner_split_scripts, + reclaim_script, + }; + Ok(outcome_spend_info) + } + + /// Returns the TX locking script for this this outcome contract. + pub fn script_pubkey(&self) -> ScriptBuf { + ScriptBuf::new_p2tr_tweaked(self.spend_info.output_key()) + } + + /// Computes the input weight when spending the output of the outcome TX + /// as an input of the split TX. This assumes one of the winning ticketholders' + /// tapscript leaves is being used to build a witness. This prediction aims + /// for fee estimation in the worst-case-scenario: For the winner whose tapscript + /// leaf is deepest in the taptree (and hence requires the largest merkle proof). + pub fn input_weight_for_split_tx(&self) -> InputWeightPrediction { + let outcome_script_len = self + .winner_split_scripts + .values() + .nth(0) + .expect("always at least one winner") + .len(); + + let max_taptree_depth = self + .spend_info + .script_map() + .values() + .flatten() + .map(|proof| proof.len()) + .max() + .expect("always has at least one node"); + + // The witness stack for the split TX (spends the outcome TX) is: + //