diff --git a/.github/actions/bitcoin/action.yml b/.github/actions/bitcoin/action.yml new file mode 100644 index 00000000..4aad4791 --- /dev/null +++ b/.github/actions/bitcoin/action.yml @@ -0,0 +1,41 @@ +name: bitcoin-regtest +description: Spawns a regtest Bitcoin daemon + +inputs: + version: + description: "Version to download and run" + required: false + default: 24.0.1 + +runs: + using: "composite" + steps: + - name: Bitcoin Daemon Cache + id: cache-bitcoind + uses: actions/cache@v3 + with: + path: bitcoind + key: bitcoind-${{ runner.os }}-${{ runner.arch }}-${{ inputs.version }} + + - name: Download the Bitcoin Daemon + if: steps.cache-bitcoind.outputs.cache-hit != 'true' + shell: bash + run: | + RUNNER_OS=linux + RUNNER_ARCH=x86_64 + + BASE=bitcoin-${{ inputs.version }} + FILE=$BASE-$RUNNER_ARCH-$RUNNER_OS-gnu.tar.gz + wget https://bitcoincore.org/bin/bitcoin-core-${{ inputs.version }}/$FILE + tar xzvf $FILE + + cd bitcoin-${{ inputs.version }} + sudo mv bin/* /bin && sudo mv lib/* /lib + + - name: Bitcoin Regtest Daemon + shell: bash + run: | + RPC_USER=serai + RPC_PASS=seraidex + + bitcoind -regtest -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS -daemon diff --git a/.github/actions/test-dependencies/action.yml b/.github/actions/test-dependencies/action.yml index 6340e946..2fb500d7 100644 --- a/.github/actions/test-dependencies/action.yml +++ b/.github/actions/test-dependencies/action.yml @@ -12,6 +12,11 @@ inputs: required: false default: v0.18.0.0 + bitcoin-version: + description: "Bitcoin version to download and run as a regtest node" + required: false + default: 24.0.1 + serai: description: "Run a Serai development node in the background" required: false @@ -35,6 +40,11 @@ runs: with: version: ${{ inputs.monero-version }} + - name: Run a Bitcoin Regtest Node + uses: ./.github/actions/bitcoin + with: + version: ${{ inputs.bitcoin-version }} + - name: Run a Monero Wallet-RPC uses: ./.github/actions/monero-wallet-rpc diff --git a/Cargo.lock b/Cargo.lock index ba40c8c7..aa96be02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,6 +503,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dabbe35f96fb9507f7330793dc490461b2962659ac5d427181e451a623751d1" +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "beef" version = "0.5.2" @@ -555,6 +561,46 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bitcoin" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0694ea59225b0c5f3cb405ff3f670e4828358ed26aec49dc352f730f0cb1a8a3" +dependencies = [ + "bech32 0.9.1", + "bitcoin_hashes", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-serai" +version = "0.1.0" +dependencies = [ + "bitcoin", + "flexible-transcript", + "hex", + "k256", + "lazy_static", + "modular-frost", + "rand_core 0.6.4", + "reqwest", + "secp256k1", + "serde", + "serde_json", + "sha2 0.10.6", + "thiserror", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90064b8dee6815a6470d60bad07bbbaee885c0e12d04177138fa3291a01b7bc4" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1112,7 +1158,7 @@ checksum = "c94090a6663f224feae66ab01e41a2555a8296ee07b5f20dab8888bdefc9f617" dependencies = [ "base58check", "base64 0.12.3", - "bech32", + "bech32 0.7.3", "blake2", "digest 0.10.6", "generic-array 0.14.6", @@ -7882,7 +7928,10 @@ version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1629c9c557ef9b293568b338dddfc8208c98a18c59d722a9d53f859d9c9b62" dependencies = [ + "bitcoin_hashes", + "rand 0.8.5", "secp256k1-sys", + "serde", ] [[package]] @@ -8027,15 +8076,19 @@ name = "serai-processor" version = "0.1.0" dependencies = [ "async-trait", + "bitcoin", + "bitcoin-serai", "curve25519-dalek 3.2.0", "dalek-ff-group", "flexible-transcript", "futures", "group", "hex", + "k256", "modular-frost", "monero-serai", "rand_core 0.6.4", + "secp256k1", "serde", "serde_json", "thiserror", diff --git a/coins/bitcoin/Cargo.toml b/coins/bitcoin/Cargo.toml new file mode 100644 index 00000000..de987bbc --- /dev/null +++ b/coins/bitcoin/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bitcoin-serai" +version = "0.1.0" +description = "A Bitcoin library for FROST-signing transactions" +license = "AGPL-3.0-only" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/bitcoin" +authors = ["Luke Parker ", "Vrx "] +edition = "2021" +publish = false + +[dependencies] +lazy_static = "1" +thiserror = "1" + +rand_core = "0.6" + +sha2 = "0.10" + +secp256k1 = { version = "0.24", features = ["global-context"] } +bitcoin = { version = "0.29", features = ["serde"] } + +k256 = { version = "0.11", features = ["arithmetic"] } +transcript = { package = "flexible-transcript", path = "../../crypto/transcript", version = "0.2", features = ["recommended"] } +frost = { version = "0.5", package = "modular-frost", path = "../../crypto/frost", features = ["secp256k1", "tests"] } + +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.11", features = ["json"] } diff --git a/coins/bitcoin/LICENSE b/coins/bitcoin/LICENSE new file mode 100644 index 00000000..c425427c --- /dev/null +++ b/coins/bitcoin/LICENSE @@ -0,0 +1,15 @@ +AGPL-3.0-only license + +Copyright (c) 2022-2023 Luke Parker + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License Version 3 as +published by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/coins/bitcoin/src/crypto.rs b/coins/bitcoin/src/crypto.rs new file mode 100644 index 00000000..bea62831 --- /dev/null +++ b/coins/bitcoin/src/crypto.rs @@ -0,0 +1,58 @@ +use lazy_static::lazy_static; + +use sha2::{Digest, Sha256}; + +use k256::{ + elliptic_curve::{ + ops::Reduce, + sec1::{Tag, ToEncodedPoint}, + }, + U256, Scalar, ProjectivePoint, +}; + +use bitcoin::XOnlyPublicKey; + +use frost::{algorithm::Hram, curve::Secp256k1}; + +/// Get the x coordinate of a non-infinity, even point. +pub fn x(key: &ProjectivePoint) -> [u8; 32] { + let encoded = key.to_encoded_point(true); + assert_eq!(encoded.tag(), Tag::CompressedEvenY); + (*encoded.x().expect("point at infinity")).into() +} + +pub fn x_only(key: &ProjectivePoint) -> XOnlyPublicKey { + XOnlyPublicKey::from_slice(&x(key)).unwrap() +} + +pub fn make_even(mut key: ProjectivePoint) -> (ProjectivePoint, u64) { + let mut c = 0; + while key.to_encoded_point(true).tag() == Tag::CompressedOddY { + key += ProjectivePoint::GENERATOR; + c += 1; + } + (key, c) +} + +#[derive(Clone)] +pub struct BitcoinHram {} + +lazy_static! { + static ref TAG_HASH: [u8; 32] = Sha256::digest(b"BIP0340/challenge").into(); +} + +#[allow(non_snake_case)] +impl Hram for BitcoinHram { + fn hram(R: &ProjectivePoint, A: &ProjectivePoint, m: &[u8]) -> Scalar { + let (R, _) = make_even(*R); + + let mut data = Sha256::new(); + data.update(*TAG_HASH); + data.update(*TAG_HASH); + data.update(x(&R)); + data.update(x(A)); + data.update(m); + + Scalar::from_uint_reduced(U256::from_be_slice(&data.finalize())) + } +} diff --git a/coins/bitcoin/src/lib.rs b/coins/bitcoin/src/lib.rs new file mode 100644 index 00000000..22c1c26c --- /dev/null +++ b/coins/bitcoin/src/lib.rs @@ -0,0 +1,6 @@ +pub mod crypto; +pub mod wallet; +pub mod rpc; + +#[cfg(test)] +mod tests; diff --git a/coins/bitcoin/src/rpc.rs b/coins/bitcoin/src/rpc.rs new file mode 100644 index 00000000..1ae84916 --- /dev/null +++ b/coins/bitcoin/src/rpc.rs @@ -0,0 +1,80 @@ +use core::fmt::Debug; + +use thiserror::Error; + +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::json; + +use bitcoin::{ + hashes::hex::{FromHex, ToHex}, + consensus::encode, + Txid, Transaction, BlockHash, Block, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum RpcResponse { + Ok { result: T }, + Err { error: String }, +} + +#[derive(Clone, Debug)] +pub struct Rpc(String); + +#[derive(Clone, PartialEq, Eq, Debug, Error)] +pub enum RpcError { + #[error("couldn't connect to node")] + ConnectionError, + #[error("request had an error: {0}")] + RequestError(String), + #[error("node sent an invalid response")] + InvalidResponse, +} + +impl Rpc { + pub fn new(url: String) -> Rpc { + Rpc(url) + } + + pub async fn rpc_call( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + let client = reqwest::Client::new(); + let res = client + .post(&self.0) + .json(&json!({ "jsonrpc": "2.0", "method": method, "params": params })) + .send() + .await + .map_err(|_| RpcError::ConnectionError)? + .text() + .await + .map_err(|_| RpcError::ConnectionError)?; + + let res: RpcResponse = + serde_json::from_str(&res).map_err(|_| RpcError::InvalidResponse)?; + match res { + RpcResponse::Ok { result } => Ok(result), + RpcResponse::Err { error } => Err(RpcError::RequestError(error)), + } + } + + pub async fn get_latest_block_number(&self) -> Result { + self.rpc_call("getblockcount", json!([])).await + } + + pub async fn get_block_hash(&self, number: usize) -> Result { + self.rpc_call("getblockhash", json!([number])).await + } + + pub async fn get_block(&self, block_hash: &BlockHash) -> Result { + let hex = self.rpc_call::("getblock", json!([block_hash.to_hex(), 0])).await?; + let bytes: Vec = FromHex::from_hex(&hex).map_err(|_| RpcError::InvalidResponse)?; + encode::deserialize(&bytes).map_err(|_| RpcError::InvalidResponse) + } + + pub async fn send_raw_transaction(&self, tx: &Transaction) -> Result { + self.rpc_call("sendrawtransaction", json!([encode::serialize_hex(tx)])).await + } +} diff --git a/coins/bitcoin/src/tests/mod.rs b/coins/bitcoin/src/tests/mod.rs new file mode 100644 index 00000000..20be306a --- /dev/null +++ b/coins/bitcoin/src/tests/mod.rs @@ -0,0 +1,47 @@ +use rand_core::OsRng; + +use sha2::{Digest, Sha256}; + +use secp256k1::{SECP256K1, Message, schnorr::Signature}; +use bitcoin::hashes::{Hash as HashTrait, sha256::Hash}; + +use k256::Scalar; +use frost::{ + curve::Secp256k1, + algorithm::Schnorr, + tests::{algorithm_machines, key_gen, sign}, +}; + +use crate::crypto::{BitcoinHram, x_only, make_even}; + +#[test] +fn test_signing() { + let mut keys = key_gen::<_, Secp256k1>(&mut OsRng); + const MESSAGE: &[u8] = b"Hello, World!"; + + for (_, keys) in keys.iter_mut() { + let (_, offset) = make_even(keys.group_key()); + *keys = keys.offset(Scalar::from(offset)); + } + + let algo = Schnorr::::new(); + let mut sig = sign( + &mut OsRng, + algo, + keys.clone(), + algorithm_machines(&mut OsRng, Schnorr::::new(), &keys), + &Sha256::digest(MESSAGE), + ); + + let offset; + (sig.R, offset) = make_even(sig.R); + sig.s += Scalar::from(offset); + + SECP256K1 + .verify_schnorr( + &Signature::from_slice(&sig.serialize()[1 .. 65]).unwrap(), + &Message::from(Hash::hash(MESSAGE)), + &x_only(&keys[&1].group_key()), + ) + .unwrap() +} diff --git a/coins/bitcoin/src/wallet.rs b/coins/bitcoin/src/wallet.rs new file mode 100644 index 00000000..26d602fb --- /dev/null +++ b/coins/bitcoin/src/wallet.rs @@ -0,0 +1,295 @@ +use std::{ + io::{self, Read}, + collections::HashMap, +}; + +use rand_core::RngCore; + +use transcript::{Transcript, RecommendedTranscript}; + +use k256::{elliptic_curve::sec1::ToEncodedPoint, Scalar}; +use frost::{curve::Secp256k1, ThresholdKeys, FrostError, algorithm::Schnorr, sign::*}; + +use bitcoin::{ + hashes::Hash, + consensus::encode::{Encodable, Decodable, serialize}, + util::sighash::{SchnorrSighashType, SighashCache, Prevouts}, + OutPoint, Script, Sequence, Witness, TxIn, TxOut, PackedLockTime, Transaction, Address, +}; + +use crate::crypto::{BitcoinHram, make_even}; + +#[derive(Clone, Debug)] +pub struct SpendableOutput { + pub output: TxOut, + pub outpoint: OutPoint, +} + +impl SpendableOutput { + pub fn id(&self) -> [u8; 36] { + serialize(&self.outpoint).try_into().unwrap() + } + + pub fn read(r: &mut R) -> io::Result { + Ok(SpendableOutput { + output: TxOut::consensus_decode(r) + .map_err(|_| io::Error::new(io::ErrorKind::Other, "invalid TxOut"))?, + outpoint: OutPoint::consensus_decode(r) + .map_err(|_| io::Error::new(io::ErrorKind::Other, "invalid OutPoint"))?, + }) + } + + pub fn serialize(&self) -> Vec { + let mut res = serialize(&self.output); + self.outpoint.consensus_encode(&mut res).unwrap(); + res + } +} + +#[derive(Clone, Debug)] +pub struct SignableTransaction(Transaction, Vec); + +impl SignableTransaction { + fn calculate_weight(inputs: usize, payments: &[(Address, u64)], change: Option<&Address>) -> u64 { + let mut tx = Transaction { + version: 2, + lock_time: PackedLockTime::ZERO, + input: vec![ + TxIn { + previous_output: OutPoint::default(), + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::from_vec(vec![vec![0; 64]]) + }; + inputs + ], + output: payments + .iter() + .map(|payment| TxOut { value: payment.1, script_pubkey: payment.0.script_pubkey() }) + .collect(), + }; + if let Some(change) = change { + tx.output.push(TxOut { value: 0, script_pubkey: change.script_pubkey() }); + } + u64::try_from(tx.weight()).unwrap() + } + + pub fn new( + mut inputs: Vec, + payments: &[(Address, u64)], + change: Option
, + fee: u64, + ) -> Option { + let input_sat = inputs.iter().map(|input| input.output.value).sum::(); + let tx_ins = inputs + .iter() + .map(|input| TxIn { + previous_output: input.outpoint, + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }) + .collect::>(); + + let payment_sat = payments.iter().map(|payment| payment.1).sum::(); + let mut tx_outs = payments + .iter() + .map(|payment| TxOut { value: payment.1, script_pubkey: payment.0.script_pubkey() }) + .collect::>(); + + let actual_fee = fee * Self::calculate_weight(tx_ins.len(), payments, None); + if payment_sat > (input_sat - actual_fee) { + return None; + } + + // If there's a change address, check if there's a meaningful change + if let Some(change) = change.as_ref() { + let fee_with_change = fee * Self::calculate_weight(tx_ins.len(), payments, Some(change)); + // If there's a non-zero change, add it + if let Some(value) = input_sat.checked_sub(payment_sat + fee_with_change) { + tx_outs.push(TxOut { value, script_pubkey: change.script_pubkey() }); + } + } + + // TODO: Drop outputs which BTC will consider spam (outputs worth less than the cost to spend + // them) + + Some(SignableTransaction( + Transaction { version: 2, lock_time: PackedLockTime::ZERO, input: tx_ins, output: tx_outs }, + inputs.drain(..).map(|input| input.output).collect(), + )) + } + + pub async fn multisig( + self, + keys: ThresholdKeys, + mut transcript: RecommendedTranscript, + ) -> Result { + transcript.domain_separate(b"bitcoin_transaction"); + transcript.append_message(b"root_key", keys.group_key().to_encoded_point(true).as_bytes()); + + // Transcript the inputs and outputs + let tx = &self.0; + for input in &tx.input { + transcript.append_message(b"input_hash", input.previous_output.txid.as_hash().into_inner()); + transcript.append_message(b"input_output_index", input.previous_output.vout.to_le_bytes()); + } + for payment in &tx.output { + transcript.append_message(b"output_script", payment.script_pubkey.as_bytes()); + transcript.append_message(b"output_amount", payment.value.to_le_bytes()); + } + + let mut sigs = vec![]; + for _ in 0 .. tx.input.len() { + // TODO: Use the above transcript here + sigs.push( + AlgorithmMachine::new(Schnorr::::new(), keys.clone()).unwrap(), + ); + } + + Ok(TransactionMachine { tx: self, transcript, sigs }) + } +} + +pub struct TransactionMachine { + tx: SignableTransaction, + transcript: RecommendedTranscript, + sigs: Vec>>, +} + +impl PreprocessMachine for TransactionMachine { + type Preprocess = Vec>; + type Signature = Transaction; + type SignMachine = TransactionSignMachine; + + fn preprocess( + mut self, + rng: &mut R, + ) -> (Self::SignMachine, Self::Preprocess) { + let mut preprocesses = Vec::with_capacity(self.sigs.len()); + let sigs = self + .sigs + .drain(..) + .map(|sig| { + let (sig, preprocess) = sig.preprocess(rng); + preprocesses.push(preprocess); + sig + }) + .collect(); + + (TransactionSignMachine { tx: self.tx, transcript: self.transcript, sigs }, preprocesses) + } +} + +pub struct TransactionSignMachine { + tx: SignableTransaction, + transcript: RecommendedTranscript, + sigs: Vec>>, +} + +impl SignMachine for TransactionSignMachine { + type Params = (); + type Keys = ThresholdKeys; + type Preprocess = Vec>; + type SignatureShare = Vec>; + type SignatureMachine = TransactionSignatureMachine; + + fn cache(self) -> CachedPreprocess { + unimplemented!( + "Bitcoin transactions don't support caching their preprocesses due to {}", + "being already bound to a specific transaction" + ); + } + + fn from_cache( + _: (), + _: ThresholdKeys, + _: CachedPreprocess, + ) -> Result { + unimplemented!( + "Bitcoin transactions don't support caching their preprocesses due to {}", + "being already bound to a specific transaction" + ); + } + + fn read_preprocess(&self, reader: &mut R) -> io::Result { + self.sigs.iter().map(|sig| sig.read_preprocess(reader)).collect() + } + + fn sign( + mut self, + commitments: HashMap, + msg: &[u8], + ) -> Result<(TransactionSignatureMachine, Self::SignatureShare), FrostError> { + if !msg.is_empty() { + Err(FrostError::InternalError( + "message was passed to the TransactionMachine when it generates its own", + ))?; + } + + let commitments = (0 .. self.sigs.len()) + .map(|c| { + commitments + .iter() + .map(|(l, commitments)| (*l, commitments[c].clone())) + .collect::>() + }) + .collect::>(); + + let mut cache = SighashCache::new(&self.tx.0); + let prevouts = Prevouts::All(&self.tx.1); + + let mut shares = Vec::with_capacity(self.sigs.len()); + let sigs = self + .sigs + .drain(..) + .enumerate() + .map(|(i, sig)| { + let tx_sighash = cache + .taproot_key_spend_signature_hash(i, &prevouts, SchnorrSighashType::Default) + .unwrap(); + + let (sig, share) = sig.sign(commitments[i].clone(), &tx_sighash)?; + shares.push(share); + Ok(sig) + }) + .collect::>()?; + + Ok((TransactionSignatureMachine { tx: self.tx.0, sigs }, shares)) + } +} + +pub struct TransactionSignatureMachine { + tx: Transaction, + sigs: Vec>>, +} + +impl SignatureMachine for TransactionSignatureMachine { + type SignatureShare = Vec>; + + fn read_share(&self, reader: &mut R) -> io::Result { + self.sigs.iter().map(|sig| sig.read_share(reader)).collect() + } + + fn complete( + mut self, + mut shares: HashMap, + ) -> Result { + for (input, schnorr) in self.tx.input.iter_mut().zip(self.sigs.drain(..)) { + let mut sig = schnorr.complete( + shares.iter_mut().map(|(l, shares)| (*l, shares.remove(0))).collect::>(), + )?; + + // TODO: Implement BitcoinSchnorr Algorithm to handle this + let offset; + (sig.R, offset) = make_even(sig.R); + sig.s += Scalar::from(offset); + + let mut witness: Witness = Witness::new(); + witness.push(&sig.serialize()[1 .. 65]); + input.witness = witness; + } + + Ok(self.tx) + } +} diff --git a/deny.toml b/deny.toml index a289c911..5826699d 100644 --- a/deny.toml +++ b/deny.toml @@ -44,6 +44,7 @@ allow-osi-fsf-free = "neither" default = "deny" exceptions = [ + { allow = ["AGPL-3.0"], name = "bitcoin-serai" }, { allow = ["AGPL-3.0"], name = "ethereum-serai" }, { allow = ["AGPL-3.0"], name = "serai-processor" }, diff --git a/deploy/coins/bitcoin/Dockerfile b/deploy/coins/bitcoin/Dockerfile index 75016e9c..bb86757c 100644 --- a/deploy/coins/bitcoin/Dockerfile +++ b/deploy/coins/bitcoin/Dockerfile @@ -8,7 +8,7 @@ ENV BITCOIN_DATA=/home/bitcoin/.bitcoin WORKDIR /home/bitcoin RUN apk update \ - && apk --no-cache add ca-certificates gnupg bash su-exec + && apk --no-cache add ca-certificates gnupg bash su-exec # Get Binary # TODO: When bitcoin.org publishes 23.0, retrieve checksums from there. @@ -49,6 +49,3 @@ COPY ./scripts /scripts EXPOSE 8332 8333 18332 18333 18443 18444 VOLUME ["/home/bitcoin/.bitcoin"] - -# Run -CMD ["bitcoind"] diff --git a/deploy/coins/bitcoin/scripts/entry-dev.sh b/deploy/coins/bitcoin/scripts/entry-dev.sh index eaf10bc8..a8525881 100755 --- a/deploy/coins/bitcoin/scripts/entry-dev.sh +++ b/deploy/coins/bitcoin/scripts/entry-dev.sh @@ -1,29 +1,6 @@ #!/bin/sh + RPC_USER="${RPC_USER:=serai}" RPC_PASS="${RPC_PASS:=seraidex}" -# address: bcrt1q7kc7tm3a4qljpw4gg5w73cgya6g9nfydtessgs -# private key: cV9X6E3J9jq7R1XR8uPED2JqFxqcd6KrC8XWPy1GchZj7MA7G9Wx -MINER="${MINER:=bcrt1q7kc7tm3a4qljpw4gg5w73cgya6g9nfydtessgs}" -PRIV_KEY="${PRIV_KEY:=cV9X6E3J9jq7R1XR8uPED2JqFxqcd6KrC8XWPy1GchZj7MA7G9Wx}" -BLOCK_TIME=${BLOCK_TIME:=5} - -bitcoind -regtest -txindex -fallbackfee=0.000001 -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS -rpcallowip=0.0.0.0/0 -rpcbind=127.0.0.1 -rpcbind=$(hostname) & - -# give time to bitcoind to start -while true -do - bitcoin-cli -regtest -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS generatetoaddress 100 $MINER && break - sleep 5 -done - -bitcoin-cli -regtest -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS createwallet "miner" false false $RPC_PASS false false true && -bitcoin-cli -regtest -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS walletpassphrase $RPC_PASS 60 && -bitcoin-cli -regtest -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS importprivkey $PRIV_KEY - -# mine a new block every BLOCK_TIME -while true -do - bitcoin-cli -regtest -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS generatetoaddress 1 $MINER - sleep $BLOCK_TIME -done +bitcoind -regtest -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS -rpcallowip=0.0.0.0/0 -rpcbind=127.0.0.1 -rpcbind=$(hostname) diff --git a/deploy/coins/monero/Dockerfile b/deploy/coins/monero/Dockerfile index 3a91ba41..5ca3ccf8 100644 --- a/deploy/coins/monero/Dockerfile +++ b/deploy/coins/monero/Dockerfile @@ -1,5 +1,5 @@ -# Prepare Environment FROM alpine:latest as builder + # https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.1.0.tar.bz2 # Verification will fail if MONERO_VERSION doesn't match the latest # due to the way monero publishes releases. They overwrite a single hashes.txt file @@ -38,5 +38,3 @@ COPY ./scripts /scripts EXPOSE 18080 18081 VOLUME /home/monero/.bitmonero - -CMD ["monerod"] diff --git a/deploy/coins/monero/scripts/entry-dev.sh b/deploy/coins/monero/scripts/entry-dev.sh index 0f14cea1..262c2c21 100755 --- a/deploy/coins/monero/scripts/entry-dev.sh +++ b/deploy/coins/monero/scripts/entry-dev.sh @@ -9,10 +9,3 @@ BLOCK_TIME=${BLOCK_TIME:=5} monerod --regtest --rpc-access-control-origins * --confirm-external-bind \ --rpc-bind-ip=0.0.0.0 --offline --fixed-difficulty=1 \ --non-interactive --mining-threads 1 --detach - -# give time to monerod to start -while true; do - sleep 5 -done - -# Create wallet from PRIV_KEY in monero wallet diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 23cae952..772a5da1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -152,6 +152,8 @@ services: volumes: - "./coins/bitcoin/scripts:/scripts" entrypoint: /scripts/entry-dev.sh + ports: + - "18443:18443" ethereum: profiles: diff --git a/processor/Cargo.toml b/processor/Cargo.toml index c7760bab..6aec28f0 100644 --- a/processor/Cargo.toml +++ b/processor/Cargo.toml @@ -26,10 +26,18 @@ curve25519-dalek = { version = "3", features = ["std"] } dalek-ff-group = { path = "../crypto/dalek-ff-group" } transcript = { package = "flexible-transcript", path = "../crypto/transcript" } -frost = { package = "modular-frost", path = "../crypto/frost", features = ["ed25519"] } +frost = { package = "modular-frost", path = "../crypto/frost", features = ["secp256k1", "ed25519"] } # Monero monero-serai = { path = "../coins/monero", features = ["multisig"] } +bitcoin-serai = { path = "../coins/bitcoin" } + +k256 = { version = "0.11", features = ["arithmetic"] } +bitcoin = "0.29" +hex = "0.4" +secp256k1 = { version = "0.24", features = ["global-context", "rand-std"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [dev-dependencies] rand_core = "0.6" @@ -41,4 +49,4 @@ serde_json = "1.0" futures = "0.3" tokio = { version = "1", features = ["full"] } -frost = { package = "modular-frost", path = "../crypto/frost", features = ["ed25519", "tests"] } +frost = { package = "modular-frost", path = "../crypto/frost", features = ["tests"] } diff --git a/processor/src/coin/bitcoin.rs b/processor/src/coin/bitcoin.rs new file mode 100644 index 00000000..f5352aee --- /dev/null +++ b/processor/src/coin/bitcoin.rs @@ -0,0 +1,283 @@ +use std::io; + +use async_trait::async_trait; + +#[rustfmt::skip] +use bitcoin::{ + hashes::Hash, schnorr::TweakedPublicKey, OutPoint, Transaction, Block, Network, Address +}; + +#[cfg(test)] +use bitcoin::{ + secp256k1::{SECP256K1, SecretKey, Message}, + PrivateKey, PublicKey, EcdsaSighashType, + blockdata::script::Builder, + PackedLockTime, Sequence, Script, Witness, TxIn, TxOut, +}; + +use transcript::RecommendedTranscript; +use k256::{ + ProjectivePoint, Scalar, + elliptic_curve::sec1::{ToEncodedPoint, Tag}, +}; +use frost::{curve::Secp256k1, ThresholdKeys}; + +use bitcoin_serai::{ + crypto::{x_only, make_even}, + wallet::{SpendableOutput, TransactionMachine, SignableTransaction as BSignableTransaction}, + rpc::Rpc, +}; + +use crate::coin::{CoinError, Block as BlockTrait, OutputType, Output as OutputTrait, Coin}; + +impl BlockTrait for Block { + type Id = [u8; 32]; + fn id(&self) -> Self::Id { + self.block_hash().as_hash().into_inner() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Fee(u64); + +#[derive(Clone, Debug)] +pub struct Output(SpendableOutput); +impl OutputTrait for Output { + type Id = [u8; 36]; + + // TODO: Implement later + fn kind(&self) -> OutputType { + OutputType::External + } + + fn id(&self) -> Self::Id { + self.0.id() + } + + fn amount(&self) -> u64 { + self.0.output.value + } + + fn serialize(&self) -> Vec { + self.0.serialize() + } + + fn read(reader: &mut R) -> io::Result { + SpendableOutput::read(reader).map(Output) + } +} + +#[derive(Debug)] +pub struct SignableTransaction { + keys: ThresholdKeys, + transcript: RecommendedTranscript, + actual: BSignableTransaction, +} + +#[derive(Clone, Debug)] +pub struct Bitcoin { + pub(crate) rpc: Rpc, +} +impl Bitcoin { + pub async fn new(url: String) -> Bitcoin { + Bitcoin { rpc: Rpc::new(url) } + } + + #[cfg(test)] + pub async fn fresh_chain(&self) { + if self.rpc.get_latest_block_number().await.unwrap() > 0 { + self + .rpc + .rpc_call("invalidateblock", serde_json::json!([self.rpc.get_block_hash(1).await.unwrap()])) + .await + .unwrap() + } + } +} + +#[async_trait] +impl Coin for Bitcoin { + type Curve = Secp256k1; + + type Fee = Fee; + type Transaction = Transaction; + type Block = Block; + + type Output = Output; + type SignableTransaction = SignableTransaction; + type TransactionMachine = TransactionMachine; + + type Address = Address; + + const ID: &'static [u8] = b"Bitcoin"; + const CONFIRMATIONS: usize = 3; + + // TODO: Get hard numbers and tune + const MAX_INPUTS: usize = 128; + const MAX_OUTPUTS: usize = 16; + + fn tweak_keys(&self, key: &mut ThresholdKeys) { + let (_, offset) = make_even(key.group_key()); + *key = key.offset(Scalar::from(offset)); + } + + fn address(&self, key: ProjectivePoint) -> Self::Address { + debug_assert!(key.to_encoded_point(true).tag() == Tag::CompressedEvenY, "YKey is odd"); + Address::p2tr_tweaked( + TweakedPublicKey::dangerous_assume_tweaked(x_only(&key)), + Network::Regtest, + ) + } + + // TODO: Implement later + fn branch_address(&self, key: ProjectivePoint) -> Self::Address { + self.address(key) + } + + async fn get_latest_block_number(&self) -> Result { + Ok(self.rpc.get_latest_block_number().await.map_err(|_| CoinError::ConnectionError)?) + } + + async fn get_block(&self, number: usize) -> Result { + let block_hash = + self.rpc.get_block_hash(number).await.map_err(|_| CoinError::ConnectionError)?; + self.rpc.get_block(&block_hash).await.map_err(|_| CoinError::ConnectionError) + } + + async fn get_outputs( + &self, + block: &Self::Block, + key: ProjectivePoint, + ) -> Result, CoinError> { + let main_addr = self.address(key); + + let mut outputs = Vec::new(); + // Skip the coinbase transaction which is burdened by maturity + for tx in &block.txdata[1 ..] { + for (vout, output) in tx.output.iter().enumerate() { + if output.script_pubkey == main_addr.script_pubkey() { + outputs.push(Output(SpendableOutput { + output: output.clone(), + outpoint: OutPoint { txid: tx.txid(), vout: u32::try_from(vout).unwrap() }, + })); + } + } + } + + Ok(outputs) + } + + async fn prepare_send( + &self, + keys: ThresholdKeys, + transcript: RecommendedTranscript, + _: usize, + mut inputs: Vec, + payments: &[(Address, u64)], + change: Option, + fee: Fee, + ) -> Result { + Ok(SignableTransaction { + keys, + transcript, + actual: BSignableTransaction::new( + inputs.drain(..).map(|input| input.0).collect(), + payments, + // TODO: Diversify to a proper change address + change.map(|change| self.address(change)), + fee.0, + ) + .ok_or(CoinError::NotEnoughFunds)?, + }) + } + + async fn attempt_send( + &self, + transaction: Self::SignableTransaction, + ) -> Result { + transaction + .actual + .clone() + .multisig(transaction.keys.clone(), transaction.transcript.clone()) + .await + .map_err(|_| CoinError::ConnectionError) + } + + async fn publish_transaction(&self, tx: &Self::Transaction) -> Result, CoinError> { + Ok(self.rpc.send_raw_transaction(tx).await.unwrap().to_vec()) + } + + #[cfg(test)] + async fn get_fee(&self) -> Self::Fee { + Fee(1) + } + + #[cfg(test)] + async fn mine_block(&self) { + self + .rpc + .rpc_call::>( + "generatetoaddress", + serde_json::json!([ + 1, + Address::p2sh(&Script::new(), Network::Regtest).unwrap().to_string() + ]), + ) + .await + .unwrap(); + } + + #[cfg(test)] + async fn test_send(&self, address: Self::Address) { + let secret_key = SecretKey::new(&mut rand_core::OsRng); + let private_key = PrivateKey::new(secret_key, Network::Regtest); + let public_key = PublicKey::from_private_key(SECP256K1, &private_key); + let main_addr = Address::p2pkh(&public_key, Network::Regtest); + + let new_block = self.get_latest_block_number().await.unwrap() + 1; + self + .rpc + .rpc_call::>("generatetoaddress", serde_json::json!([1, main_addr])) + .await + .unwrap(); + + for _ in 0 .. 100 { + self.mine_block().await; + } + + // TODO: Consider grabbing bdk as a dev dependency + let tx = self.get_block(new_block).await.unwrap().txdata.swap_remove(0); + let mut tx = Transaction { + version: 2, + lock_time: PackedLockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { txid: tx.txid(), vout: 0 }, + script_sig: Script::default(), + sequence: Sequence(u32::MAX), + witness: Witness::default(), + }], + output: vec![TxOut { + value: tx.output[0].value - 10000, + script_pubkey: address.script_pubkey(), + }], + }; + + let mut der = SECP256K1 + .sign_ecdsa_low_r( + &Message::from( + tx.signature_hash(0, &main_addr.script_pubkey(), EcdsaSighashType::All.to_u32()) + .as_hash(), + ), + &private_key.inner, + ) + .serialize_der() + .to_vec(); + der.push(1); + tx.input[0].script_sig = Builder::new().push_slice(&der).push_key(&public_key).into_script(); + + self.rpc.send_raw_transaction(&tx).await.unwrap(); + for _ in 0 .. Self::CONFIRMATIONS { + self.mine_block().await; + } + } +} diff --git a/processor/src/coin/mod.rs b/processor/src/coin/mod.rs index b5fb9065..aa742a98 100644 --- a/processor/src/coin/mod.rs +++ b/processor/src/coin/mod.rs @@ -10,6 +10,9 @@ use frost::{ sign::PreprocessMachine, }; +pub mod bitcoin; +pub use self::bitcoin::Bitcoin; + pub mod monero; pub use self::monero::Monero; @@ -17,6 +20,8 @@ pub use self::monero::Monero; pub enum CoinError { #[error("failed to connect to coin daemon")] ConnectionError, + #[error("not enough funds")] + NotEnoughFunds, } pub trait Block: Sized + Clone { @@ -62,6 +67,8 @@ pub trait Coin { const MAX_INPUTS: usize; const MAX_OUTPUTS: usize; // TODO: Decide if this includes change or not + fn tweak_keys(&self, key: &mut ThresholdKeys); + /// Address for the given group key to receive external coins to. // Doesn't have to take self, enables some level of caching which is pleasant fn address(&self, key: ::G) -> Self::Address; @@ -93,10 +100,7 @@ pub trait Coin { transaction: Self::SignableTransaction, ) -> Result; - async fn publish_transaction( - &self, - tx: &Self::Transaction, - ) -> Result<(Vec, Vec<::Id>), CoinError>; + async fn publish_transaction(&self, tx: &Self::Transaction) -> Result, CoinError>; #[cfg(test)] async fn get_fee(&self) -> Self::Fee; diff --git a/processor/src/coin/monero.rs b/processor/src/coin/monero.rs index 5f364f7e..67329a74 100644 --- a/processor/src/coin/monero.rs +++ b/processor/src/coin/monero.rs @@ -162,6 +162,9 @@ impl Coin for Monero { const MAX_INPUTS: usize = 128; const MAX_OUTPUTS: usize = 16; + // Monero doesn't require/benefit from tweaking + fn tweak_keys(&self, _: &mut ThresholdKeys) {} + fn address(&self, key: dfg::EdwardsPoint) -> Self::Address { self.address_internal(key, EXTERNAL_SUBADDRESS) } @@ -258,12 +261,9 @@ impl Coin for Monero { .map_err(|_| CoinError::ConnectionError) } - async fn publish_transaction( - &self, - tx: &Self::Transaction, - ) -> Result<(Vec, Vec<::Id>), CoinError> { + async fn publish_transaction(&self, tx: &Self::Transaction) -> Result, CoinError> { self.rpc.publish_transaction(tx).await.map_err(|_| CoinError::ConnectionError)?; - Ok((tx.hash().to_vec(), tx.prefix.outputs.iter().map(|output| output.key.to_bytes()).collect())) + Ok(tx.hash().to_vec()) } #[cfg(test)] diff --git a/processor/src/tests/bitcoin.rs b/processor/src/tests/bitcoin.rs new file mode 100644 index 00000000..dcf3aeed --- /dev/null +++ b/processor/src/tests/bitcoin.rs @@ -0,0 +1,12 @@ +use crate::{ + coin::{Coin, Bitcoin}, + tests::test_send, +}; + +#[tokio::test] +async fn bitcoin() { + let bitcoin = Bitcoin::new("http://serai:seraidex@127.0.0.1:18443".to_string()).await; + bitcoin.fresh_chain().await; + let fee = bitcoin.get_fee().await; + test_send(bitcoin, fee).await; +} diff --git a/processor/src/tests/mod.rs b/processor/src/tests/mod.rs index e8abeade..4f07d090 100644 --- a/processor/src/tests/mod.rs +++ b/processor/src/tests/mod.rs @@ -1,4 +1,5 @@ mod send; pub(crate) use send::test_send; +mod bitcoin; mod monero; diff --git a/processor/src/tests/send.rs b/processor/src/tests/send.rs index 3f0a5b55..066bed55 100644 --- a/processor/src/tests/send.rs +++ b/processor/src/tests/send.rs @@ -94,7 +94,7 @@ pub async fn test_send(coin: C, fee: C::Fee) { let latest = coin.get_latest_block_number().await.unwrap(); wallet.acknowledge_block(1, latest - (C::CONFIRMATIONS - 1)); let signable = wallet - .prepare_sends(1, vec![(wallet.address(), 10000000000)], fee) + .prepare_sends(1, vec![(wallet.address(), 100000000)], fee) .await .unwrap() .1 @@ -102,5 +102,5 @@ pub async fn test_send(coin: C, fee: C::Fee) { futures.push(wallet.attempt_send(network, signable)); } - println!("{:?}", hex::encode(futures::future::join_all(futures).await.swap_remove(0).unwrap().0)); + println!("{:?}", hex::encode(futures::future::join_all(futures).await.swap_remove(0).unwrap())); } diff --git a/processor/src/wallet.rs b/processor/src/wallet.rs index 340bccea..73a90bb9 100644 --- a/processor/src/wallet.rs +++ b/processor/src/wallet.rs @@ -225,7 +225,10 @@ impl Wallet { } pub fn add_keys(&mut self, keys: &WalletKeys) { - self.pending.push((self.acknowledged_block(keys.creation_block), keys.bind(C::ID))); + let creation_block = keys.creation_block; + let mut keys = keys.bind(C::ID); + self.coin.tweak_keys(&mut keys); + self.pending.push((self.acknowledged_block(creation_block), keys)); } pub fn address(&self) -> C::Address { @@ -262,8 +265,7 @@ impl Wallet { .coin .get_outputs(&block, keys.group_key()) .await? - .iter() - .cloned() + .drain(..) .filter(|output| self.db.add_output(output)), ); } @@ -282,7 +284,7 @@ impl Wallet { pub async fn prepare_sends( &mut self, canonical: usize, - payments: Vec<(C::Address, u64)>, + mut payments: Vec<(C::Address, u64)>, fee: C::Fee, ) -> Result<(Vec<(C::Address, u64)>, Vec), CoinError> { if payments.is_empty() { @@ -296,7 +298,6 @@ impl Wallet { // As each payment re-appears, let mut payments = schedule[payment] where the only input is // the source payment // let (mut payments, schedule) = schedule(payments); - let mut payments = payments; let mut txs = vec![]; for (keys, outputs) in self.keys.iter_mut() { @@ -342,7 +343,7 @@ impl Wallet { &mut self, network: &mut N, prepared: C::SignableTransaction, - ) -> Result<(Vec, Vec<::Id>), SignError> { + ) -> Result, SignError> { let attempt = self.coin.attempt_send(prepared).await.map_err(SignError::CoinError)?; let (attempt, commitments) = attempt.preprocess(&mut OsRng);