├── .github └── workflows │ ├── clippy.yml │ ├── format.yml │ └── test.yml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── docs └── README.md ├── src ├── amount_ext.rs ├── betting │ ├── bet.rs │ ├── bet_args.rs │ ├── ciphertext.rs │ ├── joint_output.rs │ ├── mod.rs │ ├── offer.rs │ ├── proposal.rs │ ├── randomize.rs │ ├── wallet_impls │ │ ├── mod.rs │ │ ├── offer.rs │ │ ├── proposal.rs │ │ ├── spend_won.rs │ │ ├── state_machine.rs │ │ └── take_offer.rs │ └── witness.rs ├── bin │ └── gun.rs ├── bip85.rs ├── change.rs ├── cmd │ ├── bet.rs │ ├── config.rs │ ├── mod.rs │ ├── oracle.rs │ ├── setup.rs │ └── wallet.rs ├── config.rs ├── database.rs ├── ecdh.rs ├── encode.rs ├── fee_spec.rs ├── keychain.rs ├── lib.rs ├── psbt_ext.rs ├── serde_hacks.rs ├── signers.rs └── wallet.rs └── tests └── end_to_end.rs /.github/workflows/clippy.yml: -------------------------------------------------------------------------------- 1 | name: Clippy check 2 | on: [ pull_request ] 3 | jobs: 4 | clippy_check: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - uses: actions-rs/toolchain@v1 9 | with: 10 | toolchain: stable 11 | components: clippy 12 | override: true 13 | - uses: actions-rs/clippy-check@v1 14 | with: 15 | # https://github.com/actions-rs/clippy-check/issues/2#issuecomment-538671632 16 | args: -- -D warnings 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Rustfmt 2 | on: [ pull_request ] 3 | 4 | jobs: 5 | fmt: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions-rs/toolchain@v1 10 | with: 11 | profile: minimal 12 | toolchain: stable 13 | override: true 14 | components: rustfmt 15 | - uses: actions-rs/cargo@v1 16 | with: 17 | command: fmt 18 | args: --all -- --check 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | toolchain: [ 11 | { rust: "1.56.0", args: "" }, 12 | # { rust: "nightly", args: "--features=nightly" } # disable nightly temporarily 13 | ] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: ${{ matrix.toolchain.rust }} 19 | override: true 20 | profile: minimal 21 | - uses: Swatinem/rust-cache@v1.2.0 22 | - run: cargo test ${{ matrix.toolchain.args }} --release --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Crate" 2 | edition = "2018" 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.6.1 4 | 5 | - Fix missing newlines bug 6 | - Add `k` and `M` satoshi denominations and allow `_` in values 7 | 8 | ## v0.6.0 9 | 10 | - Add support for signet 11 | - Add support for coldcard bip84 🎉 12 | - Add support for BIP39 passphrases 13 | - Add `--internal` and `--all` flags to `gun address list` 14 | - Add `txos` column to `gun address list` 15 | - Fix `gun split` creating too many address gaps 16 | 17 | ## v0.5.0 18 | 19 | - Upgrade to new bdk commit 20 | - Switch over to using ureq as http backend exclusively 21 | 22 | ## v0.4.0 23 | 24 | - Upgrade to base2048 v2 which removes right-to-left characters which made copy pasting very difficult. 25 | - Less mandatory arguments more prompting when betting. 26 | 27 | ## v0.3.0 28 | 29 | ?? 30 | 31 | 32 | ## v0.2.0 33 | 34 | - Fix unreliable state machine that could cause loss of funds. 35 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gun_wallet" 3 | version = "0.6.1" 4 | authors = ["LLFourn "] 5 | edition = "2021" 6 | rust-version = "1.56" 7 | license = "0BSD" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | dirs = "3.0" 13 | bdk = { git = "https://github.com/llfourn/bdk", rev = "198b0ee597fded5c5be43ff0f960f8bcdb3a0b73", features = ["key-value-db", "use-esplora-ureq", "compiler", "keys-bip39"], default-features = false } 14 | # bdk = { path = "../bdk", features = ["key-value-db", "esplora", "use-esplora-ureq", "compiler", "keys-bip39"], default-features = false } 15 | serde_json = "1.0" 16 | olivia_core = { git = "https://github.com/llfourn/olivia", rev = "15c35aad66c418e05046a57c72cc90ce5f351620" } 17 | olivia_secp256k1 = { git = "https://github.com/llfourn/olivia", features = ["libsecp_compat"], rev = "15c35aad66c418e05046a57c72cc90ce5f351620" } 18 | olivia_describe = { git = "https://github.com/llfourn/olivia", rev = "15c35aad66c418e05046a57c72cc90ce5f351620" } 19 | sha2 = "0.9" 20 | base2048 = "2.0.2" 21 | chacha20 = { version = "0.7", features = ["rng", "cipher"] } 22 | serde = { version = "1" } 23 | bincode = "1.3" 24 | anyhow = "1" 25 | thiserror = "1.0" 26 | rand = { version = "0.8", features = ["getrandom"] } 27 | rpassword = "5" 28 | structopt = "0.3" 29 | miniscript = { version = "6", features = ["serde"] } 30 | term-table = { version = "1", default-features = false } 31 | ureq = { version = "2", features = ["json"] } 32 | url = "2" 33 | 34 | [features] 35 | nightly = ["olivia_secp256k1/nightly"] 36 | 37 | 38 | [dev-dependencies] 39 | rand = "0.8" 40 | bdk = { git = "https://github.com/llfourn/bdk", rev = "198b0ee597fded5c5be43ff0f960f8bcdb3a0b73", features = ["key-value-db", "use-esplora-ureq", "compiler", "keys-bip39", "test-esplora", "test-blockchains"], default-features = false } 41 | # bdk = { path = "../bdk", features = ["key-value-db", "esplora", "use-esplora-ureq","compiler", "keys-bip39", "test-esplora", "test-blockchains"], default-features = false } 42 | 43 | 44 | [patch.crates-io] 45 | bitcoin = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "0e2e55971275da64ceb62e8991a0a5fa962cb8b1" } 46 | miniscript = { git = "https://github.com/rust-bitcoin/rust-miniscript.git", rev = "f3c38b8cc04fed0a68f4d6074d8c30f6912d958f" } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Lloyd Fournier 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 7 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 8 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 9 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 10 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 11 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 12 | PERFORMANCE OF THIS SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./docs/README.md -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Go Up Number!   [![actions_badge]][actions_url] 2 | 3 | [actions_badge]: https://github.com/llfourn/gun/workflows/Tests/badge.svg 4 | [actions_url]: https://github.com/llfourn/gun/actions?query=workflow%3ATests 5 | 6 | `gun` is a CLI Bitcoin wallet for plebs, degenerates and revolutionaries. 7 | 8 | Its distinguishing feature is the ability to do [peer-to-peer betting](https://gun.fun/bet/betting.html). 9 | 10 | See [gun.fun](https://gun.fun) for full documentation. 11 | 12 | **⚠ WARNING EXPERIMENTAL** 13 | 14 | The wallet is beta quality. 15 | It is buggy and is missing features. 16 | The underlying wallet functionality is built with the awesome [Bitcoin Dev Kit](https://bitcoindevkit.org) but the betting functionality is freshly engineered. 17 | Only put into it what you are willing to lose. 18 | Thanks for testing this for me and thank you in advance for any coins you sacrifice along the way. 19 | 20 | ## Quick Install 21 | 22 | ``` sh 23 | git clone https://github.com/LLFourn/gun 24 | cd gun 25 | cargo install --path . 26 | # Make sure ~/.cargo/bin is in your $PATH 27 | ``` 28 | 29 | The *minimum supported rust version* for `gun` is `1.56.0`. 30 | 31 | To setup your wallet see [`gun setup --help`](https://gun.fun/setup/setup.html). 32 | 33 | ## Community 34 | 35 | 36 | 37 | Join us on [Discord](https://discord.gg/MB27cDJyrR) 38 | 39 | 40 | 41 | 42 | [BIP84]: https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki 43 | -------------------------------------------------------------------------------- /src/amount_ext.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use bdk::bitcoin::{Amount, Denomination}; 3 | use std::str::FromStr; 4 | 5 | pub trait FromCliStr: Sized { 6 | fn from_cli_str(string: &str) -> anyhow::Result; 7 | } 8 | 9 | impl FromCliStr for Amount { 10 | fn from_cli_str(string: &str) -> anyhow::Result { 11 | match string.rfind(char::is_numeric) { 12 | Some(i) => { 13 | let value = &string[..=i].replace('_', ""); 14 | let (amount, shift_right) = match &string[(i + 1)..] { 15 | "k" => (Amount::from_str_in(value, Denomination::Bitcoin)?, 100_000), 16 | "M" => (Amount::from_str_in(value, Denomination::Bitcoin)?, 100), 17 | "" => (Amount::from_str_in(value, Denomination::Satoshi)?, 1), 18 | denom => { 19 | let denom = Denomination::from_str(denom)?; 20 | (Amount::from_str_in(value, denom)?, 1) 21 | } 22 | }; 23 | 24 | Ok(amount.checked_div(shift_right).ok_or(anyhow!( 25 | "{} has too many decimal places for that denomination", 26 | string 27 | ))?) 28 | } 29 | None => Err(anyhow!("'{}' is not a Bitcoin amount", string)), 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod test { 36 | use super::*; 37 | #[test] 38 | #[should_panic( 39 | expected = "called `Result::unwrap()` on an `Err` value: unknown denomination: kBTC" 40 | )] 41 | fn test_parse_failing_btc() { 42 | Amount::from_cli_str("1kBTC").unwrap(); 43 | } 44 | 45 | #[test] 46 | fn test_parse_value_btc() { 47 | assert_eq!( 48 | Amount::from_cli_str("0.01BTC").unwrap(), 49 | Amount::from_sat(1_000_000) 50 | ); 51 | } 52 | 53 | #[test] 54 | fn test_parse_value_sat() { 55 | assert_eq!( 56 | Amount::from_cli_str("100sat").unwrap(), 57 | Amount::from_sat(100) 58 | ); 59 | } 60 | 61 | #[test] 62 | fn test_parse_k_suffix() { 63 | assert_eq!( 64 | Amount::from_cli_str("15k").unwrap(), 65 | Amount::from_sat(15_000) 66 | ); 67 | } 68 | 69 | #[test] 70 | fn test_parse_m_suffix() { 71 | assert_eq!( 72 | Amount::from_cli_str("1.5M").unwrap(), 73 | Amount::from_sat(1_500_000) 74 | ); 75 | } 76 | 77 | #[test] 78 | fn test_strip_underscores() { 79 | assert_eq!( 80 | Amount::from_cli_str("1_000_000").unwrap(), 81 | Amount::from_sat(1_000_000) 82 | ); 83 | } 84 | 85 | #[test] 86 | fn test_strip_underscores_with_denom() { 87 | assert_eq!( 88 | Amount::from_cli_str("1_000k").unwrap(), 89 | Amount::from_sat(1_000_000) 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/betting/bet.rs: -------------------------------------------------------------------------------- 1 | use crate::betting::*; 2 | use bdk::bitcoin::{ 3 | self, util::psbt::PartiallySignedTransaction as Psbt, Amount, OutPoint, Transaction, Txid, 4 | }; 5 | use olivia_core::{OracleEvent, OracleId, Outcome}; 6 | use olivia_secp256k1::Secp256k1; 7 | 8 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 9 | pub struct Bet { 10 | pub psbt: Psbt, 11 | pub my_input_indexes: Vec, 12 | pub vout: u32, 13 | pub joint_output: JointOutput, 14 | pub oracle_id: OracleId, 15 | pub oracle_event: OracleEvent, 16 | #[serde(with = "bitcoin::util::amount::serde::as_sat")] 17 | pub local_value: Amount, 18 | #[serde(with = "bitcoin::util::amount::serde::as_sat")] 19 | pub joint_output_value: Amount, 20 | pub i_chose_right: bool, 21 | #[serde(default)] 22 | pub tags: Vec, 23 | } 24 | 25 | impl Bet { 26 | pub fn outpoint(&self) -> OutPoint { 27 | OutPoint { 28 | txid: self.tx().txid(), 29 | vout: self.vout, 30 | } 31 | } 32 | pub fn my_inputs(&self) -> Vec { 33 | self.my_input_indexes 34 | .iter() 35 | .map(|i| self.tx().input[*i as usize].previous_output) 36 | .collect() 37 | } 38 | 39 | pub fn their_inputs(&self) -> Vec { 40 | self.tx() 41 | .input 42 | .iter() 43 | .enumerate() 44 | .filter(|(i, _)| !self.my_input_indexes.contains(&(*i as u32))) 45 | .map(|(_, input)| input.previous_output) 46 | .collect() 47 | } 48 | 49 | pub fn tx(&self) -> Transaction { 50 | self.psbt.clone().extract_tx() 51 | } 52 | 53 | pub fn my_outcome(&self) -> Outcome { 54 | Outcome { 55 | id: self.oracle_event.event.id.clone(), 56 | value: self.i_chose_right as u64, 57 | } 58 | } 59 | 60 | pub fn input_outpoints(&self) -> Vec { 61 | self.psbt 62 | .unsigned_tx 63 | .input 64 | .iter() 65 | .map(|x| x.previous_output) 66 | .collect() 67 | } 68 | } 69 | 70 | /// newtype to mark a bet that doesn't have all its PSBT inputs signed 71 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 72 | pub struct OfferedBet(pub Bet); 73 | 74 | impl OfferedBet { 75 | pub fn add_counterparty_sigs(self, tx: Transaction) -> Bet { 76 | let mut bet = self.0; 77 | assert_eq!( 78 | tx.txid(), 79 | bet.tx().txid(), 80 | "the transactions must be the same to add_counterparty_sigs" 81 | ); 82 | for (txin, psbt_input) in tx.input.into_iter().zip(bet.psbt.inputs.iter_mut()) { 83 | psbt_input 84 | .final_script_witness 85 | .get_or_insert(txin.witness.to_vec()); 86 | } 87 | 88 | bet 89 | } 90 | } 91 | 92 | impl std::ops::Deref for OfferedBet { 93 | type Target = Bet; 94 | fn deref(&self) -> &Self::Target { 95 | &self.0 96 | } 97 | } 98 | 99 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 100 | #[serde(tag = "state")] 101 | pub enum BetState { 102 | /// You've made a proposal 103 | Proposed { local_proposal: LocalProposal }, 104 | /// You've made an offer 105 | Offered { 106 | bet: OfferedBet, 107 | encrypted_offer: Ciphertext, 108 | }, 109 | /// The bet tx has been included in mempool or chain 110 | Included { 111 | bet: Bet, 112 | // None implies in mempool 113 | height: Option, 114 | }, 115 | /// You won the bet 116 | Won { 117 | bet: Bet, 118 | secret_key: bitcoin::secp256k1::SecretKey, 119 | attestation: Attestation, 120 | }, 121 | /// You lost the bet 122 | Lost { bet: Bet, attestation: Attestation }, 123 | /// A Tx spending the bet output has been included in mempool or chain. 124 | Claimed { 125 | bet: Bet, 126 | txid: Txid, 127 | // None implies calim tx is in mempool 128 | height: Option, 129 | secret_key: bitcoin::secp256k1::SecretKey, 130 | attestation: Attestation, 131 | }, 132 | /// There is a tx spending one of the bet inputs that is *not* the bet tx. 133 | Canceled { 134 | pre_cancel: BetOrProp, 135 | bet_spent_vin: u32, 136 | cancel_txid: Txid, 137 | cancel_vin: u32, 138 | /// Height of cancel tx None implies cancel tx is in mempool 139 | height: Option, 140 | /// Whether the cancel_txid seems to be ours 141 | i_intend_cancel: bool, 142 | }, 143 | } 144 | 145 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 146 | pub enum BetOrProp { 147 | Bet(Bet), 148 | Proposal(LocalProposal), 149 | OfferedBet { 150 | bet: OfferedBet, 151 | encrypted_offer: Ciphertext, 152 | }, 153 | } 154 | 155 | impl BetOrProp { 156 | pub fn inputs(&self) -> Vec { 157 | match self { 158 | BetOrProp::Bet(bet) 159 | | BetOrProp::OfferedBet { 160 | bet: OfferedBet(bet), 161 | .. 162 | } => bet 163 | .tx() 164 | .input 165 | .iter() 166 | .map(|input| input.previous_output) 167 | .collect(), 168 | BetOrProp::Proposal(local_proposal) => local_proposal.proposal.inputs.clone(), 169 | } 170 | } 171 | } 172 | 173 | impl BetState { 174 | pub fn name(&self) -> &'static str { 175 | use BetState::*; 176 | match self { 177 | Proposed { .. } => "proposed", 178 | Offered { .. } => "offered", 179 | Included { height: None, .. } => "unconfirmed", 180 | Included { 181 | height: Some(_), .. 182 | } => "confirmed", 183 | Won { .. } => "won", 184 | Lost { .. } => "lost", 185 | Claimed { height: None, .. } => "claiming", 186 | Claimed { 187 | height: Some(_), .. 188 | } => "claimed", 189 | Canceled { height: None, .. } => "canceling", 190 | Canceled { 191 | height: Some(_), .. 192 | } => "canceled", 193 | } 194 | } 195 | 196 | pub fn reserved_utxos(&self) -> Vec { 197 | use BetState::*; 198 | match self { 199 | Proposed { local_proposal } => local_proposal 200 | .proposal 201 | .inputs 202 | .iter() 203 | .map(Clone::clone) 204 | .collect(), 205 | Offered { 206 | bet: OfferedBet(bet), 207 | .. 208 | } 209 | | Included { bet, .. } => bet 210 | .my_input_indexes 211 | .iter() 212 | .map(|i| bet.tx().input[*i as usize].previous_output) 213 | .collect(), 214 | _ => vec![], 215 | } 216 | } 217 | 218 | pub fn into_bet_or_prop(self) -> BetOrProp { 219 | match self { 220 | BetState::Proposed { local_proposal } => BetOrProp::Proposal(local_proposal), 221 | BetState::Offered { 222 | bet, 223 | encrypted_offer, 224 | } => BetOrProp::OfferedBet { 225 | bet, 226 | encrypted_offer, 227 | }, 228 | BetState::Canceled { pre_cancel, .. } => pre_cancel, 229 | BetState::Included { bet, .. } 230 | | BetState::Won { bet, .. } 231 | | BetState::Lost { bet, .. } 232 | | BetState::Claimed { bet, .. } => BetOrProp::Bet(bet), 233 | } 234 | } 235 | 236 | pub fn tags_mut(&mut self) -> &mut Vec { 237 | match self { 238 | BetState::Proposed { local_proposal } 239 | | BetState::Canceled { 240 | pre_cancel: BetOrProp::Proposal(local_proposal), 241 | .. 242 | } => &mut local_proposal.tags, 243 | BetState::Offered { 244 | bet: OfferedBet(bet), 245 | .. 246 | } 247 | | BetState::Included { bet, .. } 248 | | BetState::Won { bet, .. } 249 | | BetState::Lost { bet, .. } 250 | | BetState::Claimed { bet, .. } 251 | | BetState::Canceled { 252 | pre_cancel: 253 | BetOrProp::OfferedBet { 254 | bet: OfferedBet(bet), 255 | .. 256 | } 257 | | BetOrProp::Bet(bet), 258 | .. 259 | } => &mut bet.tags, 260 | } 261 | } 262 | 263 | pub fn relies_on_protocol_secret(&self) -> bool { 264 | matches!(self, BetState::Proposed { .. }) 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/betting/bet_args.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, database::GunDatabase, ValueChoice}; 2 | use anyhow::{anyhow, Context}; 3 | use bdk::{ 4 | bitcoin::Amount, 5 | database::BatchDatabase, 6 | wallet::{coin_selection::CoinSelectionAlgorithm, tx_builder::TxBuilderContext}, 7 | TxBuilder, 8 | }; 9 | 10 | // TODO remove autism 11 | 12 | #[derive(Clone, Debug)] 13 | pub struct BetArgs<'a, 'b> { 14 | pub value: ValueChoice, 15 | pub may_overlap: &'a [BetId], 16 | pub must_overlap: &'b [BetId], 17 | pub tags: Vec, 18 | } 19 | 20 | impl Default for BetArgs<'_, '_> { 21 | fn default() -> Self { 22 | static EMPTY: [BetId; 0] = []; 23 | BetArgs { 24 | value: ValueChoice::Amount(Amount::ZERO), 25 | may_overlap: &EMPTY, 26 | must_overlap: &EMPTY, 27 | tags: vec![], 28 | } 29 | } 30 | } 31 | 32 | impl BetArgs<'_, '_> { 33 | pub fn apply_args, Ctx: TxBuilderContext>( 34 | &self, 35 | gun_db: &GunDatabase, 36 | builder: &mut TxBuilder, 37 | ) -> anyhow::Result<()> { 38 | builder.unspendable(gun_db.currently_used_utxos(self.may_overlap)?); 39 | for bet_id in self.must_overlap { 40 | let bet = gun_db.get_entity::(*bet_id)?.ok_or_else(|| { 41 | anyhow!("bet {} that we must overlap with does not exist", bet_id) 42 | })?; 43 | for input in bet.reserved_utxos() { 44 | builder.add_utxo(input).with_context(|| { 45 | format!("adding utxo {} for 'must_overlap' with {}", input, bet_id) 46 | })?; 47 | } 48 | } 49 | 50 | Ok(()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/betting/ciphertext.rs: -------------------------------------------------------------------------------- 1 | use chacha20::cipher::StreamCipher; 2 | use olivia_secp256k1::fun::{marker::EvenY, Point}; 3 | use std::str::FromStr; 4 | use crate::betting::Offer; 5 | 6 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 7 | pub struct Offer { 8 | pub inputs: Vec, 9 | pub change: Option, 10 | pub choose_right: bool, 11 | #[serde(with = "bitcoin::util::amount::serde::as_sat")] 12 | pub value: Amount, 13 | } 14 | 15 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] 16 | pub struct Ciphertext { 17 | pub public_key: Point, 18 | pub encrypted_bytes: Vec, 19 | } 20 | 21 | impl Ciphertext { 22 | pub fn create( 23 | public_key: Point, 24 | cipher: &mut impl StreamCipher, 25 | plaintext: Plaintext, 26 | ) -> Self { 27 | let mut encrypted_bytes = crate::encode::serialize(&plaintext); 28 | cipher.apply_keystream(&mut encrypted_bytes); 29 | Self { 30 | public_key, 31 | encrypted_bytes, 32 | } 33 | } 34 | 35 | pub fn to_string(&self) -> String { 36 | crate::encode::serialize_base2048(self) 37 | } 38 | 39 | pub fn to_string_padded( 40 | &self, 41 | pad_to: usize, 42 | pad_cipher: &mut impl StreamCipher, 43 | ) -> (String, Option) { 44 | let mut bytes = crate::encode::serialize(self); 45 | let mut overflow = None; 46 | if bytes.len() < pad_to { 47 | let mut padding = vec![0u8; pad_to - bytes.len()]; 48 | pad_cipher.apply_keystream(&mut padding); 49 | bytes.append(&mut padding); 50 | } else if bytes.len() > pad_to { 51 | overflow = Some(bytes.len() - pad_to); 52 | } 53 | 54 | (base2048::encode(&bytes), overflow) 55 | } 56 | 57 | pub fn decrypt(&self, cipher: &mut impl StreamCipher) -> anyhow::Result { 58 | let mut plaintext = self.encrypted_bytes.clone(); 59 | cipher.apply_keystream(&mut plaintext); 60 | Ok(crate::encode::deserialize::<Plaintext>(&plaintext)?) 61 | } 62 | } 63 | 64 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] 65 | pub enum Plaintext { 66 | Offerv1 { 67 | offer: Offer, 68 | message: Option<String>, 69 | }, 70 | Messagev1(String), 71 | } 72 | 73 | impl Plaintext { 74 | pub fn into_offer(self) -> Offer { 75 | match self { 76 | Self::Offerv1 { offer, .. } => offer, 77 | _ => panic!("expected offer"), 78 | } 79 | } 80 | } 81 | 82 | impl FromStr for Ciphertext { 83 | type Err = crate::encode::DecodeError; 84 | 85 | fn from_str(s: &str) -> Result<Self, Self::Err> { 86 | crate::encode::deserialize_base2048(s) 87 | } 88 | } 89 | 90 | #[cfg(test)] 91 | mod test { 92 | use super::*; 93 | use crate::keychain::KeyPair; 94 | use bdk::bitcoin::{Address, OutPoint}; 95 | use chacha20::{cipher::NewCipher, ChaCha20}; 96 | use core::str::FromStr; 97 | 98 | fn test_offer() -> (Point<EvenY>, Offer) { 99 | let offer_keypair = KeyPair::from_slice(&[42u8; 32]).unwrap(); 100 | let public_key = offer_keypair.public_key; 101 | ( 102 | public_key, 103 | Offer { 104 | inputs: vec![ 105 | SignedInput { 106 | outpoint: OutPoint::default(), 107 | witness: Witness::P2wpkh { 108 | key: Point::random(&mut rand::thread_rng()).into(), 109 | signature: ecdsa_fun::Signature::from_bytes([43u8; 64]).unwrap(), 110 | }, 111 | }, 112 | SignedInput { 113 | outpoint: OutPoint::default(), 114 | witness: Witness::P2wpkh { 115 | key: Point::random(&mut rand::thread_rng()).into(), 116 | signature: ecdsa_fun::Signature::from_bytes([43u8; 64]).unwrap(), 117 | }, 118 | }, 119 | ], 120 | change: None, 121 | choose_right: false, 122 | value: Amount::from_str_with_denomination("1 BTC").unwrap(), 123 | }, 124 | ) 125 | } 126 | 127 | #[test] 128 | pub fn encrypt_decrypt_roundtrip() { 129 | let (public_key, offer) = test_offer(); 130 | let mut cipher1 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 131 | let mut cipher2 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 132 | 133 | let encrypted_offer = Ciphertext::create( 134 | public_key, 135 | &mut cipher1, 136 | Plaintext::Offerv1 { 137 | offer: offer.clone(), 138 | message: None, 139 | }, 140 | ); 141 | 142 | assert_eq!( 143 | encrypted_offer.decrypt(&mut cipher2).unwrap().into_offer(), 144 | offer 145 | ); 146 | } 147 | 148 | #[test] 149 | fn offer_with_message_attached() { 150 | let (public_key, offer) = test_offer(); 151 | let mut cipher1 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 152 | let mut cipher2 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 153 | 154 | let encrypted_offer = Ciphertext::create( 155 | public_key, 156 | &mut cipher1, 157 | Plaintext::Offerv1 { 158 | offer: offer.clone(), 159 | message: Some("a message".into()), 160 | }, 161 | ); 162 | 163 | if let Plaintext::Offerv1 { 164 | offer: decrypted_offer, 165 | message, 166 | } = encrypted_offer.decrypt(&mut cipher2).unwrap() 167 | { 168 | assert_eq!(decrypted_offer, offer); 169 | assert_eq!(message, Some("a message".into())); 170 | } else { 171 | panic!("expected offer"); 172 | } 173 | } 174 | 175 | #[test] 176 | pub fn encrypt_decrypt_padded_offer_of_different_sizes() { 177 | let (public_key, offer) = test_offer(); 178 | let encrypted_offer1 = { 179 | let mut cipher1 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 180 | let mut cipher2 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 181 | 182 | let encrypted_offer = Ciphertext::create( 183 | public_key, 184 | &mut cipher1, 185 | Plaintext::Offerv1 { 186 | offer: offer.clone(), 187 | message: None, 188 | }, 189 | ); 190 | let (enc_string_offer, _) = encrypted_offer.to_string_padded(385, &mut cipher1); 191 | let decrypted_offer = Ciphertext::from_str(&enc_string_offer) 192 | .unwrap() 193 | .decrypt(&mut cipher2) 194 | .unwrap() 195 | .into_offer(); 196 | assert_eq!(decrypted_offer, offer); 197 | enc_string_offer 198 | }; 199 | 200 | let encrypted_offer2 = { 201 | let mut cipher1 = ChaCha20::new(&[3u8; 32].into(), &[2u8; 12].into()); 202 | let mut cipher2 = ChaCha20::new(&[3u8; 32].into(), &[2u8; 12].into()); 203 | 204 | let mut offer = offer.clone(); 205 | offer.change = Some(Change::new( 206 | 5_000, 207 | Address::from_str("bc1qwxhv5aqc6xahxedh7m2wm333lgkjpmllz4j248") 208 | .unwrap() 209 | .script_pubkey(), 210 | )); 211 | let encrypted_offer = Ciphertext::create( 212 | public_key, 213 | &mut cipher1, 214 | Plaintext::Offerv1 { 215 | offer: offer.clone(), 216 | message: None, 217 | }, 218 | ); 219 | let (enc_string_offer, _) = encrypted_offer.to_string_padded(385, &mut cipher1); 220 | let decrypted_offer = Ciphertext::from_str(&enc_string_offer) 221 | .unwrap() 222 | .decrypt(&mut cipher2) 223 | .unwrap() 224 | .into_offer(); 225 | assert_eq!(decrypted_offer, offer); 226 | enc_string_offer 227 | }; 228 | 229 | assert_eq!( 230 | encrypted_offer1.chars().count(), 231 | encrypted_offer2.chars().count(), 232 | ); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/betting/joint_output.rs: -------------------------------------------------------------------------------- 1 | use bdk::{ 2 | bitcoin::{self, PublicKey}, 3 | descriptor::ExtendedDescriptor, 4 | keys::DescriptorSinglePub, 5 | miniscript::{descriptor::Wsh, policy::concrete::Policy, Descriptor, DescriptorPublicKey}, 6 | }; 7 | use olivia_secp256k1::fun::{g, marker::*, s, Point, Scalar, G}; 8 | use std::convert::{Infallible, TryInto}; 9 | 10 | use super::randomize::Randomize; 11 | 12 | #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq)] 13 | pub enum Either<T> { 14 | Left(T), 15 | Right(T), 16 | } 17 | 18 | impl<T> Either<T> { 19 | pub fn swap(self) -> Self { 20 | match self { 21 | Either::Left(t) => Either::Right(t), 22 | Either::Right(t) => Either::Left(t), 23 | } 24 | } 25 | 26 | pub fn unwrap(&self) -> &T { 27 | match &self { 28 | Either::Left(t) => t, 29 | Either::Right(t) => t, 30 | } 31 | } 32 | } 33 | 34 | #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq)] 35 | pub struct JointOutput { 36 | pub output_keys: [Point; 2], 37 | pub my_key: Either<Scalar>, 38 | pub swapped: bool, 39 | } 40 | 41 | impl JointOutput { 42 | pub fn new( 43 | public_keys: [Point<EvenY>; 2], 44 | my_key: Either<Scalar>, 45 | anticipated_signatures: [Point<impl PointType, Public, Zero>; 2], 46 | offer_choose_right: bool, 47 | Randomize { 48 | r1, 49 | r2, 50 | swap_points, 51 | }: Randomize, 52 | ) -> Self { 53 | let (left, right) = (anticipated_signatures[0], anticipated_signatures[1]); 54 | let (proposal_key, offer_key) = (&public_keys[0], &public_keys[1]); 55 | 56 | // These unwraps are safe -- we added r1 and r2 to the sum which are both functions of offer_key and 57 | // proposal_key it will never add up to zero. 58 | let output_keys = match offer_choose_right { 59 | false => vec![ 60 | g!(proposal_key + right + r1 * G) 61 | .mark::<(Normal, NonZero)>() 62 | .unwrap(), 63 | g!(offer_key + left + r2 * G) 64 | .mark::<(Normal, NonZero)>() 65 | .unwrap(), 66 | ], 67 | true => vec![ 68 | g!(proposal_key + left + r1 * G) 69 | .mark::<(Normal, NonZero)>() 70 | .unwrap(), 71 | { 72 | let point = g!(offer_key + right + r2 * G); 73 | point.mark::<(Normal, NonZero)>().unwrap() 74 | }, 75 | ], 76 | }; 77 | 78 | let my_key = match my_key { 79 | Either::Left(key) => { 80 | debug_assert!(&g!(key * G) == proposal_key, "secret key wasn't correct"); 81 | Either::Left(s!(key + r1).mark::<NonZero>().unwrap()) 82 | } 83 | Either::Right(key) => { 84 | debug_assert!(&g!(key * G) == offer_key, "secret key wasn't correct"); 85 | Either::Right(s!(key + r2).mark::<NonZero>().unwrap()) 86 | } 87 | }; 88 | 89 | Self { 90 | output_keys: output_keys.try_into().unwrap(), 91 | my_key, 92 | swapped: swap_points, 93 | } 94 | } 95 | 96 | pub fn policy(&self) -> Policy<bitcoin::PublicKey> { 97 | let keys = &match self.swapped { 98 | false => self.output_keys, 99 | true => [self.output_keys[1], self.output_keys[0]], 100 | }; 101 | 102 | Policy::<bitcoin::PublicKey>::Or( 103 | keys.iter() 104 | .map(|key| { 105 | ( 106 | 1, 107 | Policy::Key(PublicKey { 108 | compressed: true, 109 | key: (*key).into(), 110 | }), 111 | ) 112 | }) 113 | .collect(), 114 | ) 115 | } 116 | 117 | // pub fn compute_privkey<B: Blockchain>( 118 | // &self, 119 | // sig_scalar: Either, 120 | // ) -> anyhow::Result<SecretKey> { 121 | // let (completed_key, public_key) = match &self.my_key { 122 | // Either::Left(key) => (s!(key + sig_scalar), self.output_keys[0]), 123 | // Either::Right(key) => (s!(key + sig_scalar), self.output_keys[1]), 124 | // }; 125 | 126 | // if g!(completed_key * G) != public_key { 127 | // return Err(anyhow!("oracle's scalar does not much what was expected")); 128 | // } 129 | 130 | // Ok(completed_key 131 | // .mark::<NonZero>() 132 | // .expect("must not be zero since it was equal to the output key") 133 | // .into()) 134 | // } 135 | 136 | pub fn my_point(&self) -> &Point { 137 | match self.my_key { 138 | Either::Left(_) => &self.output_keys[0], 139 | Either::Right(_) => &self.output_keys[1], 140 | } 141 | } 142 | 143 | pub fn wallet_descriptor(&self) -> ExtendedDescriptor { 144 | let compiled_policy = self 145 | .policy() 146 | .translate_pk(&mut |pk: &bitcoin::PublicKey| -> Result<_, Infallible> { 147 | Ok(DescriptorPublicKey::SinglePub(DescriptorSinglePub { 148 | origin: None, 149 | key: *pk, 150 | })) 151 | }) 152 | .unwrap() 153 | .compile() 154 | .unwrap(); 155 | Descriptor::Wsh(Wsh::new(compiled_policy).unwrap()) 156 | } 157 | 158 | pub fn descriptor(&self) -> Descriptor<bitcoin::PublicKey> { 159 | Descriptor::Wsh(Wsh::new(self.policy().compile().unwrap()).unwrap()) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/betting/mod.rs: -------------------------------------------------------------------------------- 1 | mod bet; 2 | mod bet_args; 3 | mod joint_output; 4 | mod offer; 5 | mod proposal; 6 | mod randomize; 7 | mod wallet_impls; 8 | mod witness; 9 | 10 | pub use bet::*; 11 | pub use bet_args::*; 12 | pub use joint_output::*; 13 | pub use offer::*; 14 | use olivia_secp256k1::fun::{marker::EvenY, Point}; 15 | pub use proposal::*; 16 | pub use randomize::*; 17 | pub use witness::*; 18 | 19 | pub type OracleEvent = olivia_core::OracleEvent<olivia_secp256k1::Secp256k1>; 20 | pub type Attestation = olivia_core::Attestation<olivia_secp256k1::Secp256k1>; 21 | pub type EventResponse = olivia_core::http::EventResponse<olivia_secp256k1::Secp256k1>; 22 | 23 | pub type PublicKey = Point<EvenY>; 24 | pub type BetId = u32; 25 | -------------------------------------------------------------------------------- /src/betting/offer.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, change::Change, keychain::Keychain, FeeSpec, OracleInfo}; 2 | use bdk::{ 3 | bitcoin, 4 | bitcoin::{Amount, Transaction}, 5 | }; 6 | use chacha20::cipher::StreamCipher; 7 | use olivia_secp256k1::fun::{marker::EvenY, Point}; 8 | use std::str::FromStr; 9 | 10 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 11 | pub struct Offer { 12 | pub inputs: Vec<SignedInput>, 13 | pub change: Option<Change>, 14 | pub choose_right: bool, 15 | #[serde(with = "bitcoin::util::amount::serde::as_sat")] 16 | pub value: Amount, 17 | } 18 | 19 | pub struct ValidatedOffer { 20 | pub bet_id: BetId, 21 | pub bet: Bet, 22 | } 23 | 24 | pub struct OfferArgs<'a, 'b, 'c> { 25 | pub proposal: Proposal, 26 | pub choose_right: bool, 27 | pub oracle_event: OracleEvent, 28 | pub oracle_info: OracleInfo, 29 | pub args: BetArgs<'a, 'b>, 30 | pub fee_spec: FeeSpec, 31 | pub keychain: &'c Keychain, 32 | } 33 | 34 | impl ValidatedOffer { 35 | pub fn tx(&self) -> Transaction { 36 | self.bet.psbt.clone().extract_tx() 37 | } 38 | } 39 | 40 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] 41 | pub struct Ciphertext { 42 | pub public_key: Point<EvenY>, 43 | pub encrypted_bytes: Vec<u8>, 44 | } 45 | 46 | impl Ciphertext { 47 | pub fn create( 48 | public_key: Point<EvenY>, 49 | cipher: &mut impl StreamCipher, 50 | plaintext: Plaintext, 51 | ) -> Self { 52 | let mut encrypted_bytes = crate::encode::serialize(&plaintext); 53 | cipher.apply_keystream(&mut encrypted_bytes); 54 | Self { 55 | public_key, 56 | encrypted_bytes, 57 | } 58 | } 59 | 60 | pub fn to_base2048_string(&self) -> String { 61 | crate::encode::serialize_base2048(self) 62 | } 63 | 64 | pub fn to_string_padded( 65 | &self, 66 | pad_to: usize, 67 | pad_cipher: &mut impl StreamCipher, 68 | ) -> (String, usize) { 69 | let mut bytes = crate::encode::serialize(self); 70 | let mut overflow = 0; 71 | match bytes.len() { 72 | len if len < pad_to => { 73 | let mut padding = vec![0u8; pad_to - len]; 74 | pad_cipher.apply_keystream(&mut padding); 75 | bytes.append(&mut padding); 76 | } 77 | len => overflow = len - pad_to, 78 | } 79 | (base2048::encode(&bytes), overflow) 80 | } 81 | 82 | pub fn decrypt(&self, cipher: &mut impl StreamCipher) -> anyhow::Result<Plaintext> { 83 | let mut plaintext = self.encrypted_bytes.clone(); 84 | cipher.apply_keystream(&mut plaintext); 85 | Ok(crate::encode::deserialize::<Plaintext>(&plaintext)?) 86 | } 87 | } 88 | 89 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] 90 | pub enum Plaintext { 91 | Offerv1 { 92 | offer: Offer, 93 | message: Option<String>, 94 | }, 95 | Messagev1(String), 96 | } 97 | 98 | impl Plaintext { 99 | pub fn into_offer(self) -> Offer { 100 | match self { 101 | Self::Offerv1 { offer, .. } => offer, 102 | _ => panic!("expected offer"), 103 | } 104 | } 105 | } 106 | 107 | impl FromStr for Ciphertext { 108 | type Err = crate::encode::DecodeError; 109 | 110 | fn from_str(s: &str) -> Result<Self, Self::Err> { 111 | crate::encode::deserialize_base2048(s) 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod test { 117 | use super::*; 118 | use crate::keychain::KeyPair; 119 | use bdk::bitcoin::{Address, OutPoint}; 120 | use chacha20::{cipher::NewCipher, ChaCha20}; 121 | use core::str::FromStr; 122 | use olivia_secp256k1::ecdsa_fun::Signature; 123 | 124 | fn test_offer() -> (Point<EvenY>, Offer) { 125 | let offer_keypair = KeyPair::from_slice(&[42u8; 32]).unwrap(); 126 | let public_key = offer_keypair.public_key; 127 | ( 128 | public_key, 129 | Offer { 130 | inputs: vec![ 131 | SignedInput { 132 | outpoint: OutPoint::default(), 133 | witness: Witness::P2wpkh { 134 | key: Point::random(&mut rand::thread_rng()).into(), 135 | signature: Signature::from_bytes([43u8; 64]).unwrap(), 136 | }, 137 | }, 138 | SignedInput { 139 | outpoint: OutPoint::default(), 140 | witness: Witness::P2wpkh { 141 | key: Point::random(&mut rand::thread_rng()).into(), 142 | signature: Signature::from_bytes([43u8; 64]).unwrap(), 143 | }, 144 | }, 145 | ], 146 | change: None, 147 | choose_right: false, 148 | value: Amount::from_str_with_denomination("1 BTC").unwrap(), 149 | }, 150 | ) 151 | } 152 | 153 | #[test] 154 | pub fn encrypt_decrypt_roundtrip() { 155 | let (public_key, offer) = test_offer(); 156 | let mut cipher1 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 157 | let mut cipher2 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 158 | 159 | let encrypted_offer = Ciphertext::create( 160 | public_key, 161 | &mut cipher1, 162 | Plaintext::Offerv1 { 163 | offer: offer.clone(), 164 | message: None, 165 | }, 166 | ); 167 | 168 | assert_eq!( 169 | encrypted_offer.decrypt(&mut cipher2).unwrap().into_offer(), 170 | offer 171 | ); 172 | } 173 | 174 | #[test] 175 | fn offer_with_message_attached() { 176 | let (public_key, offer) = test_offer(); 177 | let mut cipher1 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 178 | let mut cipher2 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 179 | 180 | let encrypted_offer = Ciphertext::create( 181 | public_key, 182 | &mut cipher1, 183 | Plaintext::Offerv1 { 184 | offer: offer.clone(), 185 | message: Some("a message".into()), 186 | }, 187 | ); 188 | 189 | if let Plaintext::Offerv1 { 190 | offer: decrypted_offer, 191 | message, 192 | } = encrypted_offer.decrypt(&mut cipher2).unwrap() 193 | { 194 | assert_eq!(decrypted_offer, offer); 195 | assert_eq!(message, Some("a message".into())); 196 | } else { 197 | panic!("expected offer"); 198 | } 199 | } 200 | 201 | #[test] 202 | pub fn encrypt_decrypt_padded_offer_of_different_sizes() { 203 | let (public_key, offer) = test_offer(); 204 | let encrypted_offer1 = { 205 | let mut cipher1 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 206 | let mut cipher2 = ChaCha20::new(&[2u8; 32].into(), &[2u8; 12].into()); 207 | 208 | let encrypted_offer = Ciphertext::create( 209 | public_key, 210 | &mut cipher1, 211 | Plaintext::Offerv1 { 212 | offer: offer.clone(), 213 | message: None, 214 | }, 215 | ); 216 | let (enc_string_offer, _) = encrypted_offer.to_string_padded(385, &mut cipher1); 217 | let decrypted_offer = Ciphertext::from_str(&enc_string_offer) 218 | .unwrap() 219 | .decrypt(&mut cipher2) 220 | .unwrap() 221 | .into_offer(); 222 | assert_eq!(decrypted_offer, offer); 223 | enc_string_offer 224 | }; 225 | 226 | let encrypted_offer2 = { 227 | let mut cipher1 = ChaCha20::new(&[3u8; 32].into(), &[2u8; 12].into()); 228 | let mut cipher2 = ChaCha20::new(&[3u8; 32].into(), &[2u8; 12].into()); 229 | 230 | let mut offer = offer.clone(); 231 | offer.change = Some(Change::new( 232 | 5_000, 233 | Address::from_str("bc1qwxhv5aqc6xahxedh7m2wm333lgkjpmllz4j248") 234 | .unwrap() 235 | .script_pubkey(), 236 | )); 237 | let encrypted_offer = Ciphertext::create( 238 | public_key, 239 | &mut cipher1, 240 | Plaintext::Offerv1 { 241 | offer: offer.clone(), 242 | message: None, 243 | }, 244 | ); 245 | let (enc_string_offer, _) = encrypted_offer.to_string_padded(385, &mut cipher1); 246 | let decrypted_offer = Ciphertext::from_str(&enc_string_offer) 247 | .unwrap() 248 | .decrypt(&mut cipher2) 249 | .unwrap() 250 | .into_offer(); 251 | assert_eq!(decrypted_offer, offer); 252 | enc_string_offer 253 | }; 254 | 255 | assert_eq!( 256 | encrypted_offer1.chars().count(), 257 | encrypted_offer2.chars().count(), 258 | ); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/betting/proposal.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use crate::{ 4 | betting::*, 5 | change::{BinScript, Change}, 6 | }; 7 | use anyhow::anyhow; 8 | use bdk::bitcoin::{self, Amount}; 9 | use olivia_core::EventId; 10 | 11 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 12 | pub enum VersionedProposal { 13 | One(Proposal), 14 | } 15 | 16 | impl From<VersionedProposal> for Proposal { 17 | fn from(vp: VersionedProposal) -> Self { 18 | match vp { 19 | VersionedProposal::One(proposal) => proposal, 20 | } 21 | } 22 | } 23 | 24 | #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] 25 | pub struct Proposal { 26 | pub oracle: String, 27 | pub event_id: EventId, 28 | #[serde(with = "bitcoin::util::amount::serde::as_sat")] 29 | pub value: Amount, 30 | pub inputs: Vec<bdk::bitcoin::OutPoint>, 31 | pub public_key: Point<EvenY>, 32 | pub change_script: Option<BinScript>, 33 | } 34 | 35 | impl Proposal { 36 | pub fn into_versioned(self) -> VersionedProposal { 37 | VersionedProposal::One(self) 38 | } 39 | } 40 | 41 | #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] 42 | pub struct LocalProposal { 43 | pub proposal: Proposal, 44 | pub oracle_event: OracleEvent, 45 | pub change: Option<Change>, 46 | #[serde(default)] 47 | pub tags: Vec<String>, 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] 51 | pub struct Payload { 52 | pub public_key: Point<EvenY>, 53 | pub inputs: Vec<bdk::bitcoin::OutPoint>, 54 | pub change_script: Option<BinScript>, 55 | } 56 | 57 | impl Proposal { 58 | pub fn to_sentence(&self) -> String { 59 | format!( 60 | "Wants to bet {} on {} relying on {} as the oracle", 61 | self.value, self.event_id, self.oracle 62 | ) 63 | } 64 | } 65 | 66 | impl core::fmt::Display for VersionedProposal { 67 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 68 | match self { 69 | VersionedProposal::One(proposal) => { 70 | let payload = Payload { 71 | inputs: proposal.inputs.clone(), 72 | public_key: proposal.public_key, 73 | change_script: proposal.change_script.clone(), 74 | }; 75 | write!( 76 | f, 77 | "{}#{}#{}#{}", 78 | proposal 79 | .value 80 | .to_string_in(bitcoin::Denomination::Bitcoin) 81 | // FIXME: This looks dangerous? 82 | .trim_end_matches('0'), 83 | proposal.oracle, 84 | proposal.event_id, 85 | crate::encode::serialize_base2048(&payload) 86 | ) 87 | } 88 | } 89 | } 90 | } 91 | 92 | impl FromStr for VersionedProposal { 93 | type Err = anyhow::Error; 94 | 95 | fn from_str(string: &str) -> Result<Self, Self::Err> { 96 | let mut segments = string.split('#'); 97 | let value = Amount::from_str_in( 98 | segments.next().ok_or(anyhow!("missing amount"))?, 99 | bitcoin::Denomination::Bitcoin, 100 | )?; 101 | let oracle = segments 102 | .next() 103 | .ok_or(anyhow!("missing oralce"))? 104 | .to_string(); 105 | let event_id = EventId::from_str(segments.next().ok_or(anyhow!("missing event id"))?)?; 106 | let base2048_encoded_payload = segments 107 | .next() 108 | .ok_or(anyhow!("missing base2048 encoded data"))?; 109 | 110 | let payload: Payload = crate::encode::deserialize_base2048(base2048_encoded_payload)?; 111 | 112 | Ok(VersionedProposal::One(Proposal { 113 | oracle, 114 | value, 115 | event_id, 116 | inputs: payload.inputs, 117 | public_key: payload.public_key, 118 | change_script: payload.change_script, 119 | })) 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod test { 125 | use super::*; 126 | use bdk::bitcoin::{hashes::Hash, Address, OutPoint, Txid}; 127 | use olivia_secp256k1::schnorr_fun::fun::{s, G}; 128 | 129 | #[test] 130 | fn to_and_from_str() { 131 | use std::string::ToString; 132 | let forty_two = Point::<EvenY>::from_scalar_mul(G, &mut s!(42)); 133 | let change_address = 134 | Address::from_str("bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej") 135 | .unwrap(); 136 | let mut proposal = Proposal { 137 | oracle: "h00.ooo".into(), 138 | value: Amount::from_str("0.1 BTC").unwrap(), 139 | event_id: EventId::from_str("/random/2020-09-25T08:00:00/heads_tails.winner").unwrap(), 140 | inputs: vec![ 141 | OutPoint::new(Txid::from_slice(&[1u8; 32]).unwrap(), 0), 142 | OutPoint::new(Txid::from_slice(&[2u8; 32]).unwrap(), 1), 143 | ], 144 | public_key: forty_two, 145 | change_script: None, 146 | }; 147 | 148 | let encoded = proposal.clone().into_versioned().to_string(); 149 | let decoded = VersionedProposal::from_str(&encoded).unwrap(); 150 | assert_eq!(proposal, decoded.into()); 151 | 152 | proposal.change_script = Some(change_address.script_pubkey().into()); 153 | 154 | let encoded = proposal.clone().into_versioned().to_string(); 155 | let decoded = VersionedProposal::from_str(&encoded).unwrap(); 156 | assert_eq!(proposal, decoded.into()); 157 | } 158 | 159 | #[test] 160 | fn to_and_from_string_fixed() { 161 | // so we don't accidentally break parsing 162 | use bdk::bitcoin::Address; 163 | use std::str::FromStr; 164 | let fixed = VersionedProposal::One(Proposal { 165 | oracle: "h00.ooo".into(), 166 | event_id: EventId::from_str("/EPL/match/2021-08-22/ARS_CHE.vs=CHE_win").unwrap(), 167 | value: Amount::from_str("0.01000000 BTC").unwrap(), 168 | inputs: vec![OutPoint::from_str( 169 | "d407fe2bd55b6076ce4c78028dc95b4097dd1e5acbf6ccaa741559a0903f1565:1", 170 | ) 171 | .unwrap()], 172 | public_key: Point::from_str( 173 | "119cfc5a4dd8cffeebe9cfb1b42ef3d46d2dc38decebc67826d33ec8d44030c0", 174 | ) 175 | .unwrap(), 176 | change_script: Some( 177 | Address::from_str("bc1qvkswtx2t4y8t6237q753htu4hl4mxm5a9swfjw") 178 | .unwrap() 179 | .script_pubkey() 180 | .into(), 181 | ), 182 | }); 183 | 184 | let string = "0.01#h00.ooo#/EPL/match/2021-08-22/ARS_CHE.vs=CHE_win#Ō࿃Ŵઝঢჰ௵ʏఒଊಫ୵ช༨ɽબവഉబ٧থǀചµǃȅАNJëঌฉѝႡѧ८ಜԺȯDZ૭զӌભഅಮਫႫႿÅąउȆѡԯӂՃĵ࿐ৠĺ৷เшრఠՁ༎"; 185 | dbg!(&fixed.to_string()); 186 | assert_eq!(VersionedProposal::from_str(string).unwrap(), fixed); 187 | assert_eq!(fixed.to_string(), string); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/betting/randomize.rs: -------------------------------------------------------------------------------- 1 | use crate::rand_core::{CryptoRng, RngCore}; 2 | use olivia_secp256k1::fun::Scalar; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct Randomize { 6 | pub r1: Scalar, 7 | pub r2: Scalar, 8 | pub swap_points: bool, 9 | } 10 | 11 | impl Randomize { 12 | pub fn new(rng: &mut (impl RngCore + CryptoRng)) -> Self { 13 | let r1 = Scalar::random(rng); 14 | let r2 = Scalar::random(rng); 15 | let mut byte = [0u8; 1]; 16 | rng.fill_bytes(&mut byte); 17 | let swap_points = (byte[0] & 0x01) == 1; 18 | 19 | Randomize { 20 | r1, 21 | r2, 22 | swap_points, 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/betting/wallet_impls/mod.rs: -------------------------------------------------------------------------------- 1 | mod offer; 2 | mod proposal; 3 | mod spend_won; 4 | mod state_machine; 5 | mod take_offer; 6 | -------------------------------------------------------------------------------- /src/betting/wallet_impls/offer.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, change::Change, wallet::GunWallet, ValueChoice}; 2 | use anyhow::{anyhow, Context}; 3 | use bdk::{ 4 | bitcoin::Amount, 5 | miniscript::DescriptorTrait, 6 | wallet::{coin_selection::LargestFirstCoinSelection, tx_builder::TxOrdering, IsDust}, 7 | SignOptions, 8 | }; 9 | use chacha20::cipher::StreamCipher; 10 | use std::convert::TryInto; 11 | 12 | impl GunWallet { 13 | pub fn generate_offer_with_oracle_event( 14 | &self, 15 | OfferArgs { 16 | proposal, 17 | choose_right, 18 | oracle_event, 19 | oracle_info, 20 | args, 21 | fee_spec, 22 | keychain, 23 | }: OfferArgs, 24 | ) -> anyhow::Result<(Bet, Point<EvenY>, impl StreamCipher)> { 25 | let remote_public_key = &proposal.public_key; 26 | let event_id = &oracle_event.event.id; 27 | if event_id.n_outcomes() != 2 { 28 | return Err(anyhow!( 29 | "Cannot make a bet on {} since it isn't binary", 30 | event_id 31 | )); 32 | } 33 | 34 | let anticipated_attestations = oracle_event 35 | .anticipate_attestations_olivia_v1(&oracle_info.oracle_keys.olivia_v1.ok_or(anyhow!("Oracle '{}' does not support olivia_v1", oracle_info.id))?, 0) 36 | .ok_or(anyhow!("Cannot make bet on {} since {} doesn't support olivia_v1 attestation for this event", event_id, oracle_info.id))? 37 | [..2] 38 | .try_into() 39 | .unwrap(); 40 | 41 | let local_keypair = keychain.keypair_for_offer(&proposal); 42 | let (cipher, mut rng) = crate::ecdh::ecdh(&local_keypair, remote_public_key); 43 | 44 | let remote_public_key = proposal.public_key; 45 | let randomize = Randomize::new(&mut rng); 46 | 47 | let joint_output = JointOutput::new( 48 | [remote_public_key, local_keypair.public_key], 49 | Either::Right(local_keypair.secret_key), 50 | anticipated_attestations, 51 | choose_right, 52 | randomize, 53 | ); 54 | 55 | let mut builder = self 56 | .bdk_wallet() 57 | .build_tx() 58 | .coin_selection(LargestFirstCoinSelection); 59 | builder 60 | .ordering(TxOrdering::Bip69Lexicographic) 61 | .enable_rbf(); 62 | 63 | let output_script = joint_output.descriptor().script_pubkey(); 64 | 65 | match args.value { 66 | ValueChoice::All => { 67 | builder.drain_wallet().drain_to(output_script.clone()); 68 | } 69 | ValueChoice::Amount(amount) => { 70 | let bet_value = amount + proposal.value; 71 | builder.add_recipient(output_script.clone(), bet_value.as_sat()); 72 | } 73 | } 74 | 75 | fee_spec.apply_to_builder(self.bdk_wallet().client(), &mut builder)?; 76 | 77 | args.apply_args(self.gun_db(), &mut builder)?; 78 | 79 | let mut input_value = 0; 80 | for proposal_input in &proposal.inputs { 81 | let psbt_input = self 82 | .p2wpkh_outpoint_to_psbt_input(*proposal_input) 83 | .context("retrieving proposal input")?; 84 | input_value += psbt_input.witness_utxo.as_ref().unwrap().value; 85 | builder.add_foreign_utxo( 86 | *proposal_input, 87 | psbt_input, 88 | // p2wpkh wieght 89 | 4 + 1 + 73 + 33, 90 | )?; 91 | } 92 | 93 | let proposal_excess = input_value 94 | .checked_sub(proposal.value.as_sat()) 95 | .ok_or(anyhow!( 96 | "proposal input value {} is less than proposal value {}", 97 | input_value, 98 | proposal.value 99 | ))?; 100 | 101 | if !proposal_excess.is_dust() { 102 | let change_script = proposal.change_script.as_ref().ok_or(anyhow!( 103 | "proposal had excess coins but did not provide change address" 104 | ))?; 105 | builder.add_recipient(change_script.clone().into(), proposal_excess); 106 | } 107 | 108 | let (psbt, _tx_details) = builder 109 | .finish() 110 | .context("Unable to create offer transaction")?; 111 | 112 | // the inputs we own have witnesses 113 | let my_input_indexes = psbt 114 | .unsigned_tx 115 | .input 116 | .iter() 117 | .enumerate() 118 | .filter(|(_, input)| !proposal.inputs.contains(&input.previous_output)) 119 | .map(|(i, _)| i as u32) 120 | .collect::<Vec<_>>(); 121 | 122 | let (vout, txout) = psbt 123 | .unsigned_tx 124 | .output 125 | .iter() 126 | .enumerate() 127 | .find(|(_i, txout)| txout.script_pubkey == output_script) 128 | .expect("The bet output must be in there"); 129 | 130 | let joint_output_value = Amount::from_sat(txout.value); 131 | let local_value = joint_output_value - proposal.value; 132 | 133 | let bet = Bet { 134 | psbt, 135 | my_input_indexes, 136 | vout: vout as u32, 137 | joint_output, 138 | oracle_id: oracle_info.id, 139 | oracle_event, 140 | local_value, 141 | joint_output_value, 142 | i_chose_right: choose_right, 143 | tags: args.tags, 144 | }; 145 | 146 | Ok((bet, local_keypair.public_key, cipher)) 147 | } 148 | 149 | pub fn sign_save_and_encrypt_offer( 150 | &self, 151 | mut bet: Bet, 152 | message: Option<String>, 153 | local_public_key: Point<EvenY>, 154 | cipher: &mut impl StreamCipher, 155 | ) -> anyhow::Result<(BetId, Ciphertext, Offer)> { 156 | let is_final = self 157 | .bdk_wallet() 158 | .sign(&mut bet.psbt, SignOptions::default()) 159 | .context("Unable to sign offer transaction")?; 160 | 161 | if is_final { 162 | // the only reason it would be final is that the wallet is doing a bet with itself 163 | return Err(anyhow!("sorry you can't do bets with yourself yet!")); 164 | } 165 | 166 | let signed_inputs: Vec<SignedInput> = bet 167 | .my_input_indexes 168 | .iter() 169 | .cloned() 170 | .map(|i| { 171 | let txin = &bet.psbt.unsigned_tx.input[i as usize]; 172 | let psbt_input = &bet.psbt.inputs[i as usize]; 173 | let witness = psbt_input 174 | .final_script_witness 175 | .clone() 176 | .expect("we added this input so we should have signed it"); 177 | 178 | SignedInput { 179 | outpoint: txin.previous_output, 180 | witness: Witness::decode_p2wpkh(witness) 181 | .expect("we signed it so it must be p2wpkh"), 182 | } 183 | }) 184 | .collect(); 185 | 186 | let mut change = None; 187 | 188 | for output in &bet.psbt.unsigned_tx.output { 189 | if self.bdk_wallet().is_mine(&output.script_pubkey)? { 190 | change = Some(Change::new(output.value, output.script_pubkey.clone())); 191 | } 192 | } 193 | 194 | let offer = Offer { 195 | change, 196 | inputs: signed_inputs, 197 | choose_right: bet.i_chose_right, 198 | value: bet.local_value, 199 | }; 200 | 201 | let encrypted_offer = Ciphertext::create( 202 | local_public_key, 203 | cipher, 204 | Plaintext::Offerv1 { 205 | offer: offer.clone(), 206 | message, 207 | }, 208 | ); 209 | let bet_id = self.gun_db().insert_bet(BetState::Offered { 210 | bet: OfferedBet(bet), 211 | encrypted_offer: encrypted_offer.clone(), 212 | })?; 213 | Ok((bet_id, encrypted_offer, offer)) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/betting/wallet_impls/proposal.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, change::Change, keychain::Keychain, wallet::GunWallet, ValueChoice}; 2 | use anyhow::{anyhow, Context}; 3 | use bdk::{ 4 | bitcoin::{Amount, Script}, 5 | wallet::coin_selection::LargestFirstCoinSelection, 6 | FeeRate, 7 | }; 8 | use olivia_core::{OracleEvent, OracleId}; 9 | use olivia_secp256k1::Secp256k1; 10 | 11 | impl GunWallet { 12 | pub fn make_proposal( 13 | &self, 14 | oracle_id: OracleId, 15 | oracle_event: OracleEvent<Secp256k1>, 16 | args: BetArgs, 17 | keychain: &Keychain, 18 | ) -> anyhow::Result<LocalProposal> { 19 | let event_id = &oracle_event.event.id; 20 | if event_id.n_outcomes() != 2 { 21 | return Err(anyhow!( 22 | "Cannot make a bet on {} since it isn't binary", 23 | event_id 24 | )); 25 | } 26 | 27 | let mut builder = self 28 | .bdk_wallet() 29 | .build_tx() 30 | .coin_selection(LargestFirstCoinSelection); 31 | // we use a 0 feerate because the offerer will pay the fee 32 | builder.fee_rate(FeeRate::from_sat_per_vb(0.0)); 33 | 34 | match args.value { 35 | ValueChoice::All => builder.drain_wallet().drain_to(Script::default()), 36 | ValueChoice::Amount(amount) => { 37 | builder.add_recipient(Script::default(), amount.as_sat()) 38 | } 39 | }; 40 | 41 | args.apply_args(self.gun_db(), &mut builder)?; 42 | 43 | let (psbt, txdetails) = builder 44 | .finish() 45 | .context("Failed to gather proposal outputs")?; 46 | 47 | debug_assert!( 48 | // The tx fee *should* be nothing but it's possible the bet value is so close to the 49 | // UTXO value that it gets added to fee rather than creating a dust output. 50 | txdetails.fee.unwrap() < 546, 51 | "the fee should only be there if it's dust" 52 | ); 53 | 54 | let outputs = &psbt.unsigned_tx.output; 55 | let tx_inputs = psbt 56 | .unsigned_tx 57 | .input 58 | .iter() 59 | .map(|txin| txin.previous_output) 60 | .collect(); 61 | 62 | let value = Amount::from_sat( 63 | outputs 64 | .iter() 65 | .find(|o| o.script_pubkey == Script::default()) 66 | .unwrap() 67 | .value, 68 | ); 69 | 70 | let change = if outputs.len() > 1 { 71 | if outputs.len() != 2 { 72 | return Err(anyhow!( 73 | "wallet produced psbt with too many outputs: {:?}", 74 | psbt 75 | )); 76 | } 77 | Some( 78 | outputs 79 | .iter() 80 | .find(|output| output.script_pubkey != Script::default()) 81 | .map(|output| Change::new(output.value, output.script_pubkey.clone())) 82 | .expect("bdk change script_pubkey will not be empty"), 83 | ) 84 | } else { 85 | None 86 | }; 87 | 88 | let mut proposal = Proposal { 89 | oracle: oracle_id, 90 | event_id: event_id.clone(), 91 | value, 92 | inputs: tx_inputs, 93 | public_key: crate::placeholder_point(), 94 | change_script: change.as_ref().map(|x| x.binscript().clone()), 95 | }; 96 | 97 | let keypair = keychain.get_key_for_proposal(&proposal); 98 | proposal.public_key = keypair.public_key; 99 | 100 | let local_proposal = LocalProposal { 101 | proposal, 102 | oracle_event, 103 | change, 104 | tags: args.tags, 105 | }; 106 | 107 | Ok(local_proposal) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/betting/wallet_impls/spend_won.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, elog, wallet::GunWallet, FeeSpec}; 2 | use bdk::{ 3 | bitcoin::{ 4 | util::psbt::{self, PartiallySignedTransaction as Psbt}, 5 | PrivateKey, TxOut, 6 | }, 7 | blockchain::Blockchain, 8 | database::MemoryDatabase, 9 | miniscript::DescriptorTrait, 10 | signer::SignerOrdering, 11 | wallet::{coin_selection::CoinSelectionAlgorithm, tx_builder::TxBuilderContext, AddressIndex}, 12 | KeychainKind, SignOptions, TxBuilder, Wallet, 13 | }; 14 | use std::sync::Arc; 15 | 16 | impl GunWallet { 17 | pub fn claim( 18 | &self, 19 | fee: FeeSpec, 20 | bump_claiming: bool, 21 | ) -> anyhow::Result<Option<(Vec<BetId>, Psbt)>> { 22 | let bdk_wallet = self.bdk_wallet(); 23 | let mut builder = bdk_wallet.build_tx(); 24 | builder.manually_selected_only().enable_rbf(); 25 | 26 | fee.apply_to_builder(bdk_wallet.client(), &mut builder)?; 27 | 28 | let recipient = bdk_wallet 29 | .get_change_address(AddressIndex::New)? 30 | .script_pubkey(); 31 | 32 | builder.drain_to(recipient); 33 | 34 | let (mut psbt, claiming_bet_ids) = match self.spend_won_bets(builder, bump_claiming)? { 35 | Some(res) => res, 36 | None => return Ok(None), 37 | }; 38 | 39 | let finalized = bdk_wallet.finalize_psbt(&mut psbt, SignOptions::default())?; 40 | 41 | assert!( 42 | finalized, 43 | "since we have signed each input is must be finalized" 44 | ); 45 | 46 | Ok(Some((claiming_bet_ids, psbt))) 47 | } 48 | 49 | pub fn spend_won_bets< 50 | D: bdk::database::BatchDatabase, 51 | B: Blockchain, 52 | Cs: CoinSelectionAlgorithm<D>, 53 | Ctx: TxBuilderContext, 54 | >( 55 | &self, 56 | mut builder: TxBuilder<'_, B, D, Cs, Ctx>, 57 | bump_claiming: bool, 58 | ) -> anyhow::Result<Option<(Psbt, Vec<BetId>)>> { 59 | let claimable_bets = self 60 | .gun_db() 61 | .list_entities::<BetState>() 62 | .filter_map(|result| match result { 63 | Ok(ok) => Some(ok), 64 | Err(e) => { 65 | elog!(@recoverable_error "Error with entry in database: {}", e); 66 | None 67 | } 68 | }) 69 | .filter_map(|(bet_id, bet_state)| match bet_state { 70 | BetState::Won { 71 | bet, secret_key, .. 72 | } => Some((bet_id, bet, secret_key)), 73 | BetState::Claimed { 74 | height: None, 75 | bet, 76 | secret_key, 77 | .. 78 | } if bump_claiming => Some((bet_id, bet, secret_key)), 79 | _ => None, 80 | }) 81 | .collect::<Vec<_>>(); 82 | 83 | let claimable_bet_ids = claimable_bets 84 | .iter() 85 | .map(|(bet_id, _, _)| *bet_id) 86 | .collect::<Vec<_>>(); 87 | 88 | for (_, bet, _) in &claimable_bets { 89 | let psbt_input = psbt::Input { 90 | witness_utxo: Some(TxOut { 91 | value: bet.joint_output_value.as_sat(), 92 | script_pubkey: bet.joint_output.descriptor().script_pubkey(), 93 | }), 94 | non_witness_utxo: Some(bet.tx()), 95 | witness_script: Some(bet.joint_output.descriptor().script_code()), 96 | ..Default::default() 97 | }; 98 | builder 99 | .add_foreign_utxo( 100 | bet.outpoint(), 101 | psbt_input, 102 | bet.joint_output 103 | .descriptor() 104 | .max_satisfaction_weight() 105 | .unwrap(), 106 | ) 107 | .unwrap(); 108 | } 109 | 110 | let (mut psbt, _) = match builder.finish() { 111 | Ok(res) => res, 112 | Err(bdk::Error::NoUtxosSelected) => return Ok(None), 113 | e => e?, 114 | }; 115 | 116 | for (_, bet, secret_key) in claimable_bets { 117 | let signer = PrivateKey { 118 | compressed: true, 119 | network: self.bdk_wallet().network(), 120 | key: secret_key, 121 | }; 122 | let output_descriptor = bet.joint_output.wallet_descriptor(); 123 | let mut tmp_wallet = Wallet::new_offline( 124 | output_descriptor, 125 | None, 126 | self.bdk_wallet().network(), 127 | MemoryDatabase::default(), 128 | ) 129 | .expect("nothing can go wrong here"); 130 | tmp_wallet.add_signer( 131 | KeychainKind::External, 132 | SignerOrdering::default(), 133 | Arc::new(signer), 134 | ); 135 | tmp_wallet.sign(&mut psbt, SignOptions::default())?; 136 | } 137 | 138 | Ok(Some((psbt, claimable_bet_ids))) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/betting/wallet_impls/state_machine.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, elog, wallet::GunWallet}; 2 | use anyhow::{anyhow, Context}; 3 | use bdk::blockchain::{ 4 | Blockchain, Broadcast, GetInputState, InputState, TransactionState, TxState, 5 | }; 6 | 7 | macro_rules! update_bet { 8 | ($self:expr, $bet_id:expr, $($tt:tt)+) => { 9 | $self.gun_db().update_bets(&[$bet_id], |old_state, _, _| { 10 | #[allow(unreachable_patterns)] 11 | Ok(match old_state { 12 | $($tt)+, 13 | _ => old_state 14 | }) 15 | })? 16 | } 17 | } 18 | 19 | impl GunWallet { 20 | /// Look at current state and see if we can progress it. 21 | /// 22 | /// The `try_learn_outcome` exists so during tests it can be turned off so this doesn't try and contact a non-existent oracle. 23 | /// TODO: fix this with an oracle trait that can be mocked in tests. 24 | pub fn take_next_action(&self, bet_id: BetId, try_learn_outcome: bool) -> anyhow::Result<()> { 25 | let bet_state = self 26 | .gun_db() 27 | .get_entity(bet_id)? 28 | .ok_or(anyhow!("Bet {} does not exist", bet_id))?; 29 | let blockchain = self.bdk_wallet().client(); 30 | 31 | match bet_state { 32 | BetState::Canceled { 33 | pre_cancel, 34 | height, 35 | i_intend_cancel, 36 | .. 37 | } => { 38 | match &pre_cancel { 39 | BetOrProp::OfferedBet { bet, .. } => { 40 | if let TxState::Present { height } = blockchain.tx_state(&bet.tx())? { 41 | if let Some(tx) = blockchain.get_tx(&bet.tx().txid())? { 42 | let bet = bet.clone().add_counterparty_sigs(tx); 43 | update_bet! { 44 | self, bet_id, _ => BetState::Included { 45 | bet: bet.clone(), 46 | height 47 | } 48 | }; 49 | } 50 | } 51 | } 52 | BetOrProp::Bet(bet) => { 53 | if let TxState::Present { height } = blockchain.tx_state(&bet.tx())? { 54 | update_bet! { self, bet_id, BetState::Canceled { .. } => BetState::Included { bet: bet.clone(), height } } 55 | } 56 | } 57 | BetOrProp::Proposal(_) => { /* no bet to check */ } 58 | } 59 | 60 | if height.is_none() { 61 | match blockchain.input_state(&pre_cancel.inputs())? { 62 | InputState::Spent { 63 | index, 64 | txid, 65 | vin, 66 | height, 67 | } => { 68 | update_bet! { 69 | self, bet_id, 70 | BetState::Canceled { pre_cancel, mut i_intend_cancel, .. } => { 71 | i_intend_cancel = i_intend_cancel || match &pre_cancel { 72 | BetOrProp::Bet(bet) | BetOrProp::OfferedBet { bet: OfferedBet(bet), .. } => bet.my_input_indexes.contains(&(index as u32)), 73 | BetOrProp::Proposal(_) => { debug_assert!(false, "unreachable. i_intend_cancel will be true here if we were in proposal state"); true } 74 | }; 75 | BetState::Canceled { 76 | pre_cancel, 77 | height, 78 | cancel_txid: txid, 79 | cancel_vin: vin, 80 | bet_spent_vin: index, 81 | i_intend_cancel 82 | } 83 | } 84 | } 85 | } 86 | // Whatever tx that caused us to be in canceling state has disappeared from mempool so roll back. 87 | // If code is correct here it should be a tx we've broadcast ourselves (otherwise we wouldn't have transitioned). 88 | InputState::Unspent => match &pre_cancel { 89 | BetOrProp::Proposal(local_proposal) => { 90 | update_bet! { self, bet_id, BetState::Canceled { height: None, .. } => BetState::Proposed { local_proposal: local_proposal.clone() } } 91 | } 92 | BetOrProp::OfferedBet { 93 | bet, 94 | encrypted_offer, 95 | } => { 96 | update_bet! { self, bet_id, BetState::Canceled { height: None, .. } => BetState::Offered { bet: bet.clone(), encrypted_offer: encrypted_offer.clone() }} 97 | } 98 | BetOrProp::Bet(bet) => { 99 | if !i_intend_cancel { 100 | Broadcast::broadcast(blockchain, bet.tx()) 101 | .context("broadcasting bet tx because it left mempool")?; 102 | } 103 | update_bet! { self, bet_id, BetState::Canceled { height: None, .. } => BetState::Included { bet: bet.clone(), height: None } } 104 | } 105 | }, 106 | } 107 | } 108 | } 109 | BetState::Proposed { local_proposal } => { 110 | if let InputState::Spent { 111 | index, 112 | txid, 113 | vin, 114 | height, 115 | } = blockchain.input_state(&local_proposal.proposal.inputs)? 116 | { 117 | update_bet! { self, bet_id, 118 | BetState::Proposed { local_proposal, .. } => BetState::Canceled { 119 | pre_cancel: BetOrProp::Proposal(local_proposal), 120 | bet_spent_vin: index, 121 | cancel_txid: txid, 122 | cancel_vin: vin, 123 | height, 124 | i_intend_cancel: true 125 | } 126 | } 127 | } 128 | } 129 | BetState::Offered { bet, .. } => { 130 | match blockchain.tx_state(&bet.0.tx())? { 131 | TxState::Present { height } => { 132 | if let Ok(Some(tx)) = blockchain.get_tx(&bet.0.tx().txid()) { 133 | // when we offer a bet we don't have the full tx with signatures so if it's 134 | // there lets get it from the blockchain. 135 | update_bet! { self, bet_id, 136 | BetState::Offered { bet, .. } => { 137 | let bet = bet.add_counterparty_sigs(tx.clone()); 138 | BetState::Included { bet, height } 139 | } 140 | } 141 | } 142 | } 143 | TxState::Conflict { 144 | txid, 145 | vin, 146 | vin_target, 147 | height, 148 | } => { 149 | let i_intend_cancel = bet.my_input_indexes.contains(&vin_target); 150 | if height.is_some() || i_intend_cancel { 151 | update_bet! { self, bet_id, 152 | BetState::Offered { bet, encrypted_offer } => BetState::Canceled { 153 | pre_cancel: BetOrProp::OfferedBet{ bet, encrypted_offer }, 154 | bet_spent_vin: vin_target, 155 | cancel_txid: txid, 156 | cancel_vin: vin, 157 | height, 158 | i_intend_cancel, 159 | } 160 | } 161 | } 162 | } 163 | TxState::NotFound => { /* we're waiting for proposer to broadcast */ } 164 | } 165 | } 166 | BetState::Included { bet, .. } => { 167 | match blockchain.tx_state(&bet.tx())? { 168 | // If there's a conflict with the bet tx then we go to canceled 169 | TxState::Conflict { 170 | txid, 171 | vin, 172 | vin_target, 173 | height, 174 | } => update_bet! { self, bet_id, 175 | BetState::Included { bet, .. } => BetState::Canceled { 176 | i_intend_cancel: bet.my_input_indexes.contains(&vin_target), 177 | pre_cancel: BetOrProp::Bet(bet), 178 | bet_spent_vin: vin_target, 179 | cancel_txid: txid, 180 | cancel_vin: vin, 181 | height, 182 | } 183 | }, 184 | // Update height if it gto confirmed somewhere else 185 | TxState::Present { height } => update_bet! { self, bet_id, 186 | BetState::Included { bet,..} => BetState::Included { bet, height } 187 | }, 188 | TxState::NotFound => { 189 | elog!( 190 | @info 191 | "The bet tx for {} has fallen out of mempool -- rebroadcasting it!", 192 | bet_id 193 | ); 194 | Broadcast::broadcast(blockchain, bet.tx())? 195 | } 196 | } 197 | if try_learn_outcome { 198 | self.try_get_outcome(bet_id, bet)?; 199 | } 200 | } 201 | BetState::Won { bet, .. } => { 202 | // claiming but just in case someone steals your keys somehow we handle it. 203 | if let InputState::Spent { txid, height, .. } = 204 | blockchain.input_state(&[bet.outpoint()])? 205 | { 206 | update_bet! {self, bet_id, 207 | BetState::Won { bet, secret_key, attestation } => { 208 | BetState::Claimed { bet, txid, height, secret_key, attestation } 209 | } 210 | } 211 | } 212 | } 213 | // TODO: To be more robust, check if height is below some threshold rather than just None 214 | BetState::Claimed { 215 | bet, height: None, .. 216 | } => match blockchain.input_state(&[bet.outpoint()])? { 217 | InputState::Spent { txid, height, .. } => update_bet! {self, bet_id, 218 | BetState::Claimed { bet, attestation, secret_key, .. } => BetState::Claimed { bet, txid, height, secret_key, attestation} 219 | }, 220 | InputState::Unspent => update_bet! { self, bet_id, 221 | BetState::Claimed { bet, secret_key, attestation, .. } => BetState::Won { bet, secret_key, attestation } 222 | }, 223 | }, 224 | BetState::Claimed { 225 | height: Some(_), .. 226 | } 227 | | BetState::Lost { .. } => { /* terminal states */ } 228 | } 229 | Ok(()) 230 | } 231 | 232 | fn try_get_outcome(&self, bet_id: BetId, bet: Bet) -> anyhow::Result<()> { 233 | let event_id = bet.oracle_event.event.id; 234 | let event_url = format!("https://{}{}", bet.oracle_id, event_id); 235 | let event_response = self 236 | .http_client() 237 | .get(&event_url) 238 | .call() 239 | .with_context(|| format!("trying to outcome for bet {} from {}", bet_id, event_url))? 240 | .into_json::<EventResponse>()?; 241 | 242 | if let Some(attestation) = event_response.attestation { 243 | self.learn_outcome(bet_id, attestation)?; 244 | } 245 | 246 | Ok(()) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/betting/wallet_impls/take_offer.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, keychain::Keychain, wallet::GunWallet, OracleInfo}; 2 | use anyhow::{anyhow, Context}; 3 | use bdk::{ 4 | bitcoin::{ 5 | util::psbt::{self, PartiallySignedTransaction as Psbt}, 6 | Amount, 7 | }, 8 | miniscript::DescriptorTrait, 9 | wallet::tx_builder::TxOrdering, 10 | SignOptions, 11 | }; 12 | use chacha20::ChaCha20Rng; 13 | use olivia_secp256k1::fun::{marker::EvenY, Point}; 14 | use std::convert::TryInto; 15 | 16 | impl GunWallet { 17 | pub fn decrypt_offer( 18 | &self, 19 | bet_id: BetId, 20 | encrypted_offer: Ciphertext, 21 | keychain: &Keychain, 22 | ) -> anyhow::Result<(Plaintext, Point<EvenY>, ChaCha20Rng)> { 23 | let local_proposal = self 24 | .gun_db() 25 | .get_entity(bet_id)? 26 | .ok_or(anyhow!("Proposal does not exist"))?; 27 | 28 | match local_proposal { 29 | BetState::Proposed { local_proposal } => { 30 | let keypair = keychain.get_key_for_proposal(&local_proposal.proposal); 31 | let (mut cipher, rng) = crate::ecdh::ecdh(&keypair, &encrypted_offer.public_key); 32 | let plaintext = encrypted_offer.decrypt(&mut cipher)?; 33 | 34 | Ok((plaintext, encrypted_offer.public_key, rng)) 35 | } 36 | _ => Err(anyhow!("Offer has been taken for this proposal already")), 37 | } 38 | } 39 | 40 | fn lookup_offer_inputs(&self, offer: &Offer) -> anyhow::Result<(Vec<psbt::Input>, Amount)> { 41 | let mut psbt_inputs = vec![]; 42 | let mut input_value = 0; 43 | for input in &offer.inputs { 44 | let mut psbt_input = self 45 | .p2wpkh_outpoint_to_psbt_input(input.outpoint) 46 | .context("retrieving offer input")?; 47 | input_value += psbt_input.witness_utxo.as_ref().unwrap().value; 48 | psbt_input.final_script_witness = Some(input.witness.encode()); 49 | psbt_inputs.push(psbt_input); 50 | } 51 | 52 | Ok((psbt_inputs, Amount::from_sat(input_value))) 53 | } 54 | 55 | pub fn validate_offer( 56 | &self, 57 | bet_id: BetId, 58 | offer: Offer, 59 | offer_public_key: Point<EvenY>, 60 | mut rng: ChaCha20Rng, 61 | keychain: &Keychain, 62 | ) -> anyhow::Result<ValidatedOffer> { 63 | let (offer_psbt_inputs, offer_input_value) = self.lookup_offer_inputs(&offer)?; 64 | 65 | let randomize = Randomize::new(&mut rng); 66 | 67 | let bet_state = self 68 | .gun_db() 69 | .get_entity::<BetState>(bet_id)? 70 | .ok_or(anyhow!("Bet {} doesn't exist", bet_id))?; 71 | let local_proposal = match bet_state { 72 | BetState::Proposed { local_proposal } => local_proposal, 73 | _ => return Err(anyhow!("was not in proposed state")), 74 | }; 75 | 76 | let LocalProposal { 77 | oracle_event, 78 | proposal, 79 | .. 80 | } = local_proposal; 81 | 82 | let keypair = keychain.get_key_for_proposal(&proposal); 83 | let oracle_id = &proposal.oracle; 84 | 85 | let oracle_info = self 86 | .gun_db() 87 | .get_entity::<OracleInfo>(oracle_id.clone())? 88 | .ok_or(anyhow!("Oracle {} isn't in the database", oracle_id))?; 89 | 90 | let anticipated_attestations = oracle_event 91 | .anticipate_attestations_olivia_v1( 92 | &oracle_info 93 | .oracle_keys 94 | .olivia_v1 95 | .expect("since we already proposed must have olivia-v1"), 96 | 0, 97 | ) 98 | .expect("since we already proposed the bet it must have olivia-v1") 99 | .try_into() 100 | .map_err(|_| anyhow!("wrong number of attestations"))?; 101 | 102 | let joint_output = JointOutput::new( 103 | [keypair.public_key, offer_public_key], 104 | Either::Left(keypair.secret_key), 105 | anticipated_attestations, 106 | offer.choose_right, 107 | randomize, 108 | ); 109 | let joint_output_value = offer 110 | .value 111 | .checked_add(proposal.value) 112 | .expect("we've checked the offer value on the chain"); 113 | let joint_output_script_pubkey = joint_output.descriptor().script_pubkey(); 114 | 115 | let mut builder = self.bdk_wallet().build_tx(); 116 | 117 | builder 118 | .manually_selected_only() 119 | .ordering(TxOrdering::Bip69Lexicographic) 120 | .enable_rbf(); 121 | 122 | for proposal_input in &proposal.inputs { 123 | builder.add_utxo(*proposal_input)?; 124 | } 125 | 126 | for (input, psbt_input) in offer.inputs.iter().zip(offer_psbt_inputs) { 127 | builder.add_foreign_utxo(input.outpoint, psbt_input, 4 + 1 + 73 + 33)?; 128 | } 129 | 130 | if let Some(change) = local_proposal.change { 131 | builder.add_recipient(change.script().clone(), change.value().as_sat()); 132 | } 133 | 134 | let mut absolute_fee = offer_input_value 135 | .checked_sub(offer.value) 136 | .ok_or(anyhow!("offer value is more than input value"))?; 137 | 138 | if let Some(change) = offer.change { 139 | absolute_fee = absolute_fee 140 | .checked_sub(change.value()) 141 | .ok_or(anyhow!("too much change requested"))?; 142 | builder.add_recipient(change.script().clone(), change.value().as_sat()); 143 | } 144 | 145 | builder 146 | .add_recipient( 147 | joint_output_script_pubkey.clone(), 148 | joint_output_value.as_sat(), 149 | ) 150 | .fee_absolute(absolute_fee.as_sat()); 151 | 152 | let (psbt, _tx_details) = builder.finish()?; 153 | 154 | let vout = psbt 155 | .unsigned_tx 156 | .output 157 | .iter() 158 | .enumerate() 159 | .find_map(|(i, txout)| { 160 | if txout.script_pubkey == joint_output_script_pubkey { 161 | Some(i) 162 | } else { 163 | None 164 | } 165 | }) 166 | .expect("our joint outpoint will always exist") as u32; 167 | 168 | let my_input_indexes = proposal 169 | .inputs 170 | .iter() 171 | .map(|input| { 172 | psbt.unsigned_tx 173 | .input 174 | .iter() 175 | .enumerate() 176 | .find(|(_, txin)| txin.previous_output == *input) 177 | .unwrap() 178 | .0 as u32 179 | }) 180 | .collect(); 181 | 182 | let bet = Bet { 183 | psbt, 184 | my_input_indexes, 185 | vout, 186 | oracle_id: oracle_info.id, 187 | oracle_event, 188 | joint_output, 189 | local_value: proposal.value, 190 | joint_output_value, 191 | i_chose_right: !offer.choose_right, 192 | tags: local_proposal.tags, 193 | }; 194 | 195 | Ok(ValidatedOffer { bet_id, bet }) 196 | } 197 | 198 | pub fn sign_validated_offer(&self, offer: &mut ValidatedOffer) -> anyhow::Result<()> { 199 | let psbt = &mut offer.bet.psbt; 200 | let is_final = self 201 | .bdk_wallet() 202 | .sign(psbt, SignOptions::default()) 203 | .context("Failed to sign transaction")?; 204 | 205 | if !is_final { 206 | return Err(anyhow!("Transaction is incomplete after signing it")); 207 | } 208 | Ok(()) 209 | } 210 | 211 | pub fn set_offer_taken( 212 | &self, 213 | ValidatedOffer { bet_id, bet, .. }: ValidatedOffer, 214 | ) -> anyhow::Result<Psbt> { 215 | self.gun_db() 216 | .update_bets(&[bet_id], |bet_state, _, _| match bet_state { 217 | BetState::Proposed { .. } => Ok(BetState::Included { 218 | bet: bet.clone(), 219 | height: None, 220 | }), 221 | _ => Ok(bet_state), 222 | })?; 223 | 224 | Ok(bet.psbt) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/betting/witness.rs: -------------------------------------------------------------------------------- 1 | use bdk::bitcoin::secp256k1; 2 | use olivia_secp256k1::ecdsa_fun; 3 | 4 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] 5 | pub struct SignedInput { 6 | pub outpoint: bdk::bitcoin::OutPoint, 7 | pub witness: Witness, 8 | } 9 | 10 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] 11 | pub enum Witness { 12 | P2wpkh { 13 | key: secp256k1::PublicKey, 14 | // using ecdsa_fun::Signature here ebcause it serializes to 64 bytes rather than DER 15 | signature: ecdsa_fun::Signature, 16 | }, 17 | } 18 | 19 | impl Witness { 20 | pub fn encode(&self) -> Vec<Vec<u8>> { 21 | match self { 22 | Witness::P2wpkh { key, signature } => { 23 | let mut sig_bytes = 24 | secp256k1::ecdsa::Signature::from_compact(&signature.to_bytes()) 25 | .unwrap() 26 | .serialize_der() 27 | .to_vec(); 28 | sig_bytes.push(0x01); 29 | let pk_bytes = key.serialize().to_vec(); 30 | vec![sig_bytes, pk_bytes] 31 | } 32 | } 33 | } 34 | 35 | pub fn decode_p2wpkh(mut w: Vec<Vec<u8>>) -> Option<Self> { 36 | let key_bytes = w.pop()?; 37 | let mut sig_bytes = w.pop()?; 38 | let _sighash = sig_bytes.pop()?; 39 | let signature = secp256k1::ecdsa::Signature::from_der(&sig_bytes) 40 | .ok()? 41 | .into(); 42 | let key = secp256k1::PublicKey::from_slice(&key_bytes).ok()?; 43 | Some(Witness::P2wpkh { key, signature }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/bin/gun.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use bdk::blockchain::esplora::EsploraBlockchainConfig; 3 | use gun_wallet::{ 4 | cmd::{self, *}, 5 | elog, 6 | }; 7 | use std::path::PathBuf; 8 | use structopt::StructOpt; 9 | 10 | #[derive(StructOpt, Debug, Clone)] 11 | /// A CLI Bitcoin wallet for plebs, degenerates and revolutionaries. 12 | pub struct Opt { 13 | #[structopt(parse(from_os_str), short("d"), env = "GUN_DIR")] 14 | /// The wallet data directory. 15 | gun_dir: Option<PathBuf>, 16 | #[structopt(subcommand)] 17 | command: Commands, 18 | #[structopt(short, long)] 19 | /// Tell the wallet to sync itself. 20 | sync: bool, 21 | #[structopt(short, long)] 22 | /// Return output in JSON format 23 | json: bool, 24 | /// Return outupt in simplified UNIX table (tabs and newlines) 25 | #[structopt(short, long)] 26 | tabs: bool, 27 | } 28 | 29 | #[derive(StructOpt, Debug, Clone)] 30 | #[allow(clippy::large_enum_variant)] 31 | pub enum Commands { 32 | /// Make or take a bet 33 | Bet(BetOpt), 34 | /// View the balance of the wallet 35 | Balance, 36 | /// Get addresses 37 | Address(AddressOpt), 38 | /// View Transactions 39 | Tx(TransactionOpt), 40 | /// View Utxos 41 | Utxo(UtxoOpt), 42 | /// Send funds out of wallet 43 | Send(SendOpt), 44 | /// Setup a new wallet 45 | Setup(SetupOpt), 46 | /// Split coins into evenly sized outputs. 47 | Split(SplitOpt), 48 | /// Get/set configuration values 49 | Config(ConfigOpt), 50 | } 51 | 52 | fn main() -> anyhow::Result<()> { 53 | let opt = Opt::from_args(); 54 | let sync = opt.sync; 55 | 56 | let wallet_dir = opt 57 | .gun_dir 58 | .unwrap_or_else(|| dirs::home_dir().unwrap().join(".gun")); 59 | 60 | let res = if let Commands::Setup(opt) = opt.command { 61 | cmd::run_setup(&wallet_dir, opt) 62 | } else { 63 | let (wallet, keychain, config) = cmd::load_wallet(&wallet_dir)?; 64 | 65 | if sync { 66 | use Commands::*; 67 | 68 | if let Balance | Address(_) | Send(_) | Tx(_) | Utxo(_) = opt.command { 69 | let EsploraBlockchainConfig { 70 | stop_gap, 71 | base_url, 72 | concurrency, 73 | .. 74 | } = config.blockchain_config(); 75 | elog!( 76 | @info 77 | "syncing wallet with {} (stop_gap: {}, parallel_connections: {})", 78 | base_url, 79 | stop_gap, 80 | concurrency.unwrap_or(1) 81 | ); 82 | wallet.sync()?; 83 | } 84 | 85 | // we poke bets to update balance from bets as well. 86 | if let Balance = opt.command { 87 | wallet.poke_bets() 88 | } 89 | } 90 | 91 | match opt.command { 92 | Commands::Bet(opt) => { 93 | let keychain = match keychain { 94 | Some(keychain) => keychain, 95 | None => { 96 | return Err(anyhow!( 97 | "This wallet wasn't set up with a protocol secret so you can't do betting" 98 | )) 99 | } 100 | }; 101 | cmd::run_bet_cmd(&wallet, &keychain, opt, sync) 102 | } 103 | Commands::Balance => cmd::run_balance(&wallet, sync), 104 | Commands::Address(opt) => cmd::get_address(&wallet, opt), 105 | Commands::Send(opt) => cmd::run_send(&wallet, opt), 106 | Commands::Setup(_) => unreachable!("we handled setup already"), 107 | Commands::Tx(opt) => cmd::run_transaction_cmd(&wallet, opt), 108 | Commands::Utxo(opt) => cmd::run_utxo_cmd(&wallet, opt), 109 | Commands::Split(opt) => cmd::run_split_cmd(&wallet, opt), 110 | Commands::Config(opt) => { 111 | cmd::run_config_cmd(&wallet_dir, &wallet, &wallet_dir.join("config.json"), opt) 112 | } 113 | } 114 | }; 115 | 116 | match res { 117 | Ok(output) => { 118 | if opt.json { 119 | println!( 120 | "{}", 121 | serde_json::to_string_pretty(&output.render_json()).unwrap() 122 | ) 123 | } else if opt.tabs { 124 | println!("{}", output.render_simple()) 125 | } else if let Some(mut output) = output.render() { 126 | output = output.trim_end().to_string(); 127 | println!("{}", output) 128 | } 129 | } 130 | Err(e) => { 131 | if opt.json { 132 | let err_json = serde_json::json!({ 133 | "error" : format!("{}", e), 134 | }); 135 | println!("{}", serde_json::to_string_pretty(&err_json).unwrap()); 136 | std::process::exit(1) 137 | } else { 138 | return Err(e); 139 | } 140 | } 141 | } 142 | 143 | Ok(()) 144 | } 145 | -------------------------------------------------------------------------------- /src/bip85.rs: -------------------------------------------------------------------------------- 1 | use bdk::bitcoin::{ 2 | hashes::{sha512, Hash, HashEngine, Hmac, HmacEngine}, 3 | secp256k1::{Secp256k1, Signing}, 4 | util::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}, 5 | }; 6 | 7 | pub fn get_bip85_bytes<S: Signing, const L: usize>( 8 | xpriv: ExtendedPrivKey, 9 | index: u32, 10 | secp: &Secp256k1<S>, 11 | ) -> [u8; L] { 12 | let path = DerivationPath::from(vec![ 13 | ChildNumber::Hardened { index: 83696968 }, 14 | ChildNumber::Hardened { index: 128169 }, 15 | ChildNumber::Hardened { index: L as u32 }, 16 | ChildNumber::Hardened { index }, 17 | ]); 18 | let bip85_key = xpriv.derive_priv(secp, &path).unwrap(); 19 | 20 | let mut engine = HmacEngine::<sha512::Hash>::new("bip-entropy-from-k".as_bytes()); 21 | engine.input(&bip85_key.private_key.serialize_secret()); 22 | let hash = Hmac::<sha512::Hash>::from_engine(engine).into_inner(); 23 | hash[..L].try_into().unwrap() 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use super::*; 29 | use olivia_secp256k1::fun::hex; 30 | use std::str::FromStr; 31 | 32 | #[test] 33 | fn test_vector_32() { 34 | let secp = Secp256k1::signing_only(); 35 | let xpriv = ExtendedPrivKey::from_str("xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb").expect("reading extended private key"); 36 | let expected_hex = 37 | hex::decode("ea3ceb0b02ee8e587779c63f4b7b3a21e950a213f1ec53cab608d13e8796e6dc") 38 | .expect("reading in expected test bytes"); 39 | assert_eq!( 40 | get_bip85_bytes::<_, 32>(xpriv, 0, &secp).to_vec(), 41 | expected_hex 42 | ); 43 | } 44 | 45 | #[test] 46 | fn test_vector_64() { 47 | let secp = Secp256k1::signing_only(); 48 | let xpriv = ExtendedPrivKey::from_str("xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb").expect("reading extended private key"); 49 | let expected_hex = 50 | hex::decode("492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c") 51 | .expect("reading in expected test bytes"); 52 | assert_eq!( 53 | get_bip85_bytes::<_, 64>(xpriv, 0, &secp).to_vec(), 54 | expected_hex 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/change.rs: -------------------------------------------------------------------------------- 1 | use bdk::bitcoin::{Amount, Script}; 2 | use olivia_secp256k1::fun::hex; 3 | 4 | #[derive(Debug, Clone, PartialEq)] 5 | pub struct BinScript(Script); 6 | 7 | impl From<BinScript> for Script { 8 | fn from(binscript: BinScript) -> Script { 9 | binscript.0 10 | } 11 | } 12 | 13 | impl From<Script> for BinScript { 14 | fn from(script: Script) -> Self { 15 | BinScript(script) 16 | } 17 | } 18 | 19 | impl From<Vec<u8>> for BinScript { 20 | fn from(bytes: Vec<u8>) -> Self { 21 | BinScript(Script::from(bytes)) 22 | } 23 | } 24 | 25 | #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] 26 | pub struct Change { 27 | #[serde(with = "bdk::bitcoin::util::amount::serde::as_sat")] 28 | value: Amount, 29 | script_pubkey: BinScript, 30 | } 31 | 32 | impl Change { 33 | pub fn new(value: u64, script_pubkey: Script) -> Self { 34 | Change { 35 | value: Amount::from_sat(value), 36 | script_pubkey: script_pubkey.into(), 37 | } 38 | } 39 | 40 | pub fn value(&self) -> Amount { 41 | self.value 42 | } 43 | 44 | pub fn script(&self) -> &Script { 45 | &self.script_pubkey.0 46 | } 47 | 48 | pub fn binscript(&self) -> &BinScript { 49 | &self.script_pubkey 50 | } 51 | } 52 | use serde::de::Error; 53 | 54 | impl<'de> serde::Deserialize<'de> for BinScript { 55 | fn deserialize<D: serde::de::Deserializer<'de>>( 56 | deserializer: D, 57 | ) -> Result<BinScript, D::Error> { 58 | if deserializer.is_human_readable() { 59 | Ok(BinScript(Script::from( 60 | hex::decode(&String::deserialize(deserializer)?) 61 | .map_err(|e| D::Error::custom(format!("{}", e)))?, 62 | ))) 63 | } else { 64 | Ok(BinScript(Script::from(Vec::<u8>::deserialize( 65 | deserializer, 66 | )?))) 67 | } 68 | } 69 | } 70 | 71 | impl<'de> serde::Serialize for BinScript { 72 | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 73 | if serializer.is_human_readable() { 74 | serializer.serialize_str(&hex::encode(self.0.as_bytes())) 75 | } else { 76 | serializer.serialize_bytes(self.0.as_bytes()) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/cmd/config.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | cmd, 3 | cmd::Cell, 4 | config::GunSigner, 5 | database::{ProtocolKind, StringDescriptor}, 6 | eitem, 7 | keychain::ProtocolSecret, 8 | wallet::GunWallet, 9 | }; 10 | use bdk::{blockchain::AnyBlockchainConfig, KeychainKind}; 11 | use std::path::{Path, PathBuf}; 12 | use structopt::StructOpt; 13 | 14 | use super::CmdOutput; 15 | 16 | #[derive(StructOpt, Debug, Clone)] 17 | pub enum SetGetUnset<T: core::str::FromStr> 18 | where 19 | T::Err: core::fmt::Display + core::fmt::Debug, 20 | { 21 | /// Set the value 22 | Set { value: T }, 23 | /// Unset the value 24 | Unset, 25 | /// Get the value 26 | Get, 27 | } 28 | 29 | #[derive(StructOpt, Debug, Clone)] 30 | pub enum SetGet<T: core::str::FromStr> 31 | where 32 | T::Err: core::fmt::Display + core::fmt::Debug, 33 | { 34 | /// Set the value 35 | Set { value: T }, 36 | /// Get the value 37 | Get, 38 | } 39 | 40 | #[derive(StructOpt, Debug, Clone)] 41 | pub enum Get { 42 | /// Get the value 43 | Get, 44 | } 45 | 46 | impl<T: core::str::FromStr> From<SetGet<T>> for SetGetUnset<T> 47 | where 48 | T::Err: core::fmt::Display + core::fmt::Debug, 49 | { 50 | fn from(setget: SetGet<T>) -> SetGetUnset<T> { 51 | match setget { 52 | SetGet::Set { value } => SetGetUnset::Set { value }, 53 | SetGet::Get => SetGetUnset::Get, 54 | } 55 | } 56 | } 57 | 58 | #[derive(StructOpt, Debug, Clone)] 59 | #[structopt(rename_all = "snake_case")] 60 | pub enum BlockchainSettings { 61 | /// Base URL of the esplora service. 62 | /// 63 | /// eg. `https://blockstream.info/api/` 64 | BaseUrl(SetGet<String>), 65 | /// URL of the proxy to use to make requests to the Esplora server. 66 | /// 67 | /// The string should be formatted as: `<protocol>://<user>:<password>@host:<port>`. 68 | Proxy(SetGetUnset<String>), 69 | /// How many parallel requests to sent to the esplora service 70 | Concurrency(SetGetUnset<u8>), 71 | /// How many inactive addresses to give up scanning after 72 | StopGap(SetGet<usize>), 73 | } 74 | 75 | #[derive(StructOpt, Debug, Clone)] 76 | pub enum ConfigOpt { 77 | /// configure the esplora blockchain client. 78 | Blockchain(BlockchainSettings), 79 | /// Protocol specific configuration options. 80 | Protocol(Protocol), 81 | /// The wallet's descriptors. 82 | Descriptor(Descriptors), 83 | /// The wallet's signers. 84 | Signer(SignerActions), 85 | /// Get the gun directory currently being used. 86 | /// 87 | /// This can be changed by setting the $GUN_DIR environment variable. 88 | Dir(Get), 89 | } 90 | 91 | #[derive(StructOpt, Debug, Clone)] 92 | pub enum SignerActions { 93 | /// Add a signer 94 | Add(AddSigner), 95 | /// Remove a Signer 96 | Remove { index: usize }, 97 | /// List existing signers 98 | List, 99 | } 100 | 101 | #[derive(StructOpt, Debug, Clone)] 102 | pub enum AddSigner { 103 | /// Add a PSBT signer. 104 | /// 105 | /// Whenever a PSBT signer is asked to sign something it will simply write the PSBT out to the 106 | /// path and wait for you to sign it. 107 | Psbt { 108 | /// The path the signer will write PSBTs to so they can be signed. 109 | path: PathBuf, 110 | }, 111 | } 112 | 113 | #[derive(StructOpt, Debug, Clone)] 114 | pub enum Descriptors { 115 | /// The "external" descriptor (where gun derives receiving addresses from). 116 | External(Get), 117 | /// The "internal" descriptor (where gun derives change addresses from). 118 | Internal(Get), 119 | } 120 | 121 | #[derive(StructOpt, Debug, Clone)] 122 | pub enum Protocol { 123 | /// The betting protocol 124 | Bet(BetSettings), 125 | } 126 | 127 | #[derive(StructOpt, Debug, Clone)] 128 | pub enum BetSettings { 129 | /// The protocol secret is used to generate temporary keys used in betting. 130 | /// 131 | /// Unlike other configuration options it is stored in the database. 132 | ProtocolSecret(SetGet<ProtocolSecret>), 133 | } 134 | 135 | macro_rules! setgetunset { 136 | ($setget:expr, $config:ident, $config_path:ident, $config_sub:expr, $prop:ident) => { 137 | match $setget { 138 | SetGetUnset::Set { value } => { 139 | $config_sub.$prop = Some(value); 140 | Ok(CmdOutput::None) 141 | } 142 | SetGetUnset::Get => Ok(CmdOutput::EmphasisedItem { 143 | main: ( 144 | stringify!($prop), 145 | Cell::maybe_string($config_sub.$prop.as_ref()), 146 | ), 147 | other: vec![], 148 | }), 149 | SetGetUnset::Unset => { 150 | $config_sub.$prop = None; 151 | Ok(CmdOutput::None) 152 | } 153 | } 154 | }; 155 | } 156 | 157 | macro_rules! setget { 158 | ($setget:expr, $config:ident, $config_path:ident, $config_sub:expr, $prop:ident) => { 159 | match $setget { 160 | SetGet::Set { value } => { 161 | $config_sub.$prop = value; 162 | Ok(CmdOutput::None) 163 | } 164 | SetGet::Get => Ok(CmdOutput::EmphasisedItem { 165 | main: (stringify!($prop), Cell::string(&$config_sub.$prop)), 166 | other: vec![], 167 | }), 168 | } 169 | }; 170 | } 171 | 172 | pub fn run_config_cmd( 173 | wallet_dir: &Path, 174 | wallet: &GunWallet, 175 | config_path: &Path, 176 | opt: ConfigOpt, 177 | ) -> anyhow::Result<CmdOutput> { 178 | let mut config = cmd::load_config(config_path)?; 179 | let output = match opt { 180 | ConfigOpt::Blockchain(prop) => { 181 | let AnyBlockchainConfig::Esplora(esplora_config) = &mut config.blockchain; 182 | use BlockchainSettings::*; 183 | match prop { 184 | BaseUrl(setget) => setget!(setget, config, config_path, esplora_config, base_url), 185 | Proxy(setget) => setgetunset!(setget, config, config_path, esplora_config, proxy), 186 | Concurrency(setget) => { 187 | setgetunset!(setget, config, config_path, esplora_config, concurrency) 188 | } 189 | StopGap(setget) => setget!(setget, config, config_path, esplora_config, stop_gap), 190 | } 191 | } 192 | ConfigOpt::Protocol(protocol) => match protocol { 193 | Protocol::Bet(bet_settings) => match bet_settings { 194 | BetSettings::ProtocolSecret(setget) => { 195 | let db = wallet.gun_db(); 196 | match setget { 197 | SetGet::Get => { 198 | return Ok( 199 | eitem!( "protocol_secret" => Cell::maybe_string(db.get_entity::<ProtocolSecret>(ProtocolKind::Bet)?)), 200 | ) 201 | } 202 | SetGet::Set { value } => { 203 | db.safely_set_bet_protocol_secret(value)?; 204 | Ok(CmdOutput::None) 205 | } 206 | } 207 | } 208 | }, 209 | }, 210 | ConfigOpt::Descriptor(desc) => { 211 | let db = wallet.gun_db(); 212 | return Ok(match desc { 213 | Descriptors::External(Get::Get) => { 214 | eitem! { 215 | "external" => Cell::maybe_string(db.get_entity::<StringDescriptor>(KeychainKind::External)?.map(|x| x.0)), 216 | } 217 | } 218 | Descriptors::Internal(Get::Get) => { 219 | eitem! { 220 | "internal" => Cell::maybe_string(db.get_entity::<StringDescriptor>(KeychainKind::Internal)?.map(|x| x.0)) 221 | } 222 | } 223 | }); 224 | } 225 | ConfigOpt::Signer(action) => Ok(match action { 226 | SignerActions::Add(signer) => { 227 | match signer { 228 | AddSigner::Psbt { path } => config.signers.push(GunSigner::PsbtDir { path }), 229 | } 230 | CmdOutput::None 231 | } 232 | SignerActions::Remove { index } => { 233 | config.signers.remove(index); 234 | CmdOutput::None 235 | } 236 | SignerActions::List => { 237 | let rows = config 238 | .signers 239 | .iter() 240 | .enumerate() 241 | .map(|(i, signer)| { 242 | vec![ 243 | Cell::string(i), 244 | Cell::string(serde_json::to_string(&signer).unwrap()), 245 | ] 246 | }) 247 | .collect(); 248 | return Ok(CmdOutput::table(vec!["index", "signer"], rows)); 249 | } 250 | }), 251 | ConfigOpt::Dir(Get::Get) => { 252 | return Ok(eitem!( "GUN_DIR" => Cell::string(wallet_dir.display()))) 253 | } 254 | }; 255 | cmd::write_config(config_path, config)?; 256 | output 257 | } 258 | -------------------------------------------------------------------------------- /src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | mod bet; 2 | mod config; 3 | mod oracle; 4 | mod setup; 5 | mod wallet; 6 | pub use bet::*; 7 | pub use config::*; 8 | pub use oracle::*; 9 | pub use setup::*; 10 | pub use wallet::*; 11 | 12 | use crate::{ 13 | config::GunSigner, 14 | database::{ProtocolKind, StringDescriptor}, 15 | elog, 16 | keychain::ProtocolSecret, 17 | signers::{PsbtDirSigner, PwSeedSigner, XKeySigner}, 18 | wallet::GunWallet, 19 | }; 20 | use anyhow::Context; 21 | use bdk::{ 22 | bitcoin::{ 23 | consensus::encode, 24 | util::{ 25 | address::Payload, bip32::ExtendedPrivKey, psbt::PartiallySignedTransaction as Psbt, 26 | }, 27 | Address, Amount, Network, SignedAmount, Txid, 28 | }, 29 | blockchain::{ConfigurableBlockchain, EsploraBlockchain}, 30 | database::BatchDatabase, 31 | signer::Signer, 32 | sled, 33 | wallet::signer::SignerOrdering, 34 | KeychainKind, Wallet, 35 | }; 36 | use std::sync::Arc; 37 | 38 | use term_table::{row::Row, Table}; 39 | 40 | use crate::{ 41 | chrono::NaiveDateTime, 42 | config::{Config, VersionedConfig}, 43 | database::GunDatabase, 44 | keychain::Keychain, 45 | psbt_ext::PsbtFeeRate, 46 | FeeSpec, ValueChoice, 47 | }; 48 | use anyhow::anyhow; 49 | use std::{collections::HashMap, fs}; 50 | 51 | #[derive(Clone, Debug, structopt::StructOpt)] 52 | pub struct FeeArgs { 53 | /// The transaction fee to attach e.g. rate:4.5 (4.5 sats-per-byte), abs:300 (300 sats absolute 54 | /// fee), in-blocks:3 (set fee so that it is included in the next three blocks). 55 | #[structopt(default_value, long)] 56 | fee: FeeSpec, 57 | } 58 | 59 | pub enum FeeChoice { 60 | /// Pay an absolute fee 61 | Absolute(Amount), 62 | /// Pay a certain feerate in sats per vbyte 63 | Rate(f32), 64 | /// Use the estimated fee required to confirm in a certain number of blocks 65 | Speed(u32), 66 | } 67 | 68 | pub fn load_config(config_file: &std::path::Path) -> anyhow::Result<Config> { 69 | match config_file.exists() { 70 | true => { 71 | let json_config = fs::read_to_string(config_file)?; 72 | Ok(serde_json::from_str::<VersionedConfig>(&json_config) 73 | .context("Perhaps you are trying to load an old config?")? 74 | .into()) 75 | } 76 | false => return Err(anyhow!("missing config file at {}", config_file.display())), 77 | } 78 | } 79 | 80 | pub fn write_config(config_file: &std::path::Path, config: Config) -> anyhow::Result<()> { 81 | fs::write( 82 | config_file, 83 | serde_json::to_string_pretty(&config.into_versioned()) 84 | .unwrap() 85 | .as_bytes(), 86 | ) 87 | .context("writing config file")?; 88 | Ok(()) 89 | } 90 | 91 | pub fn read_yn(question: &str) -> bool { 92 | use std::io::{self, BufRead}; 93 | let stdin = io::stdin(); 94 | let mut lines = stdin.lock().lines(); 95 | elog!(@question "{} [y/n]? ", question.replace('\n', "\n> ")); 96 | lines 97 | .find_map( 98 | |line| match line.unwrap().trim_end().to_lowercase().as_str() { 99 | "y" => Some(true), 100 | "n" => Some(false), 101 | _ => { 102 | eprint!("> [y/n]? "); 103 | None 104 | } 105 | }, 106 | ) 107 | .unwrap_or(false) 108 | } 109 | 110 | pub fn read_input<V>( 111 | prompt: &str, 112 | possible: &str, 113 | validator: impl Fn(&str) -> anyhow::Result<V>, 114 | ) -> V { 115 | use std::io::{self, BufRead}; 116 | let stdin = io::stdin(); 117 | let lines = stdin.lock().lines().map(|x| x.unwrap()); 118 | eprint!("> {} [{}]? ", prompt.replace('\n', "\n> "), possible); 119 | for line in lines { 120 | match validator(line.trim_end()) { 121 | Ok(v) => return v, 122 | Err(_) => eprintln!("> ‘{}’ isn't valid. Try again [{}]", line, possible), 123 | } 124 | } 125 | eprintln!("STDIN terminated"); 126 | std::process::exit(2) 127 | } 128 | 129 | pub fn load_wallet( 130 | wallet_dir: &std::path::Path, 131 | ) -> anyhow::Result<(GunWallet, Option<Keychain>, Config)> { 132 | use bdk::keys::bip39::Mnemonic; 133 | 134 | if !wallet_dir.exists() { 135 | return Err(anyhow!( 136 | "No wallet found at {}. Run `gun init` to set a new one up or set --gun-dir.", 137 | wallet_dir.display() 138 | )); 139 | } 140 | 141 | let config = load_config(&wallet_dir.join("config.json")).context("loading configuration")?; 142 | let database = sled::open(wallet_dir.join("database.sled").to_str().unwrap()) 143 | .context("opening database.sled")?; 144 | 145 | let wallet_db = database 146 | .open_tree("wallet") 147 | .context("opening wallet tree")?; 148 | 149 | let esplora = EsploraBlockchain::from_config(config.blockchain_config())?; 150 | 151 | let gun_db = GunDatabase::new(database.open_tree("gun").context("opening gun db tree")?); 152 | 153 | let external = gun_db 154 | .get_entity::<StringDescriptor>(KeychainKind::External)? 155 | .ok_or(anyhow!( 156 | "external descriptor couldn't be retrieved from database" 157 | ))?; 158 | let internal = gun_db.get_entity::<StringDescriptor>(KeychainKind::Internal)?; 159 | 160 | let mut wallet = Wallet::new( 161 | &external.0, 162 | internal.as_ref().map(|x| &x.0), 163 | config.network, 164 | wallet_db, 165 | esplora, 166 | ) 167 | .context("Initializing wallet from descriptors")?; 168 | 169 | for (i, signer) in config.signers.iter().enumerate() { 170 | let signer: Arc<dyn Signer> = match signer { 171 | GunSigner::PsbtDir { 172 | path: psbt_signer_dir, 173 | } => Arc::new(PsbtDirSigner::create( 174 | psbt_signer_dir.to_owned(), 175 | config.network, 176 | )), 177 | GunSigner::SeedWordsFile { 178 | passphrase_fingerprint, 179 | } => { 180 | let file_path = wallet_dir.join("seed.txt"); 181 | let seed_words = fs::read_to_string(&file_path).context("loading seed words")?; 182 | let mnemonic = Mnemonic::parse(&seed_words).map_err(|e| { 183 | anyhow!( 184 | "parsing seed phrase in '{}' failed: {}", 185 | file_path.display(), 186 | e 187 | ) 188 | })?; 189 | 190 | match passphrase_fingerprint { 191 | Some(fingerprint) => Arc::new(PwSeedSigner { 192 | mnemonic, 193 | network: config.network, 194 | master_fingerprint: *fingerprint, 195 | }), 196 | None => Arc::new(XKeySigner { 197 | master_xkey: ExtendedPrivKey::new_master( 198 | config.network, 199 | &mnemonic.to_seed(""), 200 | ) 201 | .unwrap(), 202 | }), 203 | } 204 | } 205 | }; 206 | wallet.add_signer( 207 | KeychainKind::External, //NOTE: will sign internal inputs as well! 208 | SignerOrdering(i), 209 | signer, 210 | ); 211 | } 212 | 213 | let keychain = gun_db 214 | .get_entity::<ProtocolSecret>(ProtocolKind::Bet)? 215 | .map(Keychain::from); 216 | let gun_wallet = GunWallet::new(wallet, gun_db); 217 | 218 | Ok((gun_wallet, keychain, config)) 219 | } 220 | 221 | pub fn load_wallet_db(wallet_dir: &std::path::Path) -> anyhow::Result<impl BatchDatabase> { 222 | let database = sled::open(wallet_dir.join("database.sled").to_str().unwrap()) 223 | .context("opening database.sled")?; 224 | database.open_tree("wallet").context("opening wallet tree") 225 | } 226 | 227 | #[derive(Debug, Clone)] 228 | pub struct TableData { 229 | col_names: Vec<String>, 230 | rows: Vec<Vec<Cell>>, 231 | } 232 | 233 | #[derive(serde::Serialize, Debug, Clone)] 234 | #[serde(untagged)] 235 | pub enum Cell { 236 | String(String), 237 | Amount(#[serde(with = "bdk::bitcoin::util::amount::serde::as_sat")] Amount), 238 | SignedAmount(#[serde(with = "bdk::bitcoin::util::amount::serde::as_sat")] SignedAmount), 239 | Int(u64), 240 | Empty, 241 | DateTime(u64), 242 | List(Vec<Cell>), 243 | } 244 | 245 | impl From<String> for Cell { 246 | fn from(string: String) -> Self { 247 | Cell::String(string) 248 | } 249 | } 250 | 251 | pub fn format_amount(amount: Amount) -> String { 252 | if amount == Amount::ZERO { 253 | "0".to_string() 254 | } else { 255 | let mut string = amount.to_string(); 256 | string.insert(string.len() - 7, ' '); 257 | string.insert(string.len() - 11, ' '); 258 | string.trim_end_matches(" BTC").to_string() 259 | } 260 | } 261 | 262 | pub fn format_signed_amount(amount: SignedAmount) -> String { 263 | if amount == SignedAmount::ZERO { 264 | "0".to_string() 265 | } else { 266 | let mut string = amount.to_string(); 267 | string.insert(string.len() - 7, ' '); 268 | string.insert(string.len() - 11, ' '); 269 | let string = string.trim_end_matches(" BTC").trim_start_matches('-'); 270 | if amount.is_negative() { 271 | format!("-{}", string) 272 | } else { 273 | format!("+{}", string) 274 | } 275 | } 276 | } 277 | 278 | pub fn sanitize_str(string: &mut String) { 279 | string.retain(|c| !c.is_control()); 280 | } 281 | 282 | impl Cell { 283 | pub fn string<T: core::fmt::Display>(t: T) -> Self { 284 | let mut string = t.to_string(); 285 | // Remove control characters to prevent tricks 286 | sanitize_str(&mut string); 287 | Self::String(string) 288 | } 289 | 290 | pub fn maybe_string<T: core::fmt::Display>(t: Option<T>) -> Self { 291 | t.map(Self::string).unwrap_or(Cell::Empty) 292 | } 293 | 294 | pub fn datetime(dt: NaiveDateTime) -> Self { 295 | Self::DateTime(dt.timestamp() as u64) 296 | } 297 | 298 | pub fn render(self) -> String { 299 | use Cell::*; 300 | match self { 301 | String(string) => string, 302 | Amount(amount) => format_amount(amount), 303 | SignedAmount(amount) => format_signed_amount(amount), 304 | Int(integer) => integer.to_string(), 305 | Empty => "".into(), 306 | DateTime(timestamp) => NaiveDateTime::from_timestamp(timestamp as i64, 0) 307 | .format("%Y-%m-%dT%H:%M:%S") 308 | .to_string(), 309 | List(list) => list 310 | .into_iter() 311 | .map(Cell::render) 312 | .collect::<Vec<_>>() 313 | .join("\n"), 314 | } 315 | } 316 | 317 | pub fn render_tabs(self) -> String { 318 | use Cell::*; 319 | match self { 320 | String(string) => string, 321 | Amount(amount) => amount.as_sat().to_string(), 322 | SignedAmount(amount) => amount.as_sat().to_string(), 323 | Int(integer) => integer.to_string(), 324 | Empty => "".into(), 325 | DateTime(timestamp) => NaiveDateTime::from_timestamp(timestamp as i64, 0) 326 | .format("%Y-%m-%dT%H:%M:%S") 327 | .to_string(), 328 | List(list) => list 329 | .into_iter() 330 | .map(Cell::render_tabs) 331 | .collect::<Vec<_>>() 332 | .join(" "), 333 | } 334 | } 335 | 336 | pub fn render_json(self) -> serde_json::Value { 337 | use Cell::*; 338 | match self { 339 | String(string) => serde_json::Value::String(string), 340 | SignedAmount(amount) => serde_json::Value::Number(amount.as_sat().into()), 341 | Amount(amount) => serde_json::Value::Number(amount.as_sat().into()), 342 | Int(integer) => serde_json::Value::Number(integer.into()), 343 | DateTime(timestamp) => serde_json::Value::Number(timestamp.into()), 344 | Empty => serde_json::Value::Null, 345 | List(list) => serde_json::Value::Array( 346 | list.into_iter() 347 | .map(|x| serde_json::to_value(&x).unwrap()) 348 | .collect(), 349 | ), 350 | } 351 | } 352 | } 353 | 354 | #[derive(Debug)] 355 | pub enum CmdOutput { 356 | Table(TableData), 357 | Json(serde_json::Value), 358 | Item(Vec<(&'static str, Cell)>), 359 | /// An item where one field is deemed the "main" one. 360 | /// Normally the main one will be printed. 361 | EmphasisedItem { 362 | main: (&'static str, Cell), 363 | other: Vec<(&'static str, Cell)>, 364 | }, 365 | List(Vec<Cell>), 366 | None, 367 | } 368 | 369 | impl CmdOutput { 370 | pub fn table<S: Into<String>>(col_names: Vec<S>, rows: Vec<Vec<Cell>>) -> Self { 371 | CmdOutput::Table(TableData { 372 | col_names: col_names.into_iter().map(Into::into).collect(), 373 | rows, 374 | }) 375 | } 376 | 377 | pub fn render(self) -> Option<String> { 378 | use CmdOutput::*; 379 | 380 | Some(match self { 381 | Table(table_data) => { 382 | let mut table = term_table::Table::new(); 383 | table.add_row(Row::new(table_data.col_names.to_vec())); 384 | for row in table_data.rows.into_iter() { 385 | table.add_row(Row::new(row.into_iter().map(Cell::render))); 386 | } 387 | table.render() 388 | } 389 | Json(json) => serde_json::to_string_pretty(&json).unwrap(), 390 | Item(item) => { 391 | let mut table = term_table::Table::new(); 392 | for (key, value) in item { 393 | if matches!(value, Cell::Amount(_) | Cell::SignedAmount(_)) { 394 | table.add_row(Row::new(vec![format!("{} (BTC)", key), value.render()])) 395 | } else { 396 | table.add_row(Row::new(vec![key.to_string(), value.render()])) 397 | } 398 | } 399 | table.render() 400 | } 401 | List(list) => list 402 | .into_iter() 403 | .map(Cell::render) 404 | .collect::<Vec<_>>() 405 | .join("\n"), 406 | EmphasisedItem { main, .. } => main.1.render(), 407 | None => return Option::None, 408 | }) 409 | } 410 | 411 | pub fn render_simple(self) -> String { 412 | use CmdOutput::*; 413 | match self { 414 | Table(table_data) => table_data 415 | .rows 416 | .into_iter() 417 | .map(|row| { 418 | row.into_iter() 419 | .map(Cell::render_tabs) 420 | .collect::<Vec<_>>() 421 | .join("\t") 422 | }) 423 | .collect::<Vec<_>>() 424 | .join("\n"), 425 | Json(json) => serde_json::to_string(&json).unwrap(), 426 | Item(item) => item 427 | .into_iter() 428 | .map(|(k, v)| format!("{}\t{}", k, v.render_tabs())) 429 | .collect::<Vec<_>>() 430 | .join("\n"), 431 | EmphasisedItem { main, other } => core::iter::once(main) 432 | .chain(other.into_iter()) 433 | .map(|(k, v)| format!("{}\t{}", k, v.render_tabs())) 434 | .collect::<Vec<_>>() 435 | .join("\n"), 436 | List(list) => list 437 | .into_iter() 438 | .map(Cell::render_tabs) 439 | .collect::<Vec<_>>() 440 | .join("\t"), 441 | None => String::new(), 442 | } 443 | } 444 | 445 | pub fn render_json(self) -> serde_json::Value { 446 | use CmdOutput::*; 447 | match self { 448 | Table(table) => { 449 | let col_names = table.col_names; 450 | let hash_maps = table 451 | .rows 452 | .into_iter() 453 | .map(|row| { 454 | row.into_iter() 455 | .enumerate() 456 | .map(|(i, cell)| (col_names[i].clone(), cell.render_json())) 457 | .collect::<HashMap<_, _>>() 458 | }) 459 | .collect::<Vec<_>>(); 460 | 461 | serde_json::to_value(&hash_maps).unwrap() 462 | } 463 | Item(item) => { 464 | serde_json::to_value(&item.into_iter().collect::<HashMap<_, _>>()).unwrap() 465 | } 466 | EmphasisedItem { main, other } => serde_json::to_value( 467 | core::iter::once(main) 468 | .chain(other.into_iter()) 469 | .collect::<HashMap<_, _>>(), 470 | ) 471 | .unwrap(), 472 | Json(item) => item, 473 | List(list) => serde_json::to_value(list).unwrap(), 474 | None => serde_json::Value::Null, 475 | } 476 | } 477 | } 478 | 479 | pub fn display_psbt(network: Network, psbt: &Psbt) -> String { 480 | let mut table = Table::new(); 481 | let mut header = Some("in".to_string()); 482 | let mut input_total = Amount::ZERO; 483 | for (i, psbt_input) in psbt.inputs.iter().enumerate() { 484 | let txout = psbt_input.witness_utxo.as_ref().unwrap(); 485 | let input = &psbt.unsigned_tx.input[i]; 486 | let _address = Payload::from_script(&txout.script_pubkey) 487 | .map(|payload| Address { payload, network }.to_string()) 488 | .unwrap_or(txout.script_pubkey.to_string()); 489 | let value = Amount::from_sat(txout.value); 490 | table.add_row(Row::new(vec![ 491 | header.take().unwrap_or("".to_string()), 492 | input.previous_output.to_string(), 493 | format_amount(value), 494 | ])); 495 | input_total += value; 496 | } 497 | table.add_row(Row::new(vec![ 498 | "".to_string(), 499 | "total".into(), 500 | format_amount(input_total), 501 | ])); 502 | 503 | let mut output_total = Amount::ZERO; 504 | let mut header = Some("out".to_string()); 505 | for (i, _) in psbt.outputs.iter().enumerate() { 506 | let txout = &psbt.unsigned_tx.output[i]; 507 | let address = Payload::from_script(&txout.script_pubkey) 508 | .map(|payload| Address { payload, network }.to_string()) 509 | .unwrap_or(txout.script_pubkey.to_string()); 510 | let value = Amount::from_sat(txout.value); 511 | table.add_row(Row::new(vec![ 512 | header.take().unwrap_or("".to_string()), 513 | address, 514 | format_amount(value), 515 | ])); 516 | output_total += value; 517 | } 518 | 519 | table.add_row(Row::new(vec![ 520 | "".to_string(), 521 | "total".into(), 522 | format_amount(output_total), 523 | ])); 524 | let (fee, feerate, feerate_estimated) = psbt.fee(); 525 | 526 | let est = if feerate_estimated { "(est.)" } else { "" }; 527 | table.add_row(Row::new(vec![ 528 | "fee", 529 | &format!("{:.3} sats/vb {}", feerate.as_sat_vb(), est), 530 | &format_amount(fee), 531 | ])); 532 | 533 | table.render() 534 | } 535 | 536 | pub fn decide_to_broadcast( 537 | network: Network, 538 | blockchain: &impl bdk::blockchain::Broadcast, 539 | psbt: Psbt, 540 | yes: bool, 541 | print_tx: bool, 542 | ) -> anyhow::Result<(CmdOutput, Option<Txid>)> { 543 | use crate::item; 544 | if yes 545 | || read_yn(&format!( 546 | "This is the transaction that will be broadcast.\n{}Ok", 547 | display_psbt(network, &psbt) 548 | )) 549 | { 550 | let tx = psbt.extract_tx(); 551 | 552 | if print_tx { 553 | Ok(( 554 | CmdOutput::EmphasisedItem { 555 | main: ( 556 | "tx", 557 | Cell::String(crate::hex::encode(&encode::serialize(&tx))), 558 | ), 559 | other: vec![], 560 | }, 561 | Some(tx.txid()), 562 | )) 563 | } else { 564 | use bdk::blockchain::Broadcast; 565 | let txid = tx.txid(); 566 | Broadcast::broadcast(blockchain, tx)?; 567 | Ok((item! { "txid" => Cell::string(txid)}, Some(txid))) 568 | } 569 | } else { 570 | Ok((CmdOutput::None, None)) 571 | } 572 | } 573 | 574 | fn ensure_not_watch_only(wallet: &GunWallet) -> anyhow::Result<()> { 575 | if wallet.is_watch_only() { 576 | Err(anyhow!( 577 | "You cannot do this command because this wallet is watch-only" 578 | )) 579 | } else { 580 | Ok(()) 581 | } 582 | } 583 | 584 | #[macro_export] 585 | macro_rules! item { 586 | ($($key:literal => $value:expr),+$(,)?) => {{ 587 | let mut list = vec![]; 588 | $( 589 | list.push(($key, $value)); 590 | )* 591 | $crate::cmd::CmdOutput::Item(list) 592 | }} 593 | } 594 | 595 | #[macro_export] 596 | macro_rules! eitem { 597 | ($main_key:literal => $main_value:expr $(,$key:literal => $value:expr)*$(,)?) => {{ 598 | #[allow(unused_mut)] 599 | let mut list = vec![]; 600 | $( 601 | list.push(($key, $value)); 602 | )* 603 | $crate::cmd::CmdOutput::EmphasisedItem { main: ($main_key, $main_value), other: list } 604 | }} 605 | } 606 | 607 | #[macro_export] 608 | macro_rules! elog { 609 | (@warning $($tt:tt)*) => { eprint!("\u{26A0} "); eprintln!($($tt)*);}; 610 | (@info $($tt:tt)*) => { eprint!("\u{2139} "); eprintln!($($tt)*);}; 611 | (@celebration $($tt:tt)*) => { eprint!("\u{1F389} "); eprintln!($($tt)*);}; 612 | (@user_action $($tt:tt)*) => { eprint!("\u{1F449} "); eprintln!($($tt)*);}; 613 | (@recoverable_error $($tt:tt)*) => { eprint!("\u{1F4A5} "); eprintln!($($tt)*);}; 614 | (@user_error $($tt:tt)*) => { eprint!("\u{274C}"); eprintln!($($tt)*);}; 615 | (@question $($tt:tt)*) => { eprint!("\u{2753}"); eprintln!($($tt)*); }; 616 | (@suggestion $($tt:tt)*) => { eprint!("\u{1F4A1}"); eprintln!($($tt)*); };} 617 | -------------------------------------------------------------------------------- /src/cmd/oracle.rs: -------------------------------------------------------------------------------- 1 | use crate::{cmd, database::GunDatabase, elog, item, OracleInfo, Url}; 2 | use anyhow::anyhow; 3 | use olivia_core::{http::RootResponse, OracleId}; 4 | use olivia_secp256k1::Secp256k1; 5 | use std::str::FromStr; 6 | 7 | use super::{Cell, CmdOutput}; 8 | 9 | #[derive(structopt::StructOpt, Debug, Clone)] 10 | /// Oracle commands 11 | pub enum OracleOpt { 12 | /// Trust a new oracle 13 | Add { 14 | /// The base url of the oracle e.g. https://h00.ooo 15 | url: String, 16 | /// Automatically confirm trust 17 | #[structopt(short, long)] 18 | yes: bool, 19 | }, 20 | /// List oracles 21 | List, 22 | /// Remove an oracle from the list of trusted oracles 23 | Remove { 24 | /// The oralce's id 25 | oracle_id: OracleId, 26 | }, 27 | /// Show information about an oracle 28 | Show { 29 | /// The oracle's id 30 | oracle_id: OracleId, 31 | }, 32 | } 33 | 34 | pub fn run_oralce_cmd(gun_db: &GunDatabase, cmd: OracleOpt) -> anyhow::Result<CmdOutput> { 35 | match cmd { 36 | OracleOpt::Add { url, yes } => { 37 | let url = 38 | Url::from_str(&url).or_else(|_| Url::from_str(&format!("https://{}", url)))?; 39 | let oracle_id = url 40 | .host() 41 | .ok_or(anyhow!("Oracle url missing host"))? 42 | .to_string(); 43 | match gun_db.get_entity::<OracleInfo>(oracle_id.clone())? { 44 | Some(_) => { 45 | elog!(@info "Oracle {} is already trusted", oracle_id); 46 | } 47 | None => { 48 | let root_response = ureq::get(url.as_str()) 49 | .call()? 50 | .into_json::<RootResponse<Secp256k1>>()?; 51 | let oracle_info = OracleInfo { 52 | id: oracle_id.clone(), 53 | oracle_keys: root_response.public_keys, 54 | }; 55 | 56 | println!("{}", serde_json::to_string_pretty(&oracle_info).unwrap()); 57 | 58 | if yes || cmd::read_yn("Trust the oracle displayed above") { 59 | gun_db.insert_entity(oracle_id, oracle_info)?; 60 | } 61 | } 62 | } 63 | 64 | Ok(CmdOutput::None) 65 | } 66 | OracleOpt::List => { 67 | let oracles = gun_db.list_entities_print_error::<OracleInfo>(); 68 | let mut rows = vec![]; 69 | 70 | for (oracle_id, oracle_info) in oracles { 71 | let oracle_keys = oracle_info.oracle_keys; 72 | rows.push(vec![ 73 | Cell::String(oracle_id), 74 | Cell::string(oracle_keys.announcement), 75 | Cell::string(oracle_keys.olivia_v1.is_some()), 76 | Cell::string(oracle_keys.ecdsa_v1.is_some()), 77 | ]); 78 | } 79 | Ok(CmdOutput::table( 80 | vec!["id", "attestation-key", "olivia-v1", "ecdsa-v1"], 81 | rows, 82 | )) 83 | } 84 | OracleOpt::Remove { oracle_id } => { 85 | if gun_db 86 | .remove_entity::<OracleInfo>(oracle_id.clone())? 87 | .is_none() 88 | { 89 | return Err(anyhow!("oralce '{}' doesn't exist", oracle_id)); 90 | } 91 | Ok(CmdOutput::None) 92 | } 93 | OracleOpt::Show { oracle_id } => { 94 | let oracle_info = gun_db 95 | .get_entity::<OracleInfo>(oracle_id.clone())? 96 | .ok_or(anyhow!("Oracle {} not in database", oracle_id))?; 97 | let oracle_keys = oracle_info.oracle_keys; 98 | 99 | Ok(item! { 100 | "id" => Cell::string(oracle_id), 101 | "olivia-v1-key" => oracle_keys.olivia_v1.map(Cell::string).unwrap_or(Cell::Empty), 102 | "ecdsa-v1-key" => oracle_keys.ecdsa_v1.map(Cell::string).unwrap_or(Cell::Empty), 103 | "announcement" => Cell::string(oracle_keys.announcement), 104 | }) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/cmd/setup.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | bip85::get_bip85_bytes, 3 | cmd::{self}, 4 | config::{Config, GunSigner}, 5 | database::{GunDatabase, ProtocolKind, StringDescriptor}, 6 | elog, 7 | keychain::ProtocolSecret, 8 | }; 9 | use anyhow::{anyhow, Context}; 10 | use bdk::{ 11 | bitcoin::{ 12 | secp256k1::Secp256k1, 13 | util::bip32::{ExtendedPrivKey, ExtendedPubKey, Fingerprint}, 14 | Network, 15 | }, 16 | database::MemoryDatabase, 17 | descriptor::{ExtendedDescriptor, IntoWalletDescriptor}, 18 | keys::{ 19 | bip39::{Language, Mnemonic, WordCount}, 20 | GeneratableKey, GeneratedKey, 21 | }, 22 | miniscript::Segwitv0, 23 | sled, 24 | template::{Bip84, Bip84Public}, 25 | KeychainKind, Wallet, 26 | }; 27 | use miniscript::{Descriptor, DescriptorPublicKey, TranslatePk1}; 28 | use olivia_secp256k1::fun::hex; 29 | use serde::Deserialize; 30 | use std::{fs, io, path::PathBuf, str::FromStr}; 31 | use structopt::StructOpt; 32 | 33 | use super::CmdOutput; 34 | 35 | pub enum NWords {} 36 | 37 | #[derive(Clone, Debug, StructOpt)] 38 | pub struct CommonArgs { 39 | /// The network name 40 | #[structopt( 41 | long, 42 | default_value = "bitcoin", 43 | name = "bitcoin|regtest|testnet|signet" 44 | )] 45 | network: Network, 46 | } 47 | 48 | #[derive(Clone, Debug, StructOpt)] 49 | pub enum SetupOpt { 50 | /// Setup using a seedphrase 51 | Seed { 52 | #[structopt(flatten)] 53 | common_args: CommonArgs, 54 | /// Existing BIP39 seed words file. Use "-" to read words from stdin. 55 | #[structopt(long, name = "FILE")] 56 | from_existing: Option<String>, 57 | #[structopt(long, default_value = "12", name = "[12|24]")] 58 | /// The number of BIP39 seed words to use 59 | n_words: usize, 60 | /// Password protect your coins 61 | #[structopt(long)] 62 | use_passphrase: bool, 63 | }, 64 | /// Setup using a output descriptors 65 | /// 66 | /// This option is intended for people who know what they are doing! 67 | /// Keep in mind that descriptors with private keys in them will be stored in the database as plaintext. 68 | Descriptor { 69 | #[structopt(flatten)] 70 | common_args: CommonArgs, 71 | /// The external descriptor for the wallet 72 | #[structopt(name = "external-descriptor")] 73 | external: String, 74 | /// Optional internal (change) descriptor 75 | #[structopt(name = "internal-descriptor")] 76 | internal: Option<String>, 77 | }, 78 | /// Setup using an extended public key descriptor. 79 | /// 80 | /// The descriptor must be in [masterfingerprint/hardened'/derivation'/path']xkey format e.g. 81 | /// 82 | /// $ gun setup xkey "[E83E2DB9/84'/0'/0']xpub66...mSXJj" 83 | /// 84 | /// The key can be an xpriv or or an xpub. 85 | /// If you use an xpriv, keep in mind that gun will store the descriptor in the database in plaintext. 86 | #[structopt(name = "xkey")] 87 | XKey { 88 | #[structopt(flatten)] 89 | common_args: CommonArgs, 90 | /// the extended key descriptor 91 | #[structopt(name = "xkey-descriptor")] 92 | xkey: String, 93 | }, 94 | /// Setup with a ColdCard via SD card 95 | /// 96 | /// Requires a `coldcard-export.json` in this directory. 97 | /// On Coldcard: Advanced -> MicroSD Card -> Export Wallet -> Generic JSON 98 | /// Unsigned PSBTs will be saved to this SD card path, and read once signed. 99 | Coldcard { 100 | #[structopt(flatten)] 101 | common_args: CommonArgs, 102 | /// Coldcard SD card directory. PSBTs will be saved, signed, and loaded here. 103 | #[structopt(parse(from_os_str))] 104 | coldcard_sd_dir: PathBuf, 105 | /// Import entropy from deterministic entropy file. 106 | /// On Coldcard: Advanced -> Derive Entropy -> 64-bytes hex. 107 | /// Enter index 330 and press 1 to export to SD. 108 | /// Gun will use entropy from drv-hex-idx330.txt. 109 | /// This is necessary for gun to be able to execute protocols which need auxiliary keys (like gun bet). 110 | #[structopt(long)] 111 | import_entropy: bool, 112 | }, 113 | } 114 | 115 | #[derive(Deserialize)] 116 | struct WalletExport { 117 | xfp: Fingerprint, 118 | bip84: BIP84Export, 119 | } 120 | 121 | #[derive(Deserialize)] 122 | struct BIP84Export { 123 | xpub: ExtendedPubKey, 124 | } 125 | 126 | pub fn run_setup(wallet_dir: &std::path::Path, cmd: SetupOpt) -> anyhow::Result<CmdOutput> { 127 | if wallet_dir.exists() { 128 | return Err(anyhow!( 129 | "wallet directory {} already exists -- delete it to create a new wallet", 130 | wallet_dir.display() 131 | )); 132 | } 133 | let secp = Secp256k1::<bdk::bitcoin::secp256k1::All>::new(); 134 | 135 | let (config, protocol_secret, (external, internal), seed_words_file) = match cmd { 136 | SetupOpt::Seed { 137 | common_args, 138 | from_existing, 139 | n_words, 140 | use_passphrase, 141 | } => { 142 | let mnemonic = match from_existing { 143 | Some(existing_words_file) => { 144 | let seed_words = match existing_words_file.as_str() { 145 | "-" => { 146 | use io::Read; 147 | let mut words = String::new(); 148 | io::stdin().read_to_string(&mut words)?; 149 | words 150 | } 151 | existing_words_file => { 152 | let existing_words_file = PathBuf::from_str(existing_words_file) 153 | .context("parsing existing seed words file name")?; 154 | fs::read_to_string(&existing_words_file).context(format!( 155 | "loading existing seed words from {}", 156 | existing_words_file.display() 157 | ))? 158 | } 159 | }; 160 | Mnemonic::parse(&seed_words).context("parsing existing seedwords")? 161 | } 162 | None => { 163 | let seed_words: GeneratedKey<_, Segwitv0> = Mnemonic::generate(( 164 | match n_words { 165 | 12 => WordCount::Words12, 166 | 24 => WordCount::Words24, 167 | _ => return Err(anyhow!("Only 12 or 24 words are supported")), 168 | }, 169 | Language::English, 170 | )) 171 | .expect("cannot fail"); 172 | seed_words.into_key() 173 | } 174 | }; 175 | 176 | let passphrase = if use_passphrase { 177 | elog!(@warning "If you lose or forget your passphrase, you will lose access to your funds."); 178 | elog!(@warning "You MUST store your passphrase with your seed words in order to make a complete backup."); 179 | loop { 180 | let passphrase = 181 | rpassword::prompt_password_stderr("Enter your wallet passphrase:")?; 182 | let passphrase_confirmation = 183 | rpassword::prompt_password_stderr("Enter your wallet passphrase again:")?; 184 | if !passphrase.eq(&passphrase_confirmation) { 185 | elog!(@user_error "Mismatching passphrases. Try again.\n"); 186 | } else { 187 | break passphrase; 188 | } 189 | } 190 | } else { 191 | "".to_string() 192 | }; 193 | 194 | let sw_file = wallet_dir.join("seed.txt"); 195 | if cmd::read_yn("Do you want gun to print out your seed words now to make a backup?") { 196 | let printed = mnemonic 197 | .word_iter() 198 | .enumerate() 199 | .map(|(i, word)| format!("{}: {}", i + 1, word)) 200 | .collect::<Vec<_>>() 201 | .join("\n"); 202 | println!("{}", printed); 203 | } else { 204 | elog!( 205 | @suggestion 206 | "Err okay then...make sure you backup {} after this.", 207 | sw_file.display() 208 | ); 209 | } 210 | 211 | let seed_bytes = mnemonic.to_seed(passphrase); 212 | let xpriv = ExtendedPrivKey::new_master(common_args.network, &seed_bytes).unwrap(); 213 | 214 | let bip85_bytes: [u8; 64] = get_bip85_bytes(xpriv, 330, &secp); 215 | 216 | let master_fingerprint = xpriv.fingerprint(&secp); 217 | 218 | let signers = vec![GunSigner::SeedWordsFile { 219 | passphrase_fingerprint: if use_passphrase { 220 | Some(master_fingerprint) 221 | } else { 222 | None 223 | }, 224 | }]; 225 | 226 | let (external, _) = Bip84(xpriv, KeychainKind::External) 227 | .into_wallet_descriptor(&secp, common_args.network)?; 228 | let (internal, _) = Bip84(xpriv, KeychainKind::Internal) 229 | .into_wallet_descriptor(&secp, common_args.network)?; 230 | ( 231 | Config { 232 | signers, 233 | ..Config::default_config(common_args.network) 234 | }, 235 | Some(bip85_bytes), 236 | (external.to_string(), Some(internal.to_string())), 237 | Some((sw_file, mnemonic.word_iter().collect::<Vec<_>>().join(" "))), 238 | ) 239 | } 240 | SetupOpt::Descriptor { 241 | common_args, 242 | external, 243 | internal, 244 | } => { 245 | // Check descriptors are valid 246 | let _ = Wallet::new_offline( 247 | &external, 248 | internal.as_ref(), 249 | common_args.network, 250 | MemoryDatabase::default(), 251 | )?; 252 | 253 | ( 254 | Config::default_config(common_args.network), 255 | None, 256 | (external, internal), 257 | None, 258 | ) 259 | } 260 | SetupOpt::XKey { 261 | common_args, 262 | ref xkey, 263 | } => { 264 | let external = set_network(&format!("wpkh({}/0/*)", xkey), common_args.network)?; 265 | let internal = set_network(&format!("wpkh({}/1/*)", xkey), common_args.network)?; 266 | ( 267 | Config::default_config(common_args.network), 268 | None, 269 | (external, Some(internal)), 270 | None, 271 | ) 272 | } 273 | SetupOpt::Coldcard { 274 | common_args, 275 | coldcard_sd_dir, 276 | import_entropy, 277 | } => { 278 | let bip85_bytes = if import_entropy { 279 | let entropy_file = coldcard_sd_dir.join("drv-hex-idx330.txt"); 280 | let contents = match fs::read_to_string(entropy_file.clone()) { 281 | Ok(contents) => contents, 282 | Err(e) => { 283 | return Err(anyhow!( 284 | "Could not find entropy export {}.\n{}", 285 | entropy_file.display(), 286 | e 287 | )) 288 | } 289 | }; 290 | let hex_entropy = contents 291 | .lines() 292 | .nth(1) 293 | .ok_or(anyhow!("Unable to read second line from entropy file"))?; 294 | 295 | let hex_vec = hex::decode(hex_entropy).with_context(|| { 296 | format!("importing entropy from {}", entropy_file.display()) 297 | })?; 298 | if hex_vec.len() != 64 { 299 | return Err(anyhow!("entropy in {} wasn't the right length. We expected 64 bytes of hex but got {}", entropy_file.display(), hex_vec.len())); 300 | } 301 | let mut bip85_bytes = [0u8; 64]; 302 | bip85_bytes.copy_from_slice(&hex_vec[..]); 303 | Some(bip85_bytes) 304 | } else { 305 | None 306 | }; 307 | 308 | let wallet_export_file = coldcard_sd_dir.join("coldcard-export.json"); 309 | let wallet_export_str = match fs::read_to_string(wallet_export_file.clone()) { 310 | Ok(contents) => contents, 311 | Err(e) => { 312 | return Err(anyhow!( 313 | "Could not read {}.\n{}", 314 | wallet_export_file.display(), 315 | e 316 | )) 317 | } 318 | }; 319 | let mut wallet_export = serde_json::from_str::<WalletExport>(&wallet_export_str)?; 320 | wallet_export.bip84.xpub.network = common_args.network; 321 | let (external, _) = Bip84Public( 322 | wallet_export.bip84.xpub, 323 | wallet_export.xfp, 324 | KeychainKind::External, 325 | ) 326 | .into_wallet_descriptor(&secp, common_args.network)?; 327 | let (internal, _) = Bip84Public( 328 | wallet_export.bip84.xpub, 329 | wallet_export.xfp, 330 | KeychainKind::Internal, 331 | ) 332 | .into_wallet_descriptor(&secp, common_args.network)?; 333 | 334 | let signers = vec![GunSigner::PsbtDir { 335 | path: coldcard_sd_dir, 336 | }]; 337 | 338 | ( 339 | Config { 340 | signers, 341 | ..Config::default_config(common_args.network) 342 | }, 343 | bip85_bytes, 344 | (external.to_string(), Some(internal.to_string())), 345 | None, 346 | ) 347 | } 348 | }; 349 | 350 | std::fs::create_dir(&wallet_dir)?; 351 | 352 | let config_file = wallet_dir.join("config.json"); 353 | 354 | let gun_db = GunDatabase::new( 355 | sled::open(wallet_dir.join("database.sled").to_str().unwrap())?.open_tree("gun")?, 356 | ); 357 | 358 | if let Some(protocol_secret) = protocol_secret { 359 | gun_db.insert_entity(ProtocolKind::Bet, ProtocolSecret::Bytes(protocol_secret))?; 360 | } 361 | 362 | let _ = ExtendedDescriptor::parse_descriptor(&secp, &external) 363 | .context("validating external descriptor")?; 364 | gun_db.insert_entity(KeychainKind::External, StringDescriptor(external))?; 365 | 366 | if let Some(internal) = internal { 367 | let _ = ExtendedDescriptor::parse_descriptor(&secp, &internal) 368 | .context("validating internal descriptor")?; 369 | gun_db.insert_entity(KeychainKind::Internal, StringDescriptor(internal))?; 370 | } 371 | 372 | cmd::write_config(&config_file, config)?; 373 | 374 | if let Some((path, content)) = seed_words_file { 375 | std::fs::write(path, content)?; 376 | } 377 | 378 | elog!(@celebration "Successfully created wallet at {}", wallet_dir.display()); 379 | Ok(CmdOutput::None) 380 | } 381 | 382 | fn set_network(descriptor: &str, network: Network) -> anyhow::Result<String> { 383 | let descriptor = Descriptor::<DescriptorPublicKey>::from_str(descriptor)?; 384 | Ok(descriptor 385 | .translate_pk1_infallible(|pk| { 386 | let mut pk = pk.clone(); 387 | if let DescriptorPublicKey::XPub(xpub) = &mut pk { 388 | xpub.xkey.network = network; 389 | } 390 | pk 391 | }) 392 | .to_string()) 393 | } 394 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use bdk::{ 2 | bitcoin::{util::bip32::Fingerprint, Network}, 3 | blockchain::{esplora::EsploraBlockchainConfig, AnyBlockchainConfig}, 4 | }; 5 | use std::path::PathBuf; 6 | 7 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 8 | #[serde(rename_all = "kebab-case")] 9 | pub enum WalletKeys { 10 | SeedWordsFile, 11 | } 12 | 13 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 14 | #[serde(rename_all = "kebab-case")] 15 | pub enum WalletKeyOld { 16 | #[serde(rename = "p2wpkh")] 17 | P2wpkh, 18 | } 19 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 20 | #[serde(rename_all = "kebab-case", tag = "kind")] 21 | pub enum GunSigner { 22 | SeedWordsFile { 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | passphrase_fingerprint: Option<Fingerprint>, 25 | }, 26 | PsbtDir { 27 | path: PathBuf, 28 | }, 29 | } 30 | 31 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 32 | #[serde(rename_all = "kebab-case")] 33 | pub enum DerivationBip { 34 | Bip84, 35 | } 36 | 37 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 38 | #[serde(rename_all = "kebab-case", tag = "version")] 39 | pub enum VersionedConfig { 40 | #[serde(rename = "1")] 41 | V1(Config), 42 | } 43 | 44 | impl From<VersionedConfig> for Config { 45 | fn from(from: VersionedConfig) -> Self { 46 | match from { 47 | VersionedConfig::V1(config) => config, 48 | } 49 | } 50 | } 51 | 52 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 53 | #[serde(rename_all = "kebab-case")] 54 | pub struct Config { 55 | pub network: Network, 56 | pub blockchain: AnyBlockchainConfig, 57 | pub signers: Vec<GunSigner>, 58 | } 59 | 60 | impl Config { 61 | pub fn default_config(network: Network) -> Config { 62 | use Network::*; 63 | let url = match network { 64 | Bitcoin => "https://mempool.space/api", 65 | Testnet => "https://mempool.space/testnet/api", 66 | Regtest => "http://localhost:3000", 67 | Signet => "https://mempool.space/signet/api", 68 | }; 69 | 70 | let blockchain = AnyBlockchainConfig::Esplora(EsploraBlockchainConfig { 71 | concurrency: Some(10), 72 | ..EsploraBlockchainConfig::new(url.into(), 10) 73 | }); 74 | 75 | Config { 76 | network, 77 | blockchain, 78 | signers: vec![], 79 | } 80 | } 81 | pub fn into_versioned(self) -> VersionedConfig { 82 | VersionedConfig::V1(self) 83 | } 84 | 85 | pub fn blockchain_config(&self) -> &EsploraBlockchainConfig { 86 | match &self.blockchain { 87 | AnyBlockchainConfig::Esplora(config) => config, 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/database.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::*, elog, keychain::ProtocolSecret, OracleInfo}; 2 | use anyhow::{anyhow, Context}; 3 | use bdk::{ 4 | bitcoin::OutPoint, 5 | sled::{ 6 | self, 7 | transaction::{ConflictableTransactionError, TransactionalTree}, 8 | }, 9 | KeychainKind, 10 | }; 11 | use olivia_core::OracleId; 12 | 13 | pub const DB_VERSION: u8 = 0; 14 | 15 | #[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] 16 | pub enum MapKey { 17 | BetId, 18 | OracleInfo(OracleId), 19 | Bet(BetId), 20 | ProtocolSecret(ProtocolKind), 21 | Descriptor(KeychainKind), 22 | } 23 | 24 | #[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] 25 | pub enum ProtocolKind { 26 | Bet, 27 | } 28 | 29 | #[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] 30 | pub struct VersionedKey { 31 | pub version: u8, 32 | pub key: MapKey, 33 | } 34 | 35 | impl VersionedKey { 36 | pub fn to_bytes(&self) -> Vec<u8> { 37 | crate::encode::serialize(self) 38 | } 39 | 40 | pub fn from_bytes(bytes: &[u8]) -> Self { 41 | crate::encode::deserialize::<VersionedKey>(bytes).unwrap() 42 | } 43 | } 44 | 45 | impl From<MapKey> for VersionedKey { 46 | fn from(key: MapKey) -> Self { 47 | VersionedKey { 48 | version: DB_VERSION, 49 | key, 50 | } 51 | } 52 | } 53 | 54 | #[derive(serde::Serialize)] 55 | pub enum KeyKind { 56 | BetId, 57 | OracleInfo, 58 | Bet, 59 | ProtocolSecret, 60 | Descriptor, 61 | } 62 | 63 | impl KeyKind { 64 | pub fn prefix(&self) -> Vec<u8> { 65 | crate::encode::serialize(&(DB_VERSION, self)) 66 | } 67 | } 68 | 69 | pub trait Entity: serde::de::DeserializeOwned + Clone + 'static + serde::Serialize { 70 | type Key: Clone; 71 | fn key_kind() -> KeyKind; 72 | fn deserialize_key(bytes: &[u8]) -> anyhow::Result<Self::Key>; 73 | fn extract_key(key: MapKey) -> Option<Self::Key>; 74 | fn to_map_key(key: Self::Key) -> MapKey; 75 | fn name() -> &'static str; 76 | } 77 | 78 | macro_rules! impl_entity { 79 | ($key_name:ty, $type:ty, $type_name:ident) => { 80 | impl Entity for $type { 81 | type Key = $key_name; 82 | fn deserialize_key(bytes: &[u8]) -> anyhow::Result<Self::Key> { 83 | let versioned_key = $crate::encode::deserialize::<VersionedKey>(bytes)?; 84 | if let MapKey::$type_name(inner) = versioned_key.key { 85 | Ok(inner) 86 | } else { 87 | Err(anyhow::anyhow!( 88 | "Could not deserialize key {}", 89 | stringify!($type_name) 90 | )) 91 | } 92 | } 93 | 94 | fn key_kind() -> KeyKind { 95 | KeyKind::$type_name 96 | } 97 | 98 | fn extract_key(key: MapKey) -> Option<Self::Key> { 99 | if let MapKey::$type_name(key) = key { 100 | Some(key) 101 | } else { 102 | None 103 | } 104 | } 105 | 106 | fn to_map_key(key: Self::Key) -> MapKey { 107 | MapKey::$type_name(key) 108 | } 109 | 110 | fn name() -> &'static str { 111 | stringify!($type_name) 112 | } 113 | } 114 | }; 115 | } 116 | 117 | impl_entity!(OracleId, OracleInfo, OracleInfo); 118 | impl_entity!(BetId, BetState, Bet); 119 | impl_entity!(ProtocolKind, ProtocolSecret, ProtocolSecret); 120 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 121 | pub struct StringDescriptor(pub String); 122 | impl_entity!(KeychainKind, StringDescriptor, Descriptor); 123 | 124 | pub struct GunDatabase(sled::Tree); 125 | 126 | fn insert<O: serde::Serialize>(tree: &sled::Tree, key: MapKey, value: O) -> anyhow::Result<()> { 127 | tree.insert( 128 | VersionedKey::from(key).to_bytes(), 129 | serde_json::to_string(&value).unwrap().into_bytes(), 130 | )?; 131 | Ok(()) 132 | } 133 | 134 | impl GunDatabase { 135 | pub fn new(tree: sled::Tree) -> Self { 136 | GunDatabase(tree) 137 | } 138 | 139 | pub fn insert_bet(&self, bet: BetState) -> anyhow::Result<BetId> { 140 | let i = self 141 | .0 142 | .update_and_fetch( 143 | VersionedKey::from(MapKey::BetId).to_bytes(), 144 | |prev| match prev { 145 | Some(prev) => Some( 146 | (u32::from_be_bytes(<[u8; 4]>::try_from(prev).unwrap()) + 1) 147 | .to_be_bytes() 148 | .to_vec(), 149 | ), 150 | None => Some(0u32.to_be_bytes().to_vec()), 151 | }, 152 | )? 153 | .unwrap(); 154 | let i = u32::from_be_bytes(<[u8; 4]>::try_from(i.to_vec()).unwrap()); 155 | 156 | insert(&self.0, MapKey::Bet(i), bet)?; 157 | 158 | Ok(i) 159 | } 160 | 161 | pub fn currently_used_utxos(&self, ignore: &[BetId]) -> anyhow::Result<Vec<OutPoint>> { 162 | Ok(self 163 | .list_entities::<BetState>() 164 | .collect::<Result<Vec<_>, _>>()? 165 | .into_iter() 166 | .filter(|(bet_id, _)| !ignore.contains(bet_id)) 167 | .flat_map(|(_, bet)| bet.reserved_utxos()) 168 | .collect()) 169 | } 170 | 171 | pub fn insert_entity<T: Entity>(&self, key: T::Key, entity: T) -> anyhow::Result<()> { 172 | insert(&self.0, T::to_map_key(key), entity) 173 | } 174 | 175 | pub fn get_entity<T: Entity>(&self, key: T::Key) -> anyhow::Result<Option<T>> { 176 | Ok(self 177 | .0 178 | .get(VersionedKey::from(T::to_map_key(key)).to_bytes())? 179 | .map(|bytes| serde_json::from_slice(&bytes)) 180 | .transpose()?) 181 | } 182 | 183 | pub fn remove_entity<T: Entity>(&self, key: T::Key) -> anyhow::Result<Option<T>> { 184 | Ok(self 185 | .0 186 | .remove(VersionedKey::from(T::to_map_key(key)).to_bytes())? 187 | .map(|bytes| serde_json::from_slice(&bytes)) 188 | .transpose()?) 189 | } 190 | 191 | pub fn update_bets<F>(&self, bet_ids: &[BetId], f: F) -> anyhow::Result<()> 192 | where 193 | F: Fn(BetState, BetId, TxDb) -> anyhow::Result<BetState>, 194 | { 195 | self.0 196 | .transaction(move |db| { 197 | for bet_id in bet_ids { 198 | let key = VersionedKey::from(MapKey::Bet(*bet_id)); 199 | let key = key.to_bytes(); 200 | let old_state = db.remove(key.clone())?.ok_or_else(|| { 201 | ConflictableTransactionError::Abort(anyhow!( 202 | "bet {} does not exist", 203 | bet_id 204 | )) 205 | })?; 206 | let old_state = serde_json::from_slice(&old_state[..]) 207 | .expect("it's in the DB so it should be deserializable"); 208 | let new_state = f(old_state, *bet_id, TxDb(db)) 209 | .map_err(ConflictableTransactionError::Abort)?; 210 | db.insert(key, serde_json::to_vec(&new_state).unwrap())?; 211 | } 212 | Ok(()) 213 | }) 214 | .map_err(|e| match e { 215 | bdk::sled::transaction::TransactionError::Abort(e) => e, 216 | bdk::sled::transaction::TransactionError::Storage(e) => e.into(), 217 | }) 218 | } 219 | 220 | pub fn list_entities<T: Entity>(&self) -> impl Iterator<Item = anyhow::Result<(T::Key, T)>> { 221 | self.0.scan_prefix(T::key_kind().prefix()).map(|item| { 222 | let (key, value) = item?; 223 | Ok(( 224 | T::deserialize_key(&key[..]) 225 | .with_context(|| format!("Error Deserializing key for {}", T::name()))?, 226 | serde_json::from_slice(&value[..]) 227 | .with_context(|| format!("Error Deserialzing {}", T::name()))?, 228 | )) 229 | }) 230 | } 231 | 232 | pub fn list_entities_print_error<T: Entity>(&self) -> impl Iterator<Item = (T::Key, T)> { 233 | self.list_entities().filter_map(|entity| match entity { 234 | Ok(entity) => Some(entity), 235 | Err(e) => { 236 | elog!(@recoverable_error "Error retreiving an {}: {}", T::name(), e); 237 | None 238 | } 239 | }) 240 | } 241 | 242 | pub fn test_new() -> Self { 243 | GunDatabase::new( 244 | bdk::sled::Config::new() 245 | .temporary(true) 246 | .flush_every_ms(None) 247 | .open() 248 | .unwrap() 249 | .open_tree("test-gun") 250 | .unwrap(), 251 | ) 252 | } 253 | 254 | pub fn safely_set_bet_protocol_secret(&self, new_secret: ProtocolSecret) -> anyhow::Result<()> { 255 | let in_use: Vec<_> = self 256 | .list_entities::<BetState>() 257 | .filter_map(|bet| bet.ok()) 258 | .filter(|(_, state)| state.relies_on_protocol_secret()) 259 | .collect(); 260 | if in_use.is_empty() { 261 | self.insert_entity(ProtocolKind::Bet, new_secret)?; 262 | Ok(()) 263 | } else { 264 | let in_use = in_use 265 | .into_iter() 266 | .map(|(bet_id, _)| bet_id.to_string()) 267 | .collect::<Vec<_>>() 268 | .join(", "); 269 | Err(anyhow!("Bets {} are using the protocol secret so you can't change it until they're resolved", in_use)) 270 | } 271 | } 272 | } 273 | 274 | #[derive(Clone, Copy)] 275 | pub struct TxDb<'a>(&'a TransactionalTree); 276 | 277 | impl<'a> TxDb<'a> { 278 | pub fn get_entity<T: Entity>(&self, key: T::Key) -> anyhow::Result<Option<T>> { 279 | Ok(self 280 | .0 281 | .get(VersionedKey::from(T::to_map_key(key)).to_bytes())? 282 | .map(|bytes| serde_json::from_slice(&bytes)) 283 | .transpose()?) 284 | } 285 | } 286 | 287 | #[cfg(test)] 288 | mod test { 289 | use super::*; 290 | #[test] 291 | fn insert_and_list_oracles() { 292 | let db = GunDatabase::test_new(); 293 | let info1 = OracleInfo::test_oracle_info(); 294 | let info2 = { 295 | let mut info2 = OracleInfo::test_oracle_info(); 296 | info2.id = "oracle2.test".into(); 297 | info2 298 | }; 299 | db.insert_entity(info1.id.clone(), info1.clone()).unwrap(); 300 | let oracle_list = db 301 | .list_entities::<OracleInfo>() 302 | .collect::<Result<Vec<_>, _>>() 303 | .unwrap(); 304 | assert_eq!(oracle_list, vec![(info1.id.clone(), info1.clone())]); 305 | db.insert_entity(info2.id.clone(), info2.clone()).unwrap(); 306 | let mut oracle_list = db 307 | .list_entities::<OracleInfo>() 308 | .collect::<Result<Vec<_>, _>>() 309 | .unwrap(); 310 | oracle_list.sort_by_key(|(id, _)| id.clone()); 311 | assert_eq!( 312 | oracle_list, 313 | vec![(info1.id.clone(), info1), (info2.id.clone(), info2)] 314 | ); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/ecdh.rs: -------------------------------------------------------------------------------- 1 | use crate::{keychain::KeyPair, rand_core::SeedableRng}; 2 | use chacha20::{cipher::*, ChaCha20, ChaCha20Rng}; 3 | use olivia_secp256k1::schnorr_fun::fun::{g, marker::*, Point}; 4 | use sha2::{ 5 | digest::{ 6 | generic_array::{sequence::Split, typenum::U32}, 7 | Digest, 8 | }, 9 | Sha512, 10 | }; 11 | 12 | pub fn ecdh(keypair: &KeyPair, remote: &Point<EvenY>) -> (ChaCha20, ChaCha20Rng) { 13 | let Y = remote; 14 | let x = &keypair.secret_key; 15 | let XY = g!(x * Y).mark::<Normal>(); 16 | let (cipher_key, rng_key) = 17 | Split::<u8, U32>::split(Sha512::default().chain(XY.to_xonly().as_bytes()).finalize()); 18 | let rng = ChaCha20Rng::from_seed(rng_key.into()); 19 | let cipher = ChaCha20::new(&cipher_key, &[0u8; 12].into()); 20 | 21 | (cipher, rng) 22 | } 23 | -------------------------------------------------------------------------------- /src/encode.rs: -------------------------------------------------------------------------------- 1 | use bincode::Options; 2 | 3 | pub fn serialize_base2048<S: serde::Serialize>(thing: &S) -> String { 4 | base2048::encode(&serialize(thing)) 5 | } 6 | 7 | pub fn serialize<S: serde::Serialize>(thing: &S) -> Vec<u8> { 8 | // this might fail if the thing has a #[serde(flatten)] in it. 9 | bincode::options() 10 | .allow_trailing_bytes() 11 | .with_varint_encoding() 12 | .serialize(thing) 13 | .unwrap() 14 | } 15 | 16 | #[derive(Debug, thiserror::Error)] 17 | pub enum DecodeError { 18 | #[error("invalid base2048 encoding")] 19 | Base2048, 20 | #[error("invalid data: {0}")] 21 | Bincode(bincode::Error), 22 | } 23 | 24 | pub fn deserialize_base2048<D: serde::de::DeserializeOwned>( 25 | string: &str, 26 | ) -> Result<D, DecodeError> { 27 | let decoded = base2048::decode(string).ok_or(DecodeError::Base2048)?; 28 | deserialize(&decoded[..]).map_err(DecodeError::Bincode) 29 | } 30 | 31 | pub fn deserialize<D: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<D, bincode::Error> { 32 | bincode::options() 33 | .allow_trailing_bytes() 34 | .with_varint_encoding() 35 | .deserialize(bytes) 36 | } 37 | 38 | #[cfg(test)] 39 | mod test { 40 | use super::*; 41 | #[test] 42 | fn make_sure_extra_bytes_not_added() { 43 | let bytes = vec![42u8; 11]; 44 | assert_eq!(serialize(&bytes).len(), 12); 45 | assert_eq!(serialize_base2048(&bytes).chars().count(), 9); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/fee_spec.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use anyhow::anyhow; 4 | use bdk::{ 5 | bitcoin::Amount, 6 | blockchain::Blockchain, 7 | database::BatchDatabase, 8 | wallet::{coin_selection::CoinSelectionAlgorithm, tx_builder::TxBuilderContext}, 9 | FeeRate, TxBuilder, 10 | }; 11 | 12 | #[derive(Debug, Clone, PartialEq)] 13 | ///Hello 14 | pub enum FeeSpec { 15 | Absolute(Amount), 16 | Rate(FeeRate), 17 | Height(u32), 18 | } 19 | 20 | impl Default for FeeSpec { 21 | fn default() -> Self { 22 | FeeSpec::Height(1) 23 | } 24 | } 25 | 26 | impl FeeSpec { 27 | pub fn apply_to_builder< 28 | B: Blockchain, 29 | D: BatchDatabase, 30 | Cs: CoinSelectionAlgorithm<D>, 31 | Ctx: TxBuilderContext, 32 | >( 33 | &self, 34 | blockchain: &B, 35 | builder: &mut TxBuilder<'_, B, D, Cs, Ctx>, 36 | ) -> anyhow::Result<()> { 37 | use FeeSpec::*; 38 | match self { 39 | Absolute(fee) => { 40 | builder.fee_absolute(fee.as_sat()); 41 | } 42 | Rate(rate) => { 43 | builder.fee_rate(*rate); 44 | } 45 | Height(height) => { 46 | let feerate = blockchain.estimate_fee(*height as usize)?; 47 | builder.fee_rate(feerate); 48 | } 49 | } 50 | Ok(()) 51 | } 52 | } 53 | 54 | impl FromStr for FeeSpec { 55 | type Err = anyhow::Error; 56 | 57 | fn from_str(string: &str) -> anyhow::Result<Self> { 58 | use crate::amount_ext::FromCliStr; 59 | 60 | if let Some(rate) = string.strip_prefix("rate:") { 61 | let rate = f32::from_str(rate)?; 62 | return Ok(FeeSpec::Rate(FeeRate::from_sat_per_vb(rate))); 63 | } 64 | 65 | if let Some(amount) = string.strip_prefix("abs:") { 66 | return Ok(match u64::from_str(amount).ok() { 67 | Some(int_amount) => FeeSpec::Absolute(Amount::from_sat(int_amount)), 68 | None => FeeSpec::Absolute(Amount::from_cli_str(amount)?), 69 | }); 70 | } 71 | 72 | if let Some(in_blocks) = string.strip_prefix("in-blocks:") { 73 | let in_blocks = u32::from_str(in_blocks)?; 74 | return Ok(FeeSpec::Height(in_blocks)); 75 | } 76 | 77 | Err(anyhow!("'{}' is not a valid fee specification", string)) 78 | } 79 | } 80 | 81 | impl core::fmt::Display for FeeSpec { 82 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 83 | match self { 84 | FeeSpec::Rate(rate) => write!(f, "rate:{}", rate.as_sat_vb()), 85 | FeeSpec::Absolute(abs) => write!(f, "abs:{}", abs), 86 | FeeSpec::Height(height) => write!(f, "in-blocks:{}", height), 87 | } 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod test { 93 | use super::*; 94 | 95 | #[test] 96 | fn parse_feespec() { 97 | assert_eq!( 98 | FeeSpec::from_str("abs:300sat").unwrap(), 99 | FeeSpec::Absolute(Amount::from_sat(300)) 100 | ); 101 | assert_eq!( 102 | FeeSpec::from_str("abs:300").unwrap(), 103 | FeeSpec::Absolute(Amount::from_sat(300)) 104 | ); 105 | assert_eq!( 106 | FeeSpec::from_str("rate:3.5").unwrap(), 107 | FeeSpec::Rate(FeeRate::from_sat_per_vb(3.5)) 108 | ); 109 | assert_eq!( 110 | FeeSpec::from_str("in-blocks:5").unwrap(), 111 | FeeSpec::Height(5) 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/keychain.rs: -------------------------------------------------------------------------------- 1 | use crate::{betting::Proposal, hex}; 2 | use bdk::bitcoin::hashes::{sha512, Hash, HashEngine, Hmac, HmacEngine}; 3 | use olivia_secp256k1::schnorr_fun::fun::{marker::*, Point, Scalar, G}; 4 | 5 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] 6 | pub enum ProtocolSecret { 7 | Bytes(#[serde(with = "crate::serde_hacks::BigArray")] [u8; 64]), 8 | } 9 | 10 | impl core::str::FromStr for ProtocolSecret { 11 | type Err = olivia_secp256k1::hex::HexError; 12 | 13 | fn from_str(string: &str) -> Result<Self, Self::Err> { 14 | Ok(ProtocolSecret::Bytes(olivia_secp256k1::hex::decode_array( 15 | string, 16 | )?)) 17 | } 18 | } 19 | 20 | impl core::fmt::Display for ProtocolSecret { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | match self { 23 | ProtocolSecret::Bytes(bytes) => write!(f, "{}", hex::encode(&bytes[..])), 24 | } 25 | } 26 | } 27 | 28 | impl From<ProtocolSecret> for Keychain { 29 | fn from(protocol_secret: ProtocolSecret) -> Self { 30 | match protocol_secret { 31 | ProtocolSecret::Bytes(bytes) => Keychain::new(bytes), 32 | } 33 | } 34 | } 35 | 36 | pub struct Keychain { 37 | proposal_hmac: HmacEngine<sha512::Hash>, 38 | offer_hmac: HmacEngine<sha512::Hash>, 39 | } 40 | 41 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] 42 | pub struct KeyPair { 43 | pub public_key: Point<EvenY>, 44 | pub secret_key: Scalar, 45 | } 46 | 47 | impl KeyPair { 48 | pub fn from_slice(bytes: &[u8]) -> Option<Self> { 49 | let mut secret_key = Scalar::from_slice_mod_order(&bytes[..32]) 50 | .expect("is 32 bytes long") 51 | .mark::<NonZero>()?; 52 | let public_key = Point::<EvenY>::from_scalar_mul(G, &mut secret_key); 53 | Some(KeyPair { 54 | public_key, 55 | secret_key, 56 | }) 57 | } 58 | } 59 | 60 | impl Keychain { 61 | pub fn new(seed: [u8; 64]) -> Self { 62 | let proposal_hmac = { 63 | let mut hmac = HmacEngine::<sha512::Hash>::new(b"gun-proposal"); 64 | hmac.input(&seed[..]); 65 | let res = Hmac::from_engine(hmac); 66 | HmacEngine::<sha512::Hash>::new(&res[..]) 67 | }; 68 | 69 | let offer_hmac = { 70 | let mut hmac = HmacEngine::<sha512::Hash>::new(b"gun-offer"); 71 | hmac.input(&seed[..]); 72 | let res = Hmac::from_engine(hmac); 73 | HmacEngine::<sha512::Hash>::new(&res[..]) 74 | }; 75 | 76 | Self { 77 | proposal_hmac, 78 | offer_hmac, 79 | } 80 | } 81 | 82 | /// TODO: use the versioned proposal here 83 | /// DONOTMERGE LIKE THIS 84 | pub fn get_key_for_proposal(&self, proposal: &Proposal) -> KeyPair { 85 | let mut proposal = proposal.clone(); 86 | proposal.public_key = crate::placeholder_point(); 87 | let mut proposal_hmac = self.proposal_hmac.clone(); 88 | let bin = crate::encode::serialize(&proposal); 89 | proposal_hmac.input(&bin[..]); 90 | let res = Hmac::from_engine(proposal_hmac); 91 | let keypair = KeyPair::from_slice(&res[..]).expect("computationally unreachable"); 92 | proposal.public_key = keypair.public_key; 93 | keypair 94 | } 95 | 96 | pub fn keypair_for_offer(&self, proposal: &Proposal) -> KeyPair { 97 | let mut offer_hmac = self.offer_hmac.clone(); 98 | let bin = crate::encode::serialize(proposal); 99 | offer_hmac.input(&bin[..]); 100 | let res = Hmac::from_engine(offer_hmac); 101 | KeyPair::from_slice(&res[..]).expect("computationally unreachable") 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case, clippy::or_fun_call, clippy::vec_init_then_push)] 2 | 3 | use std::str::FromStr; 4 | 5 | use bdk::bitcoin::Amount; 6 | pub mod amount_ext; 7 | pub mod betting; 8 | mod change; 9 | pub mod cmd; 10 | pub mod config; 11 | pub mod ecdh; 12 | pub mod encode; 13 | mod fee_spec; 14 | pub mod keychain; 15 | pub mod psbt_ext; 16 | pub mod signers; 17 | pub use fee_spec::*; 18 | pub mod bip85; 19 | pub mod database; 20 | mod serde_hacks; 21 | pub mod wallet; 22 | 23 | pub use chacha20::cipher; 24 | pub use olivia_core::chrono; 25 | pub use olivia_secp256k1::schnorr_fun::fun::{hex, rand_core}; 26 | pub use url::Url; 27 | 28 | pub type OracleInfo = olivia_core::OracleInfo<olivia_secp256k1::Secp256k1>; 29 | 30 | #[derive(Clone, Debug)] 31 | pub enum ValueChoice { 32 | All, 33 | Amount(Amount), 34 | } 35 | 36 | impl FromStr for ValueChoice { 37 | type Err = anyhow::Error; 38 | 39 | fn from_str(string: &str) -> anyhow::Result<Self> { 40 | Ok(match string { 41 | "all" => ValueChoice::All, 42 | amount => { 43 | ValueChoice::Amount(<Amount as amount_ext::FromCliStr>::from_cli_str(amount)?) 44 | } 45 | }) 46 | } 47 | } 48 | 49 | /// So we can use data structs to derive a key to be placed into them afterwards 50 | pub(crate) fn placeholder_point( 51 | ) -> olivia_secp256k1::fun::Point<olivia_secp256k1::fun::marker::EvenY> { 52 | use olivia_secp256k1::fun::marker::*; 53 | (*olivia_secp256k1::fun::G) 54 | .mark::<Normal>() 55 | .into_point_with_even_y() 56 | .0 57 | } 58 | 59 | pub fn format_dt_diff_till_now(dt: chrono::NaiveDateTime) -> String { 60 | let now = chrono::Utc::now().naive_utc(); 61 | let diff = dt - now; 62 | if diff.abs() < chrono::Duration::hours(1) { 63 | format!("{}m", diff.num_minutes()) 64 | } else if diff.abs() < chrono::Duration::days(1) { 65 | format!("{}h", diff.num_hours()) 66 | } else { 67 | format!("{}d", diff.num_days()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/psbt_ext.rs: -------------------------------------------------------------------------------- 1 | use bdk::{ 2 | bitcoin::{util::psbt::PartiallySignedTransaction as Psbt, Amount}, 3 | FeeRate, 4 | }; 5 | 6 | pub trait PsbtFeeRate { 7 | fn fee(&self) -> (Amount, FeeRate, bool); 8 | } 9 | 10 | impl PsbtFeeRate for Psbt { 11 | fn fee(&self) -> (Amount, FeeRate, bool) { 12 | let mut psbt = self.clone(); 13 | let input_value: u64 = self 14 | .inputs 15 | .iter() 16 | .map(|x| x.witness_utxo.as_ref().map(|x| x.value).unwrap_or(0)) 17 | .sum(); 18 | 19 | let mut feerate_estimated = false; 20 | for input in &mut psbt.inputs { 21 | if input.final_script_witness.is_none() { 22 | // FIXME: (Does not work for other script types, taproot) 23 | input.final_script_witness = Some(vec![vec![0u8; 73], vec![0u8; 33]]); 24 | feerate_estimated = true; 25 | }; 26 | } 27 | 28 | let output_value: u64 = psbt.unsigned_tx.output.iter().map(|x| x.value).sum(); 29 | let fee = input_value - output_value; 30 | let feerate = 31 | FeeRate::from_sat_per_vb(fee as f32 / (psbt.extract_tx().get_weight() as f32 / 4.0)); 32 | (Amount::from_sat(fee), feerate, feerate_estimated) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/serde_hacks.rs: -------------------------------------------------------------------------------- 1 | use serde::{ 2 | de::{Deserialize, Deserializer, Error, SeqAccess, Visitor}, 3 | ser::{Serialize, SerializeTuple, Serializer}, 4 | }; 5 | use std::{fmt, marker::PhantomData}; 6 | 7 | pub(crate) trait BigArray<'de>: Sized { 8 | fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 9 | where 10 | S: Serializer; 11 | fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 12 | where 13 | D: Deserializer<'de>; 14 | } 15 | 16 | macro_rules! big_array { 17 | ($($len:expr,)+) => { 18 | $( 19 | impl<'de, T> BigArray<'de> for [T; $len] 20 | where T: Default + Copy + Serialize + Deserialize<'de> 21 | { 22 | fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 23 | where S: Serializer 24 | { 25 | let mut seq = serializer.serialize_tuple(self.len())?; 26 | for elem in &self[..] { 27 | seq.serialize_element(elem)?; 28 | } 29 | seq.end() 30 | } 31 | 32 | fn deserialize<D>(deserializer: D) -> Result<[T; $len], D::Error> 33 | where D: Deserializer<'de> 34 | { 35 | struct ArrayVisitor<T> { 36 | element: PhantomData<T>, 37 | } 38 | 39 | impl<'de, T> Visitor<'de> for ArrayVisitor<T> 40 | where T: Default + Copy + Deserialize<'de> 41 | { 42 | type Value = [T; $len]; 43 | 44 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 45 | formatter.write_str(concat!("an array of length ", $len)) 46 | } 47 | 48 | fn visit_seq<A>(self, mut seq: A) -> Result<[T; $len], A::Error> 49 | where A: SeqAccess<'de> 50 | { 51 | let mut arr = [T::default(); $len]; 52 | for i in 0..$len { 53 | arr[i] = seq.next_element()? 54 | .ok_or_else(|| Error::invalid_length(i, &self))?; 55 | } 56 | Ok(arr) 57 | } 58 | } 59 | 60 | let visitor = ArrayVisitor { element: PhantomData }; 61 | deserializer.deserialize_tuple($len, visitor) 62 | } 63 | } 64 | )+ 65 | } 66 | } 67 | 68 | big_array! { 64, } 69 | -------------------------------------------------------------------------------- /src/signers.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, str::FromStr}; 2 | 3 | use bdk::{ 4 | bitcoin::{ 5 | secp256k1::{self, All, Secp256k1}, 6 | util::{ 7 | bip32::{ExtendedPrivKey, Fingerprint}, 8 | psbt::PartiallySignedTransaction, 9 | }, 10 | Network, 11 | }, 12 | keys::{bip39::Mnemonic, DerivableKey, ExtendedKey}, 13 | wallet::signer::{Signer, SignerError, SignerId}, 14 | }; 15 | use miniscript::bitcoin::{PrivateKey, PublicKey}; 16 | 17 | use crate::{ 18 | cmd::{display_psbt, read_yn}, 19 | elog, 20 | }; 21 | 22 | #[derive(Debug)] 23 | pub struct XKeySigner { 24 | /// The extended key 25 | pub master_xkey: ExtendedPrivKey, 26 | } 27 | 28 | impl Signer for XKeySigner { 29 | fn sign( 30 | &self, 31 | psbt: &mut PartiallySignedTransaction, 32 | input_index: Option<usize>, 33 | secp: &Secp256k1<All>, 34 | ) -> Result<(), SignerError> { 35 | let signer_fingerprint = self.master_xkey.fingerprint(secp); 36 | let input_index = input_index.unwrap(); 37 | if input_index >= psbt.inputs.len() { 38 | return Err(SignerError::InputIndexOutOfRange); 39 | } 40 | 41 | if psbt.inputs[input_index].final_script_sig.is_some() 42 | || psbt.inputs[input_index].final_script_witness.is_some() 43 | { 44 | return Ok(()); 45 | } 46 | 47 | let child_matches = psbt.inputs[input_index] 48 | .bip32_derivation 49 | .iter() 50 | .find(|(_, &(fingerprint, _))| fingerprint == signer_fingerprint); 51 | 52 | let (public_key, full_path) = match child_matches { 53 | Some((pk, (_, full_path))) => (pk, full_path.clone()), 54 | None => return Ok(()), 55 | }; 56 | 57 | let derived_key = self.master_xkey.derive_priv(secp, &full_path).unwrap(); 58 | 59 | if &PublicKey::new(secp256k1::PublicKey::from_secret_key( 60 | secp, 61 | &derived_key.private_key, 62 | )) != public_key 63 | { 64 | Err(SignerError::InvalidKey) 65 | } else { 66 | PrivateKey::new(derived_key.private_key, Network::Bitcoin).sign( 67 | psbt, 68 | Some(input_index), 69 | secp, 70 | ) 71 | } 72 | } 73 | 74 | fn sign_whole_tx(&self) -> bool { 75 | false 76 | } 77 | 78 | fn id(&self, secp: &Secp256k1<All>) -> SignerId { 79 | SignerId::from(self.master_xkey.fingerprint(secp)) 80 | } 81 | } 82 | 83 | #[derive(Debug)] 84 | pub struct PwSeedSigner { 85 | /// Seed Mnemonic (without passphrase) 86 | pub mnemonic: Mnemonic, 87 | /// Bitcoin network 88 | pub network: Network, 89 | /// The expected external wallet descriptor 90 | pub master_fingerprint: Fingerprint, 91 | } 92 | 93 | pub const PSBT_SIGNER_ID: u64 = 3735928559; 94 | 95 | impl Signer for PwSeedSigner { 96 | fn sign( 97 | &self, 98 | psbt: &mut PartiallySignedTransaction, 99 | _input_index: Option<usize>, 100 | secp: &Secp256k1<All>, 101 | ) -> Result<(), SignerError> { 102 | if !read_yn(&format!( 103 | "This is the transaction you're about to sign.\n{}Ok", 104 | display_psbt(self.network, psbt) 105 | )) { 106 | return Err(SignerError::UserCanceled); 107 | } 108 | 109 | let master_xkey = loop { 110 | let p = rpassword::prompt_password_stderr("Enter your wallet passphrase: "); 111 | let passphrase = match p { 112 | Ok(passphrase) => passphrase, 113 | Err(e) => { 114 | elog!(@recoverable_error "Failed to read in password: {}", e); 115 | return Err(SignerError::InvalidKey); 116 | } 117 | }; 118 | let full_seed = self.mnemonic.to_seed(passphrase); 119 | let xkey: ExtendedKey = full_seed.into_extended_key().unwrap(); 120 | let master_xkey = xkey.into_xprv(self.network).unwrap(); 121 | 122 | if master_xkey.fingerprint(secp) != self.master_fingerprint { 123 | elog!(@recoverable_error "Invalid passphrase, derived fingerprint does not match. Try again."); 124 | } else { 125 | break master_xkey; 126 | } 127 | }; 128 | 129 | let signer = XKeySigner { master_xkey }; 130 | for i in 0..psbt.inputs.len() { 131 | signer.sign(psbt, Some(i), secp)?; 132 | } 133 | Ok(()) 134 | } 135 | 136 | fn sign_whole_tx(&self) -> bool { 137 | // we want to "sign whole tx" because we only want to be called once. 138 | true 139 | } 140 | 141 | fn id(&self, _secp: &Secp256k1<All>) -> SignerId { 142 | SignerId::from(self.master_fingerprint) 143 | } 144 | } 145 | 146 | #[derive(Debug)] 147 | pub struct PsbtDirSigner { 148 | path: PathBuf, 149 | network: Network, 150 | } 151 | 152 | impl PsbtDirSigner { 153 | pub fn create(psbt_signer_dir: PathBuf, network: Network) -> Self { 154 | PsbtDirSigner { 155 | path: psbt_signer_dir, 156 | network, 157 | } 158 | } 159 | } 160 | 161 | impl Signer for PsbtDirSigner { 162 | fn sign( 163 | &self, 164 | psbt: &mut PartiallySignedTransaction, 165 | _input_index: Option<usize>, 166 | _secp: &Secp256k1<All>, 167 | ) -> Result<(), SignerError> { 168 | if !read_yn(&format!( 169 | "This is the transaction that will be saved for signing.\n{}Ok", 170 | display_psbt(self.network, psbt) 171 | )) { 172 | return Err(SignerError::UserCanceled); 173 | } 174 | 175 | let txid = psbt.clone().extract_tx().txid(); 176 | let psbt_file = self.path.as_path().join(format!("{}.psbt", txid)); 177 | loop { 178 | if !self.path.exists() { 179 | elog!( 180 | @suggestion 181 | "PSBT directory '{}' does not exist (maybe you need to insert your SD card?).\nPress enter to try again.", 182 | self.path.display() 183 | ); 184 | let _ = std::io::stdin().read_line(&mut String::new()); 185 | } else if let Err(e) = std::fs::write(&psbt_file, psbt.to_string()) { 186 | elog!( 187 | @recoverable_error 188 | "Was unable to write PSBT {}: {}\nPress enter to try again.", 189 | psbt_file.display(), 190 | e 191 | ); 192 | let _ = std::io::stdin().read_line(&mut String::new()); 193 | } else { 194 | break; 195 | } 196 | } 197 | 198 | elog!(@celebration "Wrote PSBT to {}", psbt_file.display()); 199 | 200 | let file_locations = [ 201 | self.path.as_path().join(format!("{}-signed.psbt", txid)), 202 | self.path.as_path().join(format!("{}-part.psbt", txid)), 203 | ]; 204 | elog!(@info "gun will look for the signed psbt files at:",); 205 | for location in &file_locations { 206 | elog!(@info "{}", location.display()); 207 | } 208 | elog!(@suggestion "Press enter once signed."); 209 | let (signed_psbt_path, contents) = loop { 210 | let _ = std::io::stdin().read_line(&mut String::new()); 211 | let mut file_contents = file_locations 212 | .iter() 213 | .map(|location| (location.clone(), std::fs::read_to_string(&location))) 214 | .collect::<Vec<_>>(); 215 | match file_contents 216 | .iter() 217 | .find(|(_, file_content)| file_content.is_ok()) 218 | { 219 | Some((signed_psbt_path, contents)) => { 220 | break (signed_psbt_path.clone(), contents.as_ref().unwrap().clone()) 221 | } 222 | None => { 223 | elog!( 224 | @recoverable_error 225 | "Couldn't read any of the files: {}\n", 226 | file_contents.remove(0).1.unwrap_err() 227 | ); 228 | } 229 | } 230 | }; 231 | let psbt_result = PartiallySignedTransaction::from_str(contents.trim()); 232 | 233 | match psbt_result { 234 | Err(e) => { 235 | elog!(@recoverable_error "Failed to parse PSBT file {}", signed_psbt_path.display()); 236 | elog!(@recoverable_error "{}", e); 237 | Err(SignerError::UserCanceled) 238 | } 239 | Ok(read_psbt) => { 240 | let _ = std::fs::remove_file(psbt_file); 241 | let _ = std::fs::remove_file(signed_psbt_path); 242 | *psbt = read_psbt; 243 | Ok(()) 244 | } 245 | } 246 | } 247 | 248 | fn id(&self, _secp: &Secp256k1<All>) -> SignerId { 249 | // Fingerprint/PubKey is not used in anything important that we need just yet 250 | SignerId::Dummy(PSBT_SIGNER_ID) 251 | } 252 | 253 | fn sign_whole_tx(&self) -> bool { 254 | true 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/wallet.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | betting::*, database::GunDatabase, elog, signers::PSBT_SIGNER_ID, FeeSpec, OracleInfo, 3 | }; 4 | use anyhow::{anyhow, Context}; 5 | use bdk::{ 6 | bitcoin::{ 7 | util::psbt::{self, PartiallySignedTransaction as Psbt}, 8 | OutPoint, 9 | }, 10 | blockchain::{noop_progress, Blockchain, EsploraBlockchain}, 11 | database::Database, 12 | descriptor::policy::Satisfaction, 13 | signer::SignerId, 14 | sled, 15 | wallet::AddressIndex, 16 | KeychainKind, SignOptions, 17 | }; 18 | use miniscript::DescriptorTrait; 19 | use olivia_core::{Attestation, Outcome}; 20 | use olivia_secp256k1::{ 21 | fun::{g, marker::*, s, Scalar, G}, 22 | Secp256k1, 23 | }; 24 | 25 | type BdkWallet = bdk::Wallet<EsploraBlockchain, sled::Tree>; 26 | 27 | pub struct GunWallet { 28 | wallet: BdkWallet, 29 | client: ureq::Agent, 30 | db: GunDatabase, 31 | } 32 | 33 | impl GunWallet { 34 | pub fn new(wallet: BdkWallet, db: GunDatabase) -> Self { 35 | Self { 36 | wallet, 37 | db, 38 | client: ureq::Agent::new(), 39 | } 40 | } 41 | 42 | pub fn bdk_wallet(&self) -> &BdkWallet { 43 | &self.wallet 44 | } 45 | 46 | pub fn gun_db(&self) -> &GunDatabase { 47 | &self.db 48 | } 49 | 50 | pub fn http_client(&self) -> &ureq::Agent { 51 | &self.client 52 | } 53 | 54 | pub fn learn_outcome( 55 | &self, 56 | bet_id: BetId, 57 | attestation: Attestation<Secp256k1>, 58 | ) -> anyhow::Result<()> { 59 | self.db 60 | .update_bets(&[bet_id], move |old_state, _, txdb| match old_state { 61 | BetState::Included { bet, .. } => { 62 | let event_id = bet.oracle_event.event.id.clone(); 63 | let outcome = Outcome::try_from_id_and_outcome(event_id, &attestation.outcome) 64 | .context("parsing oracle outcome")?; 65 | let olivia_v1_scalars = &attestation 66 | .schemes 67 | .olivia_v1 68 | .as_ref() 69 | .ok_or(anyhow!("attestation is missing olivia-v1"))? 70 | .scalars; 71 | let attest_scalar = Scalar::from(olivia_v1_scalars[0].clone()); 72 | if let Some(oracle_info) = 73 | txdb.get_entity::<OracleInfo>(bet.oracle_id.clone())? 74 | { 75 | attestation 76 | .verify_olivia_v1_attestation( 77 | &bet.oracle_event, 78 | &oracle_info.oracle_keys, 79 | ) 80 | .context("Oracle gave invalid attestation")?; 81 | } 82 | 83 | let joint_output = &bet.joint_output; 84 | match (outcome.value, bet.i_chose_right) { 85 | (0, false) | (1, true) => { 86 | let my_key = match &joint_output.my_key { 87 | Either::Left(my_key) => my_key, 88 | Either::Right(my_key) => my_key, 89 | }; 90 | let secret_key = s!(attest_scalar + my_key); 91 | assert_eq!( 92 | &g!(secret_key * G), 93 | joint_output.my_point(), 94 | "redundant check to make sure we have right key" 95 | ); 96 | 97 | let secret_key = secret_key 98 | .mark::<NonZero>() 99 | .expect("it matches the output key") 100 | .into(); 101 | 102 | Ok(BetState::Won { 103 | bet, 104 | secret_key, 105 | attestation: attestation.clone(), 106 | }) 107 | } 108 | _ => Ok(BetState::Lost { 109 | bet, 110 | attestation: attestation.clone(), 111 | }), 112 | } 113 | } 114 | old_state => Ok(old_state), 115 | })?; 116 | Ok(()) 117 | } 118 | 119 | pub fn generate_cancel_tx( 120 | &self, 121 | bet_ids: &[BetId], 122 | feespec: FeeSpec, 123 | ) -> anyhow::Result<Option<Psbt>> { 124 | let mut utxos_that_need_canceling: Vec<OutPoint> = vec![]; 125 | 126 | for bet_id in bet_ids { 127 | let bet_state = self.gun_db().get_entity(*bet_id)?.ok_or(anyhow!( 128 | "can't cancel bet {} because it doesn't exist", 129 | bet_id 130 | ))?; 131 | match bet_state { 132 | BetState::Proposed { local_proposal } => { 133 | let inputs = &local_proposal.proposal.inputs; 134 | if !inputs 135 | .iter() 136 | .any(|input| utxos_that_need_canceling.contains(input)) 137 | { 138 | utxos_that_need_canceling.push(inputs[0]); 139 | } 140 | } 141 | BetState::Canceled { 142 | height: None, 143 | pre_cancel: BetOrProp::Bet(bet), 144 | .. 145 | } 146 | | BetState::Canceled { 147 | height: None, 148 | pre_cancel: 149 | BetOrProp::OfferedBet { 150 | bet: OfferedBet(bet), 151 | .. 152 | }, 153 | .. 154 | } 155 | | BetState::Offered { 156 | bet: OfferedBet(bet), 157 | .. 158 | } 159 | | BetState::Included { 160 | bet, height: None, .. 161 | } => { 162 | let tx = bet.tx(); 163 | let inputs = bet 164 | .my_input_indexes 165 | .iter() 166 | .map(|i| tx.input[*i as usize].previous_output) 167 | .collect::<Vec<_>>(); 168 | if !inputs 169 | .iter() 170 | .any(|input| utxos_that_need_canceling.contains(input)) 171 | { 172 | utxos_that_need_canceling.push(inputs[0]); 173 | } 174 | } 175 | _ => { 176 | return Err(anyhow!( 177 | "Cannot cancel bet {} in state {}", 178 | bet_id, 179 | bet_state.name() 180 | )) 181 | } 182 | } 183 | } 184 | 185 | let mut builder = self.bdk_wallet().build_tx(); 186 | builder 187 | .manually_selected_only() 188 | .enable_rbf() 189 | .only_witness_utxo(); 190 | feespec.apply_to_builder(self.wallet.client(), &mut builder)?; 191 | 192 | for utxo in utxos_that_need_canceling { 193 | // we have to add these as foreign UTXOs because BDK doesn't let you spend 194 | // outputs that have been spent by tx in the mempool. 195 | let tx = match self.bdk_wallet().database().get_tx(&utxo.txid, true)? { 196 | Some(tx) => tx, 197 | None => { 198 | debug_assert!(false, "we should always be able to find our tx"); 199 | continue; 200 | } 201 | }; 202 | let psbt_input = psbt::Input { 203 | witness_utxo: Some( 204 | tx.transaction.as_ref().unwrap().output[utxo.vout as usize].clone(), 205 | ), 206 | ..Default::default() 207 | }; 208 | let satisfaction_weight = self 209 | .wallet 210 | .get_descriptor_for_keychain(KeychainKind::External) 211 | .max_satisfaction_weight()?; 212 | builder.add_foreign_utxo(utxo, psbt_input, satisfaction_weight)?; 213 | } 214 | 215 | builder.drain_to( 216 | self.wallet 217 | .get_change_address(AddressIndex::New)? 218 | .script_pubkey(), 219 | ); 220 | let (mut psbt, _) = match builder.finish() { 221 | Err(bdk::Error::NoUtxosSelected) => return Ok(None), 222 | Ok(res) => res, 223 | e => e?, 224 | }; 225 | let finalized = self.wallet.sign( 226 | &mut psbt, 227 | SignOptions { 228 | trust_witness_utxo: true, 229 | ..Default::default() 230 | }, 231 | )?; 232 | assert!(finalized, "we should have signed all inputs"); 233 | Ok(Some(psbt)) 234 | } 235 | 236 | // the reason we require p2wpkh inputs here is so the witness is non-malleable. 237 | // What to do about TR outputs in the future I haven't decided. 238 | pub fn p2wpkh_outpoint_to_psbt_input(&self, outpoint: OutPoint) -> anyhow::Result<psbt::Input> { 239 | let tx = self 240 | .wallet 241 | .client() 242 | .get_tx(&outpoint.txid)? 243 | .ok_or(anyhow!("txid not found {}", outpoint.txid))?; 244 | 245 | let txout = tx 246 | .output 247 | .get(outpoint.vout as usize) 248 | .ok_or(anyhow!( 249 | "vout {} doesn't exist on txid {}", 250 | outpoint.vout, 251 | outpoint.txid 252 | ))? 253 | .clone(); 254 | 255 | if !txout.script_pubkey.is_v0_p2wpkh() { 256 | return Err(anyhow!("outpoint {} was not p2wpkh", outpoint)); 257 | } 258 | 259 | let psbt_input = psbt::Input { 260 | witness_utxo: Some(txout), 261 | non_witness_utxo: Some(tx), 262 | ..Default::default() 263 | }; 264 | Ok(psbt_input) 265 | } 266 | 267 | // convenience methods 268 | pub fn sync(&self) -> anyhow::Result<()> { 269 | self.wallet.sync(noop_progress(), None)?; 270 | Ok(()) 271 | } 272 | 273 | pub fn poke_bets(&self) { 274 | for (bet_id, _) in self.gun_db().list_entities_print_error::<BetState>() { 275 | match self.take_next_action(bet_id, true) { 276 | Ok(_updated) => {} 277 | Err(e) => { 278 | elog!(@recoverable_error "Error trying to take action on bet {}: {:?}", bet_id, e); 279 | } 280 | } 281 | } 282 | } 283 | 284 | pub fn is_watch_only(&self) -> bool { 285 | let (external, _) = self.bdk_wallet().signers(); 286 | 287 | // PSBT signers are meant to sign everything so if we've got one of them we can sign anything. 288 | if external.ids().contains(&&SignerId::Dummy(PSBT_SIGNER_ID)) { 289 | return false; 290 | } 291 | 292 | let policy = self 293 | .bdk_wallet() 294 | .policies(KeychainKind::External) 295 | .expect("extracting policy should not have error") 296 | .expect("policy for external wallet exists"); 297 | 298 | !matches!( 299 | policy.contribution, 300 | Satisfaction::Complete { .. } | Satisfaction::PartialComplete { .. } 301 | ) 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /tests/end_to_end.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use bdk::{ 3 | bitcoin::{util::bip32::ExtendedPrivKey, Amount, Network}, 4 | blockchain::{noop_progress, Broadcast, EsploraBlockchain}, 5 | testutils::blockchain_tests::TestClient, 6 | wallet::AddressIndex, 7 | FeeRate, Wallet, 8 | }; 9 | use gun_wallet::{ 10 | betting::*, database::GunDatabase, keychain::Keychain, wallet::GunWallet, FeeSpec, ValueChoice, 11 | }; 12 | use olivia_core::{ 13 | announce, attest, AnnouncementSchemes, Attestation, AttestationSchemes, Event, EventId, Group, 14 | OracleEvent, OracleInfo, OracleKeys, 15 | }; 16 | use olivia_secp256k1::{fun::Scalar, Secp256k1}; 17 | use rand::Rng; 18 | use std::{str::FromStr, time::Duration}; 19 | 20 | fn create_party(test_client: &mut TestClient, id: u8) -> anyhow::Result<(GunWallet, Keychain)> { 21 | let mut r = [0u8; 64]; 22 | rand::thread_rng().fill(&mut r); 23 | let keychain = Keychain::new(r); 24 | let xprv = ExtendedPrivKey::new_master(Network::Regtest, &r).unwrap(); 25 | let descriptor = bdk::template::Bip84(xprv, bdk::KeychainKind::External); 26 | let db = bdk::sled::Config::new() 27 | .temporary(true) 28 | .flush_every_ms(None) 29 | .open() 30 | .unwrap() 31 | .open_tree("test") 32 | .unwrap(); 33 | let esplora_url = format!( 34 | "http://{}", 35 | test_client.electrsd.esplora_url.as_ref().unwrap() 36 | ); 37 | let esplora = EsploraBlockchain::new(&esplora_url, 5); 38 | 39 | let wallet = Wallet::new(descriptor, None, Network::Regtest, db, esplora) 40 | .context("Initializing wallet failed")?; 41 | wallet 42 | .sync(noop_progress(), None) 43 | .context("syncing wallet failed")?; 44 | 45 | let gun_db = GunDatabase::test_new(); 46 | 47 | let funding_address = wallet.get_address(AddressIndex::New).unwrap().address; 48 | 49 | test_client.generate(1, Some(funding_address)); 50 | test_client.generate(100, None); 51 | 52 | while wallet.get_balance()? < 100_000 { 53 | std::thread::sleep(Duration::from_millis(1_000)); 54 | wallet.sync(noop_progress(), None)?; 55 | println!("syncing done on party {} -- checking balance", id); 56 | } 57 | 58 | let wallet = GunWallet::new(wallet, gun_db); 59 | Ok((wallet, keychain)) 60 | } 61 | 62 | macro_rules! setup_test { 63 | () => {{ 64 | let mut test_client = TestClient::default(); 65 | let (party_1, keychain_1) = create_party(&mut test_client, 1).unwrap(); 66 | let (party_2, keychain_2) = create_party(&mut test_client, 2).unwrap(); 67 | let nonce_secret_key = Scalar::random(&mut rand::thread_rng()); 68 | let announce_keypair = 69 | olivia_secp256k1::SCHNORR.new_keypair(Scalar::random(&mut rand::thread_rng())); 70 | let attest_keypair = 71 | olivia_secp256k1::SCHNORR.new_keypair(Scalar::random(&mut rand::thread_rng())); 72 | let oracle_nonce_keypair = olivia_secp256k1::SCHNORR.new_keypair(nonce_secret_key); 73 | let event_id = EventId::from_str("/test/red_blue.winner").unwrap(); 74 | let oracle_id = "non-existent-oracle.com".to_string(); 75 | let oracle_info = OracleInfo { 76 | id: oracle_id.clone(), 77 | oracle_keys: OracleKeys { 78 | olivia_v1: Some(attest_keypair.public_key().clone().into()), 79 | ecdsa_v1: None, 80 | announcement: announce_keypair.public_key().clone().into(), 81 | group: Secp256k1, 82 | }, 83 | }; 84 | 85 | party_1 86 | .gun_db() 87 | .insert_entity(oracle_id.clone(), oracle_info.clone()) 88 | .unwrap(); 89 | party_2 90 | .gun_db() 91 | .insert_entity(oracle_id.clone(), oracle_info.clone()) 92 | .unwrap(); 93 | 94 | let oracle_event = OracleEvent { 95 | event: Event { 96 | id: event_id.clone(), 97 | expected_outcome_time: None, 98 | }, 99 | schemes: AnnouncementSchemes { 100 | olivia_v1: Some(announce::OliviaV1 { 101 | nonces: vec![oracle_nonce_keypair.public_key().clone().into()], 102 | }), 103 | ..Default::default() 104 | }, 105 | }; 106 | ( 107 | test_client, 108 | (party_1, keychain_1), 109 | (party_2, keychain_2), 110 | oracle_info, 111 | attest_keypair, 112 | oracle_nonce_keypair, 113 | oracle_id, 114 | oracle_event, 115 | ) 116 | }}; 117 | } 118 | 119 | macro_rules! wait_for_state { 120 | ($party:ident, $bet_id:ident, $state:literal) => {{ 121 | let mut counter: usize = 0; 122 | let mut cur_state; 123 | while { 124 | cur_state = $party 125 | .gun_db() 126 | .get_entity::<BetState>($bet_id) 127 | .unwrap() 128 | .unwrap(); 129 | cur_state.name() != $state 130 | } { 131 | $party.take_next_action($bet_id, false).unwrap(); 132 | counter += 1; 133 | std::thread::sleep(std::time::Duration::from_secs(1)); 134 | if counter > 10 { 135 | panic!( 136 | "{}/{} has failed to reach state {}. It ended up in {}. {:?}", 137 | stringify!($party), 138 | stringify!($bet_id), 139 | $state, 140 | cur_state.name(), 141 | cur_state 142 | ); 143 | } 144 | } 145 | }}; 146 | } 147 | 148 | #[test] 149 | pub fn test_happy_path() { 150 | let ( 151 | mut test_client, 152 | (party_1, keychain_1), 153 | (party_2, keychain_2), 154 | oracle_info, 155 | attest_keypair, 156 | oracle_nonce_keypair, 157 | oracle_id, 158 | oracle_event, 159 | ) = setup_test!(); 160 | 161 | let local_proposal = party_1 162 | .make_proposal( 163 | oracle_id.clone(), 164 | oracle_event.clone(), 165 | BetArgs { 166 | value: ValueChoice::Amount(Amount::from_str_with_denomination("0.01 BTC").unwrap()), 167 | ..Default::default() 168 | }, 169 | &keychain_1, 170 | ) 171 | .unwrap(); 172 | 173 | let proposal_string = local_proposal.proposal.clone().into_versioned().to_string(); 174 | let p1_bet_id = party_1 175 | .gun_db() 176 | .insert_bet(BetState::Proposed { local_proposal }) 177 | .unwrap(); 178 | 179 | let (p2_bet_id, encrypted_offer, _) = { 180 | let proposal = VersionedProposal::from_str(&proposal_string).unwrap(); 181 | let (bet, local_public_key, mut cipher) = party_2 182 | .generate_offer_with_oracle_event(OfferArgs { 183 | proposal: proposal.into(), 184 | oracle_event, 185 | oracle_info, 186 | choose_right: true, 187 | args: BetArgs { 188 | value: ValueChoice::Amount( 189 | Amount::from_str_with_denomination("0.02 BTC").unwrap(), 190 | ), 191 | ..Default::default() 192 | }, 193 | fee_spec: FeeSpec::default(), 194 | keychain: &keychain_2, 195 | }) 196 | .unwrap(); 197 | party_2 198 | .sign_save_and_encrypt_offer(bet, None, local_public_key, &mut cipher) 199 | .unwrap() 200 | }; 201 | wait_for_state!(party_2, p2_bet_id, "offered"); 202 | 203 | let (decrypted_offer, offer_public_key, rng) = party_1 204 | .decrypt_offer(p1_bet_id, encrypted_offer, &keychain_1) 205 | .unwrap(); 206 | let mut validated_offer = party_1 207 | .validate_offer( 208 | p1_bet_id, 209 | decrypted_offer.into_offer(), 210 | offer_public_key, 211 | rng, 212 | &keychain_1, 213 | ) 214 | .unwrap(); 215 | party_1.sign_validated_offer(&mut validated_offer).unwrap(); 216 | 217 | Broadcast::broadcast( 218 | party_1.bdk_wallet().client(), 219 | validated_offer.bet.psbt.clone().extract_tx(), 220 | ) 221 | .unwrap(); 222 | party_1.set_offer_taken(validated_offer).unwrap(); 223 | wait_for_state!(party_1, p1_bet_id, "unconfirmed"); 224 | test_client.generate(1, None); 225 | 226 | wait_for_state!(party_1, p1_bet_id, "confirmed"); 227 | wait_for_state!(party_2, p2_bet_id, "confirmed"); 228 | 229 | let (outcome, index, winner, winner_id, loser, loser_id) = match rand::random() { 230 | false => ("red", 0, &party_1, p1_bet_id, party_2, p2_bet_id), 231 | true => ("blue", 1, &party_2, p2_bet_id, party_1, p1_bet_id), 232 | }; 233 | 234 | let winner_initial_balance = winner.bdk_wallet().get_balance().unwrap(); 235 | 236 | let attestation = Attestation { 237 | outcome: outcome.into(), 238 | schemes: AttestationSchemes { 239 | olivia_v1: Some(attest::OliviaV1 { 240 | scalars: vec![Secp256k1::reveal_attest_scalar( 241 | &attest_keypair, 242 | oracle_nonce_keypair.into(), 243 | index, 244 | ) 245 | .into()], 246 | }), 247 | ..Default::default() 248 | }, 249 | time: olivia_core::chrono::Utc::now().naive_utc(), 250 | }; 251 | 252 | winner 253 | .learn_outcome(winner_id, attestation.clone()) 254 | .unwrap(); 255 | wait_for_state!(winner, winner_id, "won"); 256 | 257 | loser.learn_outcome(loser_id, attestation).unwrap(); 258 | assert!( 259 | loser.claim(FeeSpec::default(), false).unwrap().is_none(), 260 | "loser should not have claim tx" 261 | ); 262 | wait_for_state!(loser, loser_id, "lost"); 263 | 264 | let (_, winner_claim_psbt) = winner 265 | .claim(FeeSpec::default(), false) 266 | .unwrap() 267 | .expect("winner should return a tx here"); 268 | 269 | let winner_claim_tx = winner_claim_psbt.extract_tx(); 270 | 271 | winner.bdk_wallet().broadcast(&winner_claim_tx).unwrap(); 272 | wait_for_state!(winner, winner_id, "claiming"); 273 | test_client.generate(1, None); 274 | wait_for_state!(winner, winner_id, "claimed"); 275 | winner.bdk_wallet().sync(noop_progress(), None).unwrap(); 276 | 277 | assert!(winner.bdk_wallet().get_balance().unwrap() > winner_initial_balance); 278 | wait_for_state!(winner, winner_id, "claimed"); 279 | } 280 | 281 | #[test] 282 | pub fn cancel_proposal() { 283 | let ( 284 | mut test_client, 285 | (party_1, keychain_1), 286 | (party_2, keychain_2), 287 | oracle_info, 288 | _, 289 | _, 290 | oracle_id, 291 | oracle_event, 292 | ) = setup_test!(); 293 | 294 | let local_proposal_1 = party_1 295 | .make_proposal( 296 | oracle_id.clone(), 297 | oracle_event.clone(), 298 | BetArgs { 299 | value: ValueChoice::Amount(Amount::from_str_with_denomination("0.02 BTC").unwrap()), 300 | ..Default::default() 301 | }, 302 | &keychain_1, 303 | ) 304 | .unwrap(); 305 | 306 | let proposal_1 = local_proposal_1 307 | .proposal 308 | .clone() 309 | .into_versioned() 310 | .to_string(); 311 | 312 | let p1_bet_id = party_1 313 | .gun_db() 314 | .insert_bet(BetState::Proposed { 315 | local_proposal: local_proposal_1, 316 | }) 317 | .unwrap(); 318 | 319 | let local_proposal_2 = party_1 320 | .make_proposal( 321 | oracle_id.clone(), 322 | oracle_event.clone(), 323 | BetArgs { 324 | value: ValueChoice::Amount(Amount::from_str_with_denomination("0.01 BTC").unwrap()), 325 | must_overlap: &[p1_bet_id], 326 | ..Default::default() 327 | }, 328 | &keychain_2, 329 | ) 330 | .unwrap(); 331 | 332 | let bet_id_overlap = party_1 333 | .gun_db() 334 | .insert_bet(BetState::Proposed { 335 | local_proposal: local_proposal_2, 336 | }) 337 | .unwrap(); 338 | 339 | let (p2_bet_id, _, _) = { 340 | let proposal = VersionedProposal::from_str(&proposal_1).unwrap(); 341 | let (bet, offer_public_key, mut cipher) = party_2 342 | .generate_offer_with_oracle_event(OfferArgs { 343 | proposal: proposal.into(), 344 | choose_right: true, 345 | oracle_event, 346 | oracle_info, 347 | args: BetArgs { 348 | value: ValueChoice::Amount( 349 | Amount::from_str_with_denomination("0.02 BTC").unwrap(), 350 | ), 351 | ..Default::default() 352 | }, 353 | fee_spec: FeeSpec::default(), 354 | keychain: &keychain_2, 355 | }) 356 | .unwrap(); 357 | party_2 358 | .sign_save_and_encrypt_offer(bet, None, offer_public_key, &mut cipher) 359 | .unwrap() 360 | }; 361 | 362 | let psbt = party_1 363 | .generate_cancel_tx(&[p1_bet_id], FeeSpec::default()) 364 | .unwrap() 365 | .expect("should be able to cancel"); 366 | let tx = psbt.extract_tx(); 367 | Broadcast::broadcast(party_1.bdk_wallet().client(), tx).unwrap(); 368 | wait_for_state!(party_1, p1_bet_id, "canceling"); 369 | test_client.generate(1, None); 370 | wait_for_state!(party_1, bet_id_overlap, "canceled"); 371 | // wait_for_state!(party_1, p1_bet_id, "canceled"); 372 | wait_for_state!(party_2, p2_bet_id, "canceled"); 373 | } 374 | 375 | #[test] 376 | pub fn test_cancel_offer() { 377 | let ( 378 | mut test_client, 379 | (party_1, keychain_1), 380 | (party_2, keychain_2), 381 | oracle_info, 382 | _, 383 | _, 384 | oracle_id, 385 | oracle_event, 386 | ) = setup_test!(); 387 | 388 | let local_proposal = party_1 389 | .make_proposal( 390 | oracle_id.clone(), 391 | oracle_event.clone(), 392 | BetArgs { 393 | value: ValueChoice::Amount(Amount::from_str_with_denomination("0.01 BTC").unwrap()), 394 | ..Default::default() 395 | }, 396 | &keychain_1, 397 | ) 398 | .unwrap(); 399 | 400 | let proposal_str = local_proposal.proposal.clone().into_versioned().to_string(); 401 | 402 | let (p2_bet_id, _, _) = { 403 | let proposal = VersionedProposal::from_str(&proposal_str).unwrap(); 404 | let (bet, offer_public_key, mut cipher) = party_2 405 | .generate_offer_with_oracle_event(OfferArgs { 406 | proposal: proposal.into(), 407 | choose_right: true, 408 | oracle_event, 409 | oracle_info, 410 | args: BetArgs { 411 | value: ValueChoice::Amount( 412 | Amount::from_str_with_denomination("0.02 BTC").unwrap(), 413 | ), 414 | ..Default::default() 415 | }, 416 | fee_spec: FeeSpec::default(), 417 | keychain: &keychain_2, 418 | }) 419 | .unwrap(); 420 | party_2 421 | .sign_save_and_encrypt_offer(bet, None, offer_public_key, &mut cipher) 422 | .unwrap() 423 | }; 424 | 425 | let psbt = party_2 426 | .generate_cancel_tx(&[p2_bet_id], FeeSpec::default()) 427 | .unwrap() 428 | .expect("should be able to cancel"); 429 | let tx = psbt.extract_tx(); 430 | Broadcast::broadcast(party_2.bdk_wallet().client(), tx).unwrap(); 431 | 432 | wait_for_state!(party_2, p2_bet_id, "canceling"); 433 | test_client.generate(1, None); 434 | wait_for_state!(party_2, p2_bet_id, "canceled"); 435 | } 436 | 437 | #[test] 438 | pub fn cancel_offer_after_offer_taken() { 439 | let ( 440 | mut test_client, 441 | (party_1, keychain_1), 442 | (party_2, keychain_2), 443 | oracle_info, 444 | _, 445 | _, 446 | oracle_id, 447 | oracle_event, 448 | ) = setup_test!(); 449 | 450 | let local_proposal = party_1 451 | .make_proposal( 452 | oracle_id.clone(), 453 | oracle_event.clone(), 454 | BetArgs { 455 | value: ValueChoice::Amount(Amount::from_str_with_denomination("0.01 BTC").unwrap()), 456 | ..Default::default() 457 | }, 458 | &keychain_1, 459 | ) 460 | .unwrap(); 461 | 462 | let proposal_str = local_proposal.proposal.clone().into_versioned().to_string(); 463 | let p1_bet_id = party_1 464 | .gun_db() 465 | .insert_bet(BetState::Proposed { local_proposal }) 466 | .unwrap(); 467 | let proposal = Proposal::from(VersionedProposal::from_str(&proposal_str).unwrap()); 468 | 469 | let (first_p2_bet_id, _, _) = { 470 | let (bet, offer_public_key, mut cipher) = party_2 471 | .generate_offer_with_oracle_event(OfferArgs { 472 | proposal: proposal.clone(), 473 | choose_right: true, 474 | oracle_event: oracle_event.clone(), 475 | oracle_info: oracle_info.clone(), 476 | args: BetArgs { 477 | value: ValueChoice::Amount( 478 | Amount::from_str_with_denomination("0.02 BTC").unwrap(), 479 | ), 480 | ..Default::default() 481 | }, 482 | fee_spec: FeeSpec::default(), 483 | keychain: &keychain_2, 484 | }) 485 | .unwrap(); 486 | party_2 487 | .sign_save_and_encrypt_offer(bet, None, offer_public_key, &mut cipher) 488 | .unwrap() 489 | }; 490 | 491 | let (second_p2_bet_id, second_encrypted_offer, _) = { 492 | let (bet, offer_public_key, mut cipher) = party_2 493 | .generate_offer_with_oracle_event(OfferArgs { 494 | proposal, 495 | oracle_event, 496 | oracle_info, 497 | choose_right: true, 498 | args: BetArgs { 499 | value: ValueChoice::Amount( 500 | Amount::from_str_with_denomination("0.03 BTC").unwrap(), 501 | ), 502 | must_overlap: &[first_p2_bet_id], 503 | ..Default::default() 504 | }, 505 | fee_spec: FeeSpec::Rate(FeeRate::from_sat_per_vb(1.0)), 506 | keychain: &keychain_2, 507 | }) 508 | .unwrap(); 509 | party_2 510 | .sign_save_and_encrypt_offer(bet, None, offer_public_key, &mut cipher) 511 | .unwrap() 512 | }; 513 | 514 | let (second_decrypted_offer, second_offer_public_key, rng) = party_1 515 | .decrypt_offer(p1_bet_id, second_encrypted_offer, &keychain_1) 516 | .unwrap(); 517 | let mut second_validated_offer = party_1 518 | .validate_offer( 519 | p1_bet_id, 520 | second_decrypted_offer.into_offer(), 521 | second_offer_public_key, 522 | rng, 523 | &keychain_1, 524 | ) 525 | .unwrap(); 526 | party_1 527 | .sign_validated_offer(&mut second_validated_offer) 528 | .unwrap(); 529 | 530 | Broadcast::broadcast(party_1.bdk_wallet().client(), second_validated_offer.tx()).unwrap(); 531 | party_1.set_offer_taken(second_validated_offer).unwrap(); 532 | 533 | wait_for_state!(party_1, p1_bet_id, "unconfirmed"); 534 | let psbt = party_2 535 | .generate_cancel_tx( 536 | &[first_p2_bet_id, second_p2_bet_id], 537 | FeeSpec::Rate(FeeRate::from_sat_per_vb(5.0)), 538 | ) 539 | .unwrap() 540 | .expect("should be able to cancel"); 541 | let tx = psbt.extract_tx(); 542 | Broadcast::broadcast(party_2.bdk_wallet().client(), tx).unwrap(); 543 | 544 | wait_for_state!(party_2, second_p2_bet_id, "canceling"); 545 | wait_for_state!(party_2, first_p2_bet_id, "canceling"); 546 | test_client.generate(1, None); 547 | wait_for_state!(party_2, second_p2_bet_id, "canceled"); 548 | wait_for_state!(party_2, first_p2_bet_id, "canceled"); 549 | } 550 | 551 | #[test] 552 | fn create_proposal_with_dust_change() { 553 | let ( 554 | mut test_client, 555 | (party_1, keychain_1), 556 | (party_2, keychain_2), 557 | oracle_info, 558 | _, 559 | _, 560 | oracle_id, 561 | oracle_event, 562 | ) = setup_test!(); 563 | 564 | let balance = party_1.bdk_wallet().get_balance().unwrap(); 565 | let bet_value = balance - 250; 566 | 567 | let local_proposal = party_1 568 | .make_proposal( 569 | oracle_id.clone(), 570 | oracle_event.clone(), 571 | BetArgs { 572 | value: ValueChoice::Amount(Amount::from_sat(bet_value)), 573 | ..Default::default() 574 | }, 575 | &keychain_1, 576 | ) 577 | .unwrap(); 578 | 579 | assert_eq!(local_proposal.change, None); 580 | assert_eq!(local_proposal.proposal.value.as_sat(), bet_value); 581 | 582 | let p1_bet_id = party_1 583 | .gun_db() 584 | .insert_bet(BetState::Proposed { 585 | local_proposal: local_proposal.clone(), 586 | }) 587 | .unwrap(); 588 | 589 | let (p2_bet_id, encrypted_offer) = { 590 | let balance = party_2.bdk_wallet().get_balance().unwrap(); 591 | let bet_value = balance - 250; 592 | 593 | assert!( 594 | matches!( 595 | party_2 596 | .generate_offer_with_oracle_event(OfferArgs { 597 | proposal: local_proposal.proposal.clone(), 598 | choose_right: true, 599 | oracle_event: oracle_event.clone(), 600 | oracle_info: oracle_info.clone(), 601 | args: BetArgs { 602 | value: ValueChoice::Amount(Amount::from_sat(bet_value)), 603 | ..Default::default() 604 | }, 605 | fee_spec: FeeSpec::Absolute(Amount::from_sat(501)), 606 | keychain: &keychain_2 607 | }) 608 | .map(|_| ()) 609 | .unwrap_err() 610 | .downcast() 611 | .unwrap(), 612 | bdk::Error::InsufficientFunds { .. } 613 | ), 614 | "we can't afford 501 fee even with extra proposal fee" 615 | ); 616 | 617 | let (bet, local_public_key, mut cipher) = party_2 618 | .generate_offer_with_oracle_event(OfferArgs { 619 | proposal: local_proposal.proposal, 620 | choose_right: true, 621 | oracle_event, 622 | oracle_info, 623 | args: BetArgs { 624 | value: ValueChoice::Amount(Amount::from_sat(bet_value)), 625 | ..Default::default() 626 | }, 627 | // we can afford 500 628 | fee_spec: FeeSpec::Absolute(Amount::from_sat(500)), 629 | keychain: &keychain_2, 630 | }) 631 | .unwrap(); 632 | 633 | let (bet_id, encrypted_offer, offer) = party_2 634 | .sign_save_and_encrypt_offer(bet, None, local_public_key, &mut cipher) 635 | .unwrap(); 636 | 637 | assert_eq!(offer.change, None); 638 | assert_eq!(offer.value.as_sat(), bet_value); 639 | 640 | (bet_id, encrypted_offer) 641 | }; 642 | 643 | wait_for_state!(party_2, p2_bet_id, "offered"); 644 | 645 | let (decrypted_offer, offer_public_key, rng) = party_1 646 | .decrypt_offer(p1_bet_id, encrypted_offer, &keychain_1) 647 | .unwrap(); 648 | let mut validated_offer = party_1 649 | .validate_offer( 650 | p1_bet_id, 651 | decrypted_offer.into_offer(), 652 | offer_public_key, 653 | rng, 654 | &keychain_1, 655 | ) 656 | .unwrap(); 657 | party_1.sign_validated_offer(&mut validated_offer).unwrap(); 658 | 659 | Broadcast::broadcast( 660 | party_1.bdk_wallet().client(), 661 | validated_offer.bet.psbt.clone().extract_tx(), 662 | ) 663 | .unwrap(); 664 | party_1.set_offer_taken(validated_offer).unwrap(); 665 | test_client.generate(1, None); 666 | 667 | wait_for_state!(party_1, p1_bet_id, "confirmed"); 668 | wait_for_state!(party_2, p2_bet_id, "confirmed"); 669 | } 670 | --------------------------------------------------------------------------------