From 57dadf452b0a800a9e3fa22d8b44a1b472fbfe7c Mon Sep 17 00:00:00 2001 From: conduition Date: Mon, 18 Mar 2024 01:37:47 +0000 Subject: [PATCH] add market maker server demo prototype (incomplete) --- Cargo.lock | 25 ++ Cargo.toml | 3 + demo/common/.gitignore | 1 + demo/common/Cargo.toml | 12 + demo/common/src/lib.rs | 51 +++ demo/mm_server/Cargo.lock | 430 +++++++++++++++++++++ demo/mm_server/Cargo.toml | 17 + demo/mm_server/src/errors.rs | 29 ++ demo/mm_server/src/global_state.rs | 102 +++++ demo/mm_server/src/main.rs | 49 +++ demo/mm_server/src/payouts.rs | 303 +++++++++++++++ demo/mm_server/src/server/handshake.rs | 96 +++++ demo/mm_server/src/server/mod.rs | 87 +++++ demo/mm_server/src/server/offer_and_ack.rs | 86 +++++ 14 files changed, 1291 insertions(+) create mode 100644 demo/common/.gitignore create mode 100644 demo/common/Cargo.toml create mode 100644 demo/common/src/lib.rs create mode 100644 demo/mm_server/Cargo.lock create mode 100644 demo/mm_server/Cargo.toml create mode 100644 demo/mm_server/src/errors.rs create mode 100644 demo/mm_server/src/global_state.rs create mode 100644 demo/mm_server/src/main.rs create mode 100644 demo/mm_server/src/payouts.rs create mode 100644 demo/mm_server/src/server/handshake.rs create mode 100644 demo/mm_server/src/server/mod.rs create mode 100644 demo/mm_server/src/server/offer_and_ack.rs diff --git a/Cargo.lock b/Cargo.lock index c5a27d6..1897289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "common" +version = "0.1.0" +dependencies = [ + "bitcoin", + "dlctix", + "secp", + "serde", +] + [[package]] name = "cpufeatures" version = "0.2.12" @@ -238,6 +248,21 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "mm_server" +version = "0.1.0" +dependencies = [ + "bitcoin", + "common", + "dlctix", + "hex", + "rand", + "serde", + "serde_cbor", + "serde_json", + "serdect", +] + [[package]] name = "musig2" version = "0.0.8" diff --git a/Cargo.toml b/Cargo.toml index cb9707d..7bd6780 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = ["demo/mm_server"] + [package] name = "dlctix" version = "0.0.4" diff --git a/demo/common/.gitignore b/demo/common/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/demo/common/.gitignore @@ -0,0 +1 @@ +/target diff --git a/demo/common/Cargo.toml b/demo/common/Cargo.toml new file mode 100644 index 0000000..06d20b6 --- /dev/null +++ b/demo/common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "common" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bitcoin = "0.31.1" +dlctix = { version = "0.0.4", path = "../.." } +secp = { version = "0.2.3", features = ["serde"] } +serde = "1.0.197" diff --git a/demo/common/src/lib.rs b/demo/common/src/lib.rs new file mode 100644 index 0000000..a15ed65 --- /dev/null +++ b/demo/common/src/lib.rs @@ -0,0 +1,51 @@ +use bitcoin::Amount; +use dlctix::{ContractParameters, EventAnnouncement, Outcome}; +use secp::Point; +use serde::{Deserialize, Serialize}; + +use std::collections::BTreeMap; + +pub type PlayerID = u128; + +/// Each value in the map is a weighted likelihood. Higher -> more likely to occur. +pub type OutcomeOdds = BTreeMap; + +#[derive(Serialize, Deserialize)] +pub struct ClientHello { + pub player_pubkey: Point, + pub payout_hash: [u8; 32], +} + +#[derive(Serialize, Deserialize)] +pub struct ServerHello { + pub player_id: PlayerID, + pub ticket_hash: [u8; 32], + pub market_maker_pubkey: Point, + pub event: EventAnnouncement, + pub odds: OutcomeOdds, +} + +// TODO rename: ClientIntent +#[derive(Serialize, Deserialize)] +pub struct Intent { + pub outcome: Outcome, + pub budget: bitcoin::Amount, +} + +#[derive(Serialize, Deserialize)] +pub struct ServerOffer { + pub contract_parameters: ContractParameters, + pub deposit_amount: Amount, +} + +#[derive(Serialize, Deserialize)] +pub enum ClientOfferAck { + Accept, + Reject, +} + +#[derive(Serialize, Deserialize)] +pub enum ServerOfferAck { + Ok, + Retry, +} diff --git a/demo/mm_server/Cargo.lock b/demo/mm_server/Cargo.lock new file mode 100644 index 0000000..907ef3b --- /dev/null +++ b/demo/mm_server/Cargo.lock @@ -0,0 +1,430 @@ +# 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", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +dependencies = [ + "serde", +] + +[[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", + "serde", +] + +[[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.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "common" +version = "0.1.0" +dependencies = [ + "bitcoin", + "dlctix", + "secp", + "serde", +] + +[[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", + "subtle", +] + +[[package]] +name = "dlctix" +version = "0.0.4" +dependencies = [ + "bitcoin", + "hex", + "musig2", + "rand", + "secp", + "secp256k1", + "serde", + "serdect", + "sha2", +] + +[[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 = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[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 = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "mm_server" +version = "0.1.0" +dependencies = [ + "bitcoin", + "common", + "dlctix", + "hex", + "rand", + "serde", + "serde_cbor", + "serde_json", + "serdect", +] + +[[package]] +name = "musig2" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08219fa88b8daa343b8b9b53bb9d1a131f8035fd5080e4d7780bb71487287404" +dependencies = [ + "base16ct", + "hmac", + "once_cell", + "rand", + "secp", + "secp256k1", + "serde", + "serdect", + "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 = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[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 = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "secp" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1507279bb0404bb566f85523e48fcf37a158daa5380577ee0d93f3ef4df39ccc" +dependencies = [ + "base16ct", + "once_cell", + "rand", + "secp256k1", + "serde", + "serdect", + "subtle", +] + +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes", + "rand", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[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 = "syn" +version = "2.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7383cd0e49fff4b6b90ca5670bfd3e9d6a733b3f90c686605aa7eec8c4996032" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[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/demo/mm_server/Cargo.toml b/demo/mm_server/Cargo.toml new file mode 100644 index 0000000..1ecd2ed --- /dev/null +++ b/demo/mm_server/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mm_server" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dlctix = { version = "0.0.4", path = "../.." } +serde_cbor = "0.11.2" +common = { path = "../common" } +bitcoin = "0.31.1" +rand = "0.8.5" +hex = "0.4.3" +serdect = "0.2.0" +serde = "1.0.197" +serde_json = "1.0.114" diff --git a/demo/mm_server/src/errors.rs b/demo/mm_server/src/errors.rs new file mode 100644 index 0000000..dbfd99e --- /dev/null +++ b/demo/mm_server/src/errors.rs @@ -0,0 +1,29 @@ +use crate::global_state::Stage; + +use std::{ + error::Error, + fmt::{self, Debug, Display, Formatter}, +}; + +#[derive(Debug)] +pub(crate) struct WrongStageError(pub(crate) Stage); +impl Display for WrongStageError { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!( + f, + "server is in {:?} stage, cannot accept new players", + self.0 + ) + } +} +impl Error for WrongStageError {} + +#[derive(Debug)] +pub(crate) struct InvalidInputError(pub(crate) T); + +impl Display for InvalidInputError { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "invalid input from client: {}", self.0) + } +} +impl Error for InvalidInputError {} diff --git a/demo/mm_server/src/global_state.rs b/demo/mm_server/src/global_state.rs new file mode 100644 index 0000000..cdaa06f --- /dev/null +++ b/demo/mm_server/src/global_state.rs @@ -0,0 +1,102 @@ +use bitcoin::{Amount, FeeRate}; + +use crate::payouts::compute_deposit_and_payout_weights; +use common::{Intent, OutcomeOdds, PlayerID, ServerOffer}; +use dlctix::secp::{Point, Scalar}; +use dlctix::{hashlock, ContractParameters, EventAnnouncement, MarketMaker, Player, PlayerIndex}; + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + net, +}; + +const RELATIVE_LOCKTIME_BLOCK_DELTA: u16 = 60; + +/// TODO: this should be dynamic +const FEE_RATE: FeeRate = FeeRate::from_sat_per_vb_unchecked(80); + +#[derive(serde::Serialize)] +pub(crate) struct PlayerRegistration { + #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")] + pub(crate) ticket_preimage: hashlock::Preimage, + pub(crate) player: Player, + pub(crate) intent: Intent, + #[serde(skip)] + pub(crate) connection: net::TcpStream, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Stage { + IntentRegistry, + OfferAndAck, +} + +pub(crate) struct GlobalState { + pub(crate) event: EventAnnouncement, + pub(crate) odds: OutcomeOdds, + pub(crate) market_maker_seckey: Scalar, + pub(crate) market_maker_pubkey: Point, + pub(crate) registrations: HashMap, + pub(crate) stage: Stage, +} + +impl GlobalState { + pub(crate) fn construct_offers(&self) -> BTreeMap { + // Sort players and map them to their IDs. + let player_ids: BTreeMap<&Player, PlayerID> = self + .registrations + .iter() + .map(|(&id, reg)| (®.player, id)) + .collect(); + + // Map player identifiers to player indexes. + let player_indexes: HashMap = player_ids + .values() + .enumerate() + .map(|(index, &id)| (id, index)) + .collect(); + + let player_intents: BTreeMap = self + .registrations + .iter() + .map(|(id, reg)| (player_indexes[id], ®.intent)) + .collect(); + + // Compute the total amount of bitcoin which all players wish to wager. + let (deposit_amounts, outcome_payouts) = compute_deposit_and_payout_weights( + &player_intents, + &self.odds, + self.event.all_outcomes(), + ); + let funding_value: Amount = deposit_amounts.values().copied().sum(); + + let players: BTreeSet = self + .registrations + .values() + .map(|reg| reg.player.clone()) + .collect(); + + let contract_parameters = ContractParameters { + market_maker: MarketMaker { + pubkey: self.market_maker_pubkey, + }, + players, + event: self.event.clone(), + funding_value, + outcome_payouts, + fee_rate: FEE_RATE, + relative_locktime_block_delta: RELATIVE_LOCKTIME_BLOCK_DELTA, + }; + + player_indexes + .into_iter() + .map(|(player_id, player_index)| { + let offer = ServerOffer { + contract_parameters: contract_parameters.clone(), + deposit_amount: deposit_amounts[&player_index], + }; + (player_id, offer) + }) + .collect() + } +} diff --git a/demo/mm_server/src/main.rs b/demo/mm_server/src/main.rs new file mode 100644 index 0000000..02139de --- /dev/null +++ b/demo/mm_server/src/main.rs @@ -0,0 +1,49 @@ +mod errors; +mod global_state; +mod payouts; +mod server; + +use crate::global_state::{GlobalState, Stage}; +use common::OutcomeOdds; +use dlctix::secp::Scalar; +use dlctix::EventAnnouncement; + +use std::{ + collections::HashMap, + env, + error::Error, + sync::{Arc, RwLock}, +}; + +fn run_server() -> Result<(), Box> { + let bind_addr = + env::var("MM_SERVER_BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0:1420".to_string()); + + let market_maker_seckey: Scalar = env::var("MM_SECRET_KEY")?.parse()?; + let market_maker_pubkey = market_maker_seckey.base_point_mul(); + + let event: EventAnnouncement = + serde_cbor::from_slice(&hex::decode(env::var("DLC_EVENT_ANNOUNCEMENT_CBOR")?)?)?; + + let odds: OutcomeOdds = + serde_cbor::from_slice(&hex::decode(env::var("DLC_EVENT_ODDS_CBOR")?)?)?; + + let global_state = Arc::new(RwLock::new(GlobalState { + event, + odds, + market_maker_seckey, + market_maker_pubkey, + registrations: HashMap::new(), + stage: Stage::IntentRegistry, + })); + + server::serve(bind_addr, global_state) +} + +fn main() { + if let Err(e) = run_server() { + eprintln!("fatal error: {}", e); + std::process::exit(1); + } + println!("exiting OK"); +} diff --git a/demo/mm_server/src/payouts.rs b/demo/mm_server/src/payouts.rs new file mode 100644 index 0000000..f857c3e --- /dev/null +++ b/demo/mm_server/src/payouts.rs @@ -0,0 +1,303 @@ +use bitcoin::Amount; + +use common::{Intent, OutcomeOdds}; +use dlctix::{Outcome, PayoutWeights, PlayerIndex}; + +use std::collections::BTreeMap; + +pub(crate) fn compute_deposit_and_payout_weights<'i>( + intents: &BTreeMap, + odds: &OutcomeOdds, + all_outcomes: impl IntoIterator, +) -> ( + BTreeMap, + BTreeMap, +) { + let mut outcome_payouts = BTreeMap::::new(); + + // All players for a given outcome are paid out equally. + for (&player_index, intent) in intents { + outcome_payouts + .entry(intent.outcome) + .or_default() + .insert(player_index, 1); + } + + // Count only outcomes which people have wagered on. + let total_odds_weight: u64 = odds + .iter() + .filter_map(|(outcome, &weight)| outcome_payouts.get(outcome).map(|_| weight)) + .sum(); + + // Compute the relative weights for each player to deposit. + let deposit_weights: PayoutWeights = intents + .iter() + .map(|(&player_index, intent)| { + let n_winners = outcome_payouts[&intent.outcome].len() as u64; + let outcome_odds = odds[&intent.outcome]; + let weight = 100_000_000 * outcome_odds / total_odds_weight / n_winners; + (player_index, weight) + }) + .collect(); + + // For any outcomes which nobody wagered on, all deposits will be refunded. + for outcome in all_outcomes { + if !outcome_payouts.contains_key(&outcome) { + outcome_payouts.insert(outcome, deposit_weights.clone()); + } + } + + // Start with the maximum possible pot by summing all players' budgets. + let mut pot_total: Amount = intents.values().map(|intent| intent.budget).sum(); + + let total_deposit_weight: u64 = deposit_weights.values().sum(); + for (player_index, intent) in intents { + let weight = deposit_weights[player_index]; + let suggested_deposit_amount = pot_total * weight / total_deposit_weight; + + // If this player's deposit amount exceeds their budget, scale the pot down until + if suggested_deposit_amount > intent.budget { + pot_total = pot_total * intent.budget.to_sat() / suggested_deposit_amount.to_sat() + } + } + + let deposit_amounts = deposit_weights + .into_iter() + .map(|(player_index, weight)| { + let deposit_amount = pot_total * weight / total_deposit_weight; + (player_index, deposit_amount) + }) + .collect(); + + (deposit_amounts, outcome_payouts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_weight_computations_two_players() { + let intents = BTreeMap::from([ + ( + 0, + Intent { + outcome: Outcome::Attestation(0), + budget: Amount::from_sat(100_000), + }, + ), + ( + 1, + Intent { + outcome: Outcome::Attestation(1), + budget: Amount::from_sat(300_000), + }, + ), + ]); + + let odds = OutcomeOdds::from([ + (Outcome::Attestation(0), 1), + (Outcome::Attestation(1), 2), + (Outcome::Expiry, 1), + ]); + + let all_outcomes = [ + Outcome::Attestation(0), + Outcome::Attestation(1), + Outcome::Attestation(2), + Outcome::Expiry, + ]; + + let expected_deposit_amounts = BTreeMap::from([ + (0, Amount::from_sat(100_000)), + (1, Amount::from_sat(200_000)), + ]); + let expected_outcome_payouts = BTreeMap::from([ + (Outcome::Attestation(0), PayoutWeights::from([(0, 1)])), + (Outcome::Attestation(1), PayoutWeights::from([(1, 1)])), + ( + Outcome::Attestation(2), + PayoutWeights::from([(0, 33333333), (1, 66666666)]), + ), + ( + Outcome::Expiry, + PayoutWeights::from([(0, 33333333), (1, 66666666)]), + ), + ]); + + let (deposit_amounts, outcome_payouts) = + compute_deposit_and_payout_weights(&intents, &odds, all_outcomes); + + assert_eq!(deposit_amounts, expected_deposit_amounts); + assert_eq!(outcome_payouts, expected_outcome_payouts); + } + + #[test] + fn test_weight_computations_three_players_with_equal_buy_in() { + let intents = BTreeMap::from([ + ( + 0, + Intent { + outcome: Outcome::Attestation(0), + budget: Amount::from_sat(100_000), + }, + ), + ( + 1, + Intent { + outcome: Outcome::Attestation(1), + budget: Amount::from_sat(300_000), + }, + ), + ( + 2, + Intent { + outcome: Outcome::Attestation(1), + budget: Amount::from_sat(150_000), + }, + ), + ]); + + let odds = OutcomeOdds::from([ + (Outcome::Attestation(0), 1), + (Outcome::Attestation(1), 2), + (Outcome::Expiry, 1), + ]); + + let all_outcomes = [ + Outcome::Attestation(0), + Outcome::Attestation(1), + Outcome::Attestation(2), + Outcome::Expiry, + ]; + + let expected_deposit_amounts = BTreeMap::from([ + (0, Amount::from_sat(100_000)), + (1, Amount::from_sat(100_000)), + (2, Amount::from_sat(100_000)), + ]); + let expected_outcome_payouts = BTreeMap::from([ + // Player 0 wins alone + (Outcome::Attestation(0), PayoutWeights::from([(0, 1)])), + // Player 1 and 2 win together, splitting the prize + ( + Outcome::Attestation(1), + PayoutWeights::from([(1, 1), (2, 1)]), + ), + // Unexpected outcome; all players refunded + ( + Outcome::Attestation(2), + PayoutWeights::from([(0, 33333333), (1, 33333333), (2, 33333333)]), + ), + ( + Outcome::Expiry, + PayoutWeights::from([(0, 33333333), (1, 33333333), (2, 33333333)]), + ), + ]); + + let (deposit_amounts, outcome_payouts) = + compute_deposit_and_payout_weights(&intents, &odds, all_outcomes); + + assert_eq!(deposit_amounts, expected_deposit_amounts); + assert_eq!(outcome_payouts, expected_outcome_payouts); + } + + #[test] + fn test_weight_computations_five_players() { + let intents = BTreeMap::from([ + ( + 0, + Intent { + outcome: Outcome::Attestation(0), + budget: Amount::from_sat(100_000), + }, + ), + ( + 1, + Intent { + outcome: Outcome::Attestation(0), + budget: Amount::from_sat(200_000), + }, + ), + ( + 2, + Intent { + outcome: Outcome::Attestation(1), + budget: Amount::from_sat(150_000), + }, + ), + ( + 3, + Intent { + outcome: Outcome::Attestation(1), + budget: Amount::from_sat(300_000), + }, + ), + ( + 4, + Intent { + outcome: Outcome::Attestation(2), + budget: Amount::from_sat(500_000), + }, + ), + ]); + + // Odds weight sum: 8 + let odds = OutcomeOdds::from([ + (Outcome::Attestation(0), 1), + (Outcome::Attestation(1), 2), + (Outcome::Attestation(2), 5), + ]); + + let all_outcomes = [ + Outcome::Attestation(0), + Outcome::Attestation(1), + Outcome::Attestation(2), + Outcome::Expiry, + ]; + + // Pot total: 800k + let expected_deposit_amounts = BTreeMap::from([ + // Players 0 and 1 deposit 50k, receiving 400k each on victory (1:8 odds) + (0, Amount::from_sat(50_000)), + (1, Amount::from_sat(50_000)), + // Players 2 and 3 deposit 100k, receiving 400k each on victory (1:4 odds) + (2, Amount::from_sat(100_000)), + (3, Amount::from_sat(100_000)), + // Player 4 deposits 500k, receiving 800k on victory (8:5 odds) + (4, Amount::from_sat(500_000)), + ]); + + let expected_outcome_payouts = BTreeMap::from([ + // Player 0 and 1 and win together, splitting the prize + ( + Outcome::Attestation(0), + PayoutWeights::from([(0, 1), (1, 1)]), + ), + // Player 2 and 3 win together, splitting the prize + ( + Outcome::Attestation(1), + PayoutWeights::from([(2, 1), (3, 1)]), + ), + // Player 4 wins alone + (Outcome::Attestation(2), PayoutWeights::from([(4, 1)])), + // Unexpected outcome; all players refunded + ( + Outcome::Expiry, + PayoutWeights::from([ + (0, 6_250_000), + (1, 6_250_000), + (2, 12_500_000), + (3, 12_500_000), + (4, 62_500_000), + ]), + ), + ]); + + let (deposit_amounts, outcome_payouts) = + compute_deposit_and_payout_weights(&intents, &odds, all_outcomes); + + assert_eq!(deposit_amounts, expected_deposit_amounts); + assert_eq!(outcome_payouts, expected_outcome_payouts); + } +} diff --git a/demo/mm_server/src/server/handshake.rs b/demo/mm_server/src/server/handshake.rs new file mode 100644 index 0000000..70e93c3 --- /dev/null +++ b/demo/mm_server/src/server/handshake.rs @@ -0,0 +1,96 @@ +use bitcoin::Amount; + +use super::SOCKET_DEFAULT_READ_TIMEOUT; +use crate::errors::InvalidInputError; +use crate::global_state::{GlobalState, PlayerRegistration}; +use common::{ClientHello, Intent, ServerHello}; +use dlctix::{hashlock, Player}; + +use std::{error::Error, net, sync::RwLock, time::Duration}; + +const MIN_BUDGET: Amount = Amount::from_sat(70_000); + +pub(crate) fn handshake_player_register( + state: &RwLock, + conn: net::TcpStream, +) -> Result<(), Box> { + let client_hello: ClientHello = serde_cbor::from_reader(&conn)?; + + // Sample a random ticket preimage for this player. + let mut rng = rand::thread_rng(); + let ticket_preimage = hashlock::preimage_random(&mut rng); + let ticket_hash = hashlock::sha256(&ticket_preimage); + + // The player ID is simply the first 16 bytes of the ticket hash. + let player_id = u128::from_be_bytes({ + let mut buf = [0u8; 16]; + buf.copy_from_slice(&ticket_hash[..16]); + buf + }); + + let player = Player { + pubkey: client_hello.player_pubkey, + ticket_hash, + payout_hash: client_hello.payout_hash, + }; + + let server_hello = { + let state_rlock = state.read().unwrap(); + ServerHello { + player_id, + ticket_hash, + market_maker_pubkey: state_rlock.market_maker_pubkey, + event: state_rlock.event.clone(), + odds: state_rlock.odds.clone(), + } + }; + serde_cbor::to_writer(&conn, &server_hello)?; + + // Give the client 2 minutes to compose an Intent. + conn.set_read_timeout(Some(Duration::from_secs(120)))?; + let intent: Intent = serde_cbor::from_reader(&conn)?; + conn.set_read_timeout(Some(SOCKET_DEFAULT_READ_TIMEOUT))?; + + { + // validate intent outcome is acceptable + let state_rlock = state.read().unwrap(); + if !state_rlock.event.is_valid_outcome(&intent.outcome) { + return Err(InvalidInputError( + "player's intended outcome is not valid for this event", + ))?; + } + + if !state_rlock.odds.contains_key(&intent.outcome) { + return Err(InvalidInputError(format!( + "we have no odds for the player's requested outcome '{}'", + intent.outcome + )))?; + } + + if intent.budget < MIN_BUDGET { + return Err(InvalidInputError(format!( + "budget must be at least {}", + MIN_BUDGET + )))?; + } + } + + let registration = PlayerRegistration { + ticket_preimage, + player, + intent, + connection: conn, + }; + + println!( + "registering new player: {}", + serde_json::to_string_pretty(®istration).unwrap() + ); + state + .write() + .unwrap() + .registrations + .insert(player_id, registration); + + Ok(()) +} diff --git a/demo/mm_server/src/server/mod.rs b/demo/mm_server/src/server/mod.rs new file mode 100644 index 0000000..d984e58 --- /dev/null +++ b/demo/mm_server/src/server/mod.rs @@ -0,0 +1,87 @@ +mod handshake; +mod offer_and_ack; + +use crate::errors::WrongStageError; +use crate::global_state::{GlobalState, Stage}; + +use std::{ + error::Error, + net, + sync::{Arc, RwLock}, + thread, + time::Duration, +}; + +pub(crate) const SOCKET_DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const SOCKET_WRITE_TIMEOUT: Duration = Duration::from_secs(8); + +pub(crate) const MIN_PLAYERS: usize = 5; +pub(crate) const PLAYERS_THRESHOLD: usize = 10; + +fn handle_tcp_conn( + state: Arc>, + conn: net::TcpStream, +) -> Result<(), Box> { + conn.set_read_timeout(Some(SOCKET_DEFAULT_READ_TIMEOUT))?; + conn.set_write_timeout(Some(SOCKET_WRITE_TIMEOUT))?; + + { + let state_rlock = state.read().unwrap(); + if state_rlock.stage != Stage::IntentRegistry { + return Err(WrongStageError(state_rlock.stage))?; + } + } + + handshake::handshake_player_register(&state, conn)?; + + // Once we hit the minimum threshold, dispatch offers and then prompt for signatures. + if state.read().unwrap().registrations.len() >= PLAYERS_THRESHOLD { + { + let mut state_wlock = state.write().unwrap(); + state_wlock.stage = Stage::OfferAndAck; + } + + if let Some(accepted_players) = offer_and_ack::offer_and_ack_cycle(&state)? { + // TODO prompt all players for signatures + } + } + + Ok(()) +} + +pub(crate) fn serve( + bind_addr: String, + global_state: Arc>, +) -> Result<(), Box> { + println!("starting listener on {}", bind_addr); + let listener = net::TcpListener::bind(bind_addr)?; + + // TODO use thread pool + println!("awaiting connections..."); + for stream in listener.incoming() { + let conn = match stream { + Ok(conn) => conn, + Err(e) => { + eprintln!("error accepting TCP connection: {}", e); + continue; + } + }; + + match conn.peer_addr() { + Ok(peer_addr) => { + println!("received new TCP connection from {}", peer_addr); + } + Err(e) => { + eprintln!("new TCP connection; unable to get peer IP address: {}", e); + } + } + + let state = Arc::clone(&global_state); + thread::spawn(move || { + if let Err(e) = handle_tcp_conn(state, conn) { + eprintln!("TCP connection handling failure: {}", e); + } + }); + } + Ok(()) +} diff --git a/demo/mm_server/src/server/offer_and_ack.rs b/demo/mm_server/src/server/offer_and_ack.rs new file mode 100644 index 0000000..9889455 --- /dev/null +++ b/demo/mm_server/src/server/offer_and_ack.rs @@ -0,0 +1,86 @@ +use super::{MIN_PLAYERS, SOCKET_DEFAULT_READ_TIMEOUT}; +use crate::global_state::GlobalState; +use common::{ClientOfferAck, PlayerID, ServerOffer}; + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + net, + sync::{mpsc, Arc, RwLock}, + thread, + time::Duration, +}; + +fn send_offer_and_receive_ack(conn: &net::TcpStream, offer: &ServerOffer) -> ClientOfferAck { + let client_offer_ack: ClientOfferAck = serde_cbor::to_writer(conn, offer) + .and_then(|_| { + // Give the user 2 minutes to ACK the offer. Fall back to rejection. + conn.set_read_timeout(Some(Duration::from_secs(120))) + .unwrap(); + serde_cbor::from_reader(conn) + }) + .unwrap_or(ClientOfferAck::Reject); + + // Reset the read timeout. + conn.set_read_timeout(Some(SOCKET_DEFAULT_READ_TIMEOUT)) + .unwrap(); + + client_offer_ack +} + +pub(crate) fn offer_and_ack_cycle( + state: &Arc>, +) -> Result>, Box> { + let offers = state.read().unwrap().construct_offers(); + + let (ack_sender, ack_receiver) = mpsc::channel(); + + for (player_id, offer) in offers { + let state = Arc::clone(state); + let ack_sender = ack_sender.clone(); + + thread::spawn(move || { + let state_rlock = state.read().unwrap(); + let conn = &state_rlock.registrations[&player_id].connection; + let client_offer_ack = send_offer_and_receive_ack(conn, &offer); + ack_sender + .send((player_id, offer, client_offer_ack)) + .unwrap(); + }); + } + drop(ack_sender); // Otherwise the ack_receiver channel will stay open. + + let mut accepted_players = BTreeMap::new(); + let mut rejected_players = BTreeSet::new(); + + while let Ok((player_id, offer, client_offer_ack)) = ack_receiver.recv() { + match client_offer_ack { + ClientOfferAck::Accept => { + accepted_players.insert(player_id, offer); + } + ClientOfferAck::Reject => { + rejected_players.insert(player_id); + } + }; + } + + if rejected_players.len() > 0 { + // Disconnect rejecting players. + { + let mut state_wlock = state.write().unwrap(); + for player_id in rejected_players { + state_wlock.registrations.remove(&player_id); + } + + // We don't have enough accepting players to try again. + if state_wlock.registrations.len() < MIN_PLAYERS { + return Ok(None); + } + } + + // Retry with accepting players. + return offer_and_ack_cycle(state); + } + + Ok(Some(accepted_players)) +}