diff --git a/Cargo.lock b/Cargo.lock index 743918786..6af5a2094 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ name = "alpen-express-btcio" version = "0.1.0" dependencies = [ + "alpen-express-common", "alpen-express-db", "alpen-express-primitives", "alpen-express-rocksdb", @@ -678,6 +679,8 @@ dependencies = [ "async-trait", "base64 0.22.1", "bitcoin", + "bitcoind", + "bitcoind-json-rpc-types", "bytes", "express-storage", "express-tasks", @@ -918,7 +921,6 @@ dependencies = [ "hex", "jsonrpsee", "parking_lot 0.12.3", - "reqwest 0.12.5", "reth-ipc", "reth-primitives", "reth-rpc-api", @@ -1371,8 +1373,8 @@ dependencies = [ "memchr", "pin-project-lite", "tokio", - "zstd", - "zstd-safe", + "zstd 0.13.2", + "zstd-safe 7.2.0", ] [[package]] @@ -1724,6 +1726,60 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoincore-rpc" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedd23ae0fd321affb4bbbc36126c6f49a32818dc6b979395d24da8c9d4e80ee" +dependencies = [ + "bitcoincore-rpc-json", + "jsonrpc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "bitcoincore-rpc-json" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8909583c5fab98508e80ef73e5592a651c954993dc6b7739963257d19f0e71a" +dependencies = [ + "bitcoin", + "serde", + "serde_json", +] + +[[package]] +name = "bitcoind" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee5cf6a9903ff9cc808494c1232b0e9f6eef6600913d0d69fe1cb5c428f25b9" +dependencies = [ + "anyhow", + "bitcoin_hashes", + "bitcoincore-rpc", + "flate2", + "log", + "minreq", + "tar", + "tempfile", + "which 4.4.2", + "zip 0.6.6", +] + +[[package]] +name = "bitcoind-json-rpc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80270bb74df085641b0acab8ee00c42eb531cb7ed869bfb0d9ed37f7fb23c230" +dependencies = [ + "bitcoin", + "bitcoin-internals", + "serde", + "serde_json", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1778,7 +1834,7 @@ checksum = "23285ad32269793932e830392f2fe2f83e26488fd3ec778883a93c8323735780" dependencies = [ "arrayref", "arrayvec", - "constant_time_eq", + "constant_time_eq 0.3.0", ] [[package]] @@ -1791,7 +1847,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.0", "rayon-core", ] @@ -2149,6 +2205,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + [[package]] name = "bzip2-sys" version = "0.1.11+1.0.8" @@ -2526,6 +2592,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "constant_time_eq" version = "0.3.0" @@ -3872,6 +3944,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf401df4a4e3872c4fe8151134cf483738e74b67fc934d6532c882b3d24a4550" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -5144,6 +5228,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpc" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf" +dependencies = [ + "base64 0.13.1", + "minreq", + "serde", + "serde_json", +] + [[package]] name = "jsonrpsee" version = "0.23.2" @@ -5633,6 +5729,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.6.0", "libc", + "redox_syscall 0.5.2", ] [[package]] @@ -5963,6 +6060,21 @@ dependencies = [ "adler", ] +[[package]] +name = "minreq" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763d142cdff44aaadd9268bebddb156ef6c65a0e13486bb81673cf2d8739f9b0" +dependencies = [ + "log", + "once_cell", + "rustls 0.21.12", + "rustls-webpki 0.101.7", + "serde", + "serde_json", + "webpki-roots 0.25.4", +] + [[package]] name = "mio" version = "0.8.11" @@ -6876,6 +6988,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pasta_curves" version = "0.4.1" @@ -6919,6 +7042,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", + "hmac 0.12.1", + "password-hash", + "sha2 0.10.8", ] [[package]] @@ -8946,7 +9072,7 @@ dependencies = [ "sucds", "thiserror", "tracing", - "zstd", + "zstd 0.13.2", ] [[package]] @@ -9229,7 +9355,7 @@ dependencies = [ "serde", "tempfile", "thiserror-no-std", - "zstd", + "zstd 0.13.2", ] [[package]] @@ -10106,7 +10232,7 @@ dependencies = [ "risc0-zkp", "sha2 0.10.8", "tracing", - "zip", + "zip 2.1.3", ] [[package]] @@ -11840,6 +11966,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb797dad5fb5b76fcf519e702f4a589483b5ef06567f160c392832c1f5e44909" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.10.1" @@ -13058,6 +13195,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -13272,6 +13418,17 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" +dependencies = [ + "libc", + "linux-raw-sys", + "rustix", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -13388,6 +13545,26 @@ dependencies = [ "syn 2.0.71", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes 0.8.4", + "byteorder", + "bzip2", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac 0.12.1", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd 0.11.2+zstd.1.5.2", +] + [[package]] name = "zip" version = "2.1.3" @@ -13467,13 +13644,32 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe 5.0.2+zstd.1.5.2", +] + [[package]] name = "zstd" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" dependencies = [ - "zstd-safe", + "zstd-safe 7.2.0", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2b4c06bb9..a2de058b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,8 @@ async-trait = "0.1.80" base64 = "0.22.1" bincode = "1.3.3" bitcoin = { version = "0.32.1", features = ["serde"] } +bitcoind = { version = "0.36.0", features = ["26_0"] } +bitcoind-json-rpc-types = "0.3.0" borsh = { version = "1.5.0", features = ["derive"] } bytes = { version = "1.6.0" } digest = "0.10" @@ -90,7 +92,7 @@ hex = { version = "0.4", features = ["serde"] } http = "1.0.0" hyper = "0.14.25" jmt = "0.10.0" -jsonrpsee = { version = "0.23" } +jsonrpsee = "0.23" jsonrpsee-types = "0.23" lru = "0.12" mockall = "0.11" diff --git a/crates/btcio/Cargo.toml b/crates/btcio/Cargo.toml index dd8774dc2..1f51e3971 100644 --- a/crates/btcio/Cargo.toml +++ b/crates/btcio/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } bitcoin = { workspace = true } +bitcoind-json-rpc-types = { workspace = true } bytes = { workspace = true } hex = { workspace = true } rand = { workspace = true } @@ -29,7 +30,9 @@ tokio = { workspace = true } tracing = { workspace = true } [dev-dependencies] +alpen-express-common = { workspace = true } alpen-express-rocksdb = { workspace = true, features = ["test_utils"] } alpen-test-utils = { workspace = true } arbitrary = { workspace = true } +bitcoind = { workspace = true } mockall = { workspace = true } diff --git a/crates/btcio/src/broadcaster/handle.rs b/crates/btcio/src/broadcaster/handle.rs index 27b518927..fede059fa 100644 --- a/crates/btcio/src/broadcaster/handle.rs +++ b/crates/btcio/src/broadcaster/handle.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use tracing::*; use super::task::broadcaster_task; -use crate::rpc::traits::{L1Client, SeqL1Client}; +use crate::rpc::traits::{BitcoinBroadcaster, BitcoinReader, BitcoinSigner, BitcoinWallet}; pub struct L1BroadcastHandle { ops: Arc, @@ -49,7 +49,7 @@ impl L1BroadcastHandle { pub fn spawn_broadcaster_task( executor: &TaskExecutor, - l1_rpc_client: Arc, + l1_rpc_client: Arc, bcast_ops: Arc, ) -> L1BroadcastHandle { let (bcast_tx, bcast_rx) = mpsc::channel::<(u64, L1TxEntry)>(64); diff --git a/crates/btcio/src/broadcaster/state.rs b/crates/btcio/src/broadcaster/state.rs index 906aebf8d..671b0ed1a 100644 --- a/crates/btcio/src/broadcaster/state.rs +++ b/crates/btcio/src/broadcaster/state.rs @@ -78,7 +78,7 @@ async fn filter_unfinalized_from_db( let txid = ops.get_txid_async(idx).await?.map(Txid::from); debug!(?idx, ?txid, ?status, "TxEntry"); match txentry.status { - L1TxStatus::Finalized { height: _ } | L1TxStatus::Excluded { reason: _ } => {} + L1TxStatus::Finalized { confirmations: _ } | L1TxStatus::Excluded { reason: _ } => {} _ => { unfinalized_entries.insert(idx, txentry); } @@ -94,10 +94,11 @@ mod test { broadcaster::db::{BroadcastDatabase, BroadcastDb}, test_utils::get_rocksdb_tmp_instance, }; - use alpen_test_utils::ArbitraryGenerator; + use bitcoin::{consensus, Transaction}; use express_storage::ops::l1tx_broadcast::Context; use super::*; + use crate::test_utils::SOME_TX; fn get_db() -> Arc { let (db, dbops) = get_rocksdb_tmp_instance().unwrap(); @@ -113,18 +114,18 @@ mod test { } fn gen_entry_with_status(st: L1TxStatus) -> L1TxEntry { - let arb = ArbitraryGenerator::new(); - let mut entry: L1TxEntry = arb.generate(); + let tx: Transaction = consensus::encode::deserialize_hex(SOME_TX).unwrap(); + let mut entry = L1TxEntry::from_tx(&tx); entry.status = st; entry } fn gen_confirmed_entry() -> L1TxEntry { - gen_entry_with_status(L1TxStatus::Confirmed { height: 1 }) + gen_entry_with_status(L1TxStatus::Confirmed { confirmations: 1 }) } fn gen_finalized_entry() -> L1TxEntry { - gen_entry_with_status(L1TxStatus::Finalized { height: 1 }) + gen_entry_with_status(L1TxStatus::Finalized { confirmations: 1 }) } fn gen_unpublished_entry() -> L1TxEntry { diff --git a/crates/btcio/src/broadcaster/task.rs b/crates/btcio/src/broadcaster/task.rs index 24363ed66..9f1adee12 100644 --- a/crates/btcio/src/broadcaster/task.rs +++ b/crates/btcio/src/broadcaster/task.rs @@ -1,27 +1,25 @@ use std::{collections::BTreeMap, sync::Arc, time::Duration}; use alpen_express_db::types::{ExcludeReason, L1TxEntry, L1TxStatus}; -use bitcoin::{hashes::Hash, Txid}; +use bitcoin::{hashes::Hash, Transaction, Txid}; use express_storage::{ops::l1tx_broadcast, BroadcastDbOps}; use tokio::sync::mpsc::Receiver; use tracing::*; -use super::error::BroadcasterResult; use crate::{ - broadcaster::{error::BroadcasterError, state::BroadcasterState}, - rpc::{ - traits::{L1Client, SeqL1Client}, - ClientError, + broadcaster::{ + error::{BroadcasterError, BroadcasterResult}, + state::BroadcasterState, }, + rpc::traits::{BitcoinBroadcaster, BitcoinReader, BitcoinSigner, BitcoinWallet}, }; -// TODO: make these configurable, get from config -const BROADCAST_POLL_INTERVAL: u64 = 1000; // millis const FINALITY_DEPTH: u64 = 6; +const BROADCAST_POLL_INTERVAL: u64 = 1_000; // millis /// Broadcasts the next blob to be sent pub async fn broadcaster_task( - rpc_client: Arc, + rpc_client: Arc, ops: Arc, mut entry_receiver: Receiver<(u64, L1TxEntry)>, ) -> BroadcasterResult<()> { @@ -69,7 +67,7 @@ pub async fn broadcaster_task( async fn process_unfinalized_entries( unfinalized_entries: &BTreeMap, ops: Arc, - rpc_client: &(impl SeqL1Client + L1Client), + rpc_client: &(impl BitcoinReader + BitcoinBroadcaster + BitcoinWallet + BitcoinSigner), ) -> BroadcasterResult<(BTreeMap, Vec)> { let mut to_remove = Vec::new(); let mut updated_entries = BTreeMap::new(); @@ -86,7 +84,7 @@ async fn process_unfinalized_entries( ops.update_tx_entry_async(*idx, new_txentry.clone()).await?; // Remove if finalized - if matches!(status, L1TxStatus::Finalized { height: _ }) + if matches!(status, L1TxStatus::Finalized { confirmations: _ }) || matches!(status, L1TxStatus::Excluded { reason: _ }) { to_remove.push(*idx); @@ -103,7 +101,7 @@ async fn process_unfinalized_entries( /// Takes in `[L1TxEntry]`, checks status and then either publishes or checks for confirmations and /// returns its updated status. Returns None if status is not changed async fn handle_entry( - rpc_client: &(impl SeqL1Client + L1Client), + rpc_client: &(impl BitcoinReader + BitcoinBroadcaster + BitcoinWallet + BitcoinSigner), txentry: &L1TxEntry, idx: u64, ops: &BroadcastDbOps, @@ -115,7 +113,9 @@ async fn handle_entry( match txentry.status { L1TxStatus::Unpublished => { // Try to publish - match send_tx(txentry.tx_raw(), rpc_client).await { + let tx = txentry.to_tx().expect("could not deserialize tx"); + trace!(%idx, ?tx, "Publishing tx"); + match send_tx(&tx, rpc_client).await { Ok(_) => { info!(%idx, %txid, "Successfully published tx"); Ok(Some(L1TxStatus::Published)) @@ -136,30 +136,30 @@ async fn handle_entry( } } } - L1TxStatus::Published | L1TxStatus::Confirmed { height: _ } => { + L1TxStatus::Published | L1TxStatus::Confirmed { confirmations: _ } => { // check for confirmations let txid = Txid::from_slice(txid.0.as_slice()) .map_err(|e| BroadcasterError::Other(e.to_string()))?; let txinfo = rpc_client - .get_transaction_info(txid) + .get_transaction(&txid) .await .map_err(|e| BroadcasterError::Other(e.to_string()))?; match txinfo.confirmations { - 0 if matches!(txentry.status, L1TxStatus::Confirmed { height: _ }) => { - // If the confirmations of a txn that is already confirmed is 0 then there is + 0 if matches!(txentry.status, L1TxStatus::Confirmed { confirmations: _ }) => { + // If the confirmations of a tx that is already confirmed is 0 then there is // something wrong, possibly a reorg, so just set it to unpublished Ok(Some(L1TxStatus::Unpublished)) } 0 => Ok(None), - c if c >= FINALITY_DEPTH => Ok(Some(L1TxStatus::Finalized { - height: txinfo.block_height(), + c if c >= (FINALITY_DEPTH) => Ok(Some(L1TxStatus::Finalized { + confirmations: txinfo.block_height(), })), _ => Ok(Some(L1TxStatus::Confirmed { - height: txinfo.block_height(), + confirmations: txinfo.block_height(), })), } } - L1TxStatus::Finalized { height: _ } => Ok(None), // Nothing to do for finalized tx + L1TxStatus::Finalized { confirmations: _ } => Ok(None), L1TxStatus::Excluded { reason: _ } => { // If a tx is excluded due to MissingInputsOrSpent then the downstream task like // writer/signer will be accountable for recreating the tx and asking to broadcast. @@ -175,16 +175,11 @@ enum PublishError { Other(String), } -async fn send_tx( - tx_raw: &[u8], - client: &(impl SeqL1Client + L1Client), -) -> Result<(), PublishError> { - match client.send_raw_transaction(tx_raw).await { - Ok(_) => Ok(()), - Err(ClientError::Server(-27, _)) => Ok(()), // Tx already included - Err(ClientError::Server(-25, _)) => Err(PublishError::MissingInputsOrSpent), - Err(e) => Err(PublishError::Other(e.to_string())), - } +async fn send_tx(tx: &Transaction, client: &impl BitcoinBroadcaster) -> Result { + client + .send_raw_transaction(tx) + .await + .map_err(|e| PublishError::Other(e.to_string())) } #[cfg(test)] @@ -194,11 +189,11 @@ mod test { broadcaster::db::{BroadcastDatabase, BroadcastDb}, test_utils::get_rocksdb_tmp_instance, }; - use alpen_test_utils::ArbitraryGenerator; + use bitcoin::consensus; use express_storage::ops::l1tx_broadcast::Context; use super::*; - use crate::test_utils::TestBitcoinClient; + use crate::test_utils::{TestBitcoinClient, SOME_TX}; fn get_db() -> Arc { let (db, dbops) = get_rocksdb_tmp_instance().unwrap(); @@ -214,8 +209,8 @@ mod test { } fn gen_entry_with_status(st: L1TxStatus) -> L1TxEntry { - let arb = ArbitraryGenerator::new(); - let mut entry: L1TxEntry = arb.generate(); + let tx: Transaction = consensus::encode::deserialize_hex(SOME_TX).unwrap(); + let mut entry = L1TxEntry::from_tx(&tx); entry.status = st; entry } @@ -276,7 +271,7 @@ mod test { assert_eq!( res, Some(L1TxStatus::Confirmed { - height: cl.included_height + confirmations: cl.included_height }), "Status should be confirmed if 0 < confirmations < finality_depth" ); @@ -291,7 +286,7 @@ mod test { assert_eq!( res, Some(L1TxStatus::Finalized { - height: cl.included_height + confirmations: cl.included_height }), "Status should be confirmed if confirmations >= finality_depth" ); @@ -300,7 +295,7 @@ mod test { #[tokio::test] async fn test_handle_confirmed_entry() { let ops = get_ops(); - let e = gen_entry_with_status(L1TxStatus::Confirmed { height: 1 }); + let e = gen_entry_with_status(L1TxStatus::Confirmed { confirmations: 1 }); // Add tx to db ops.insert_new_tx_entry_async([1; 32].into(), e.clone()) @@ -330,7 +325,7 @@ mod test { assert_eq!( res, Some(L1TxStatus::Confirmed { - height: cl.included_height + confirmations: cl.included_height }), "Status should be confirmed if 0 < confirmations < finality_depth" ); @@ -345,7 +340,7 @@ mod test { assert_eq!( res, Some(L1TxStatus::Finalized { - height: cl.included_height + confirmations: cl.included_height }), "Status should be confirmed if confirmations >= finality_depth" ); @@ -354,7 +349,7 @@ mod test { #[tokio::test] async fn test_handle_finalized_entry() { let ops = get_ops(); - let e = gen_entry_with_status(L1TxStatus::Finalized { height: 1 }); + let e = gen_entry_with_status(L1TxStatus::Finalized { confirmations: 1 }); // Add tx to db ops.insert_new_tx_entry_async([1; 32].into(), e.clone()) @@ -474,7 +469,7 @@ mod test { assert_eq!( new_entries.get(&i3).unwrap().status, L1TxStatus::Finalized { - height: cl.included_height + confirmations: cl.included_height }, "published tx should be finalized" ); diff --git a/crates/btcio/src/reader/query.rs b/crates/btcio/src/reader/query.rs index 58d8ff66a..17f992cba 100644 --- a/crates/btcio/src/reader/query.rs +++ b/crates/btcio/src/reader/query.rs @@ -10,12 +10,12 @@ use bitcoin::{Block, BlockHash}; use tokio::sync::mpsc; use tracing::*; -use super::{ - config::ReaderConfig, - messages::{BlockData, L1Event}, -}; use crate::{ - rpc::traits::L1Client, + reader::{ + config::ReaderConfig, + messages::{BlockData, L1Event}, + }, + rpc::traits::BitcoinReader, status::{apply_status_updates, L1StatusUpdate}, }; @@ -138,7 +138,7 @@ impl ReaderState { } pub async fn bitcoin_data_reader_task( - client: Arc, + client: Arc, event_tx: mpsc::Sender, target_next_block: u64, config: Arc, @@ -160,7 +160,7 @@ pub async fn bitcoin_data_reader_task( } async fn do_reader_task( - client: &impl L1Client, + client: &impl BitcoinReader, event_tx: &mpsc::Sender, target_next_block: u64, config: Arc, @@ -222,7 +222,7 @@ async fn do_reader_task( async fn init_reader_state( target_next_block: u64, lookback: usize, - client: &impl L1Client, + client: &impl BitcoinReader, ) -> anyhow::Result { // Init the reader state using the blockid we were given, fill in a few blocks back. debug!(%target_next_block, "initializing reader state"); @@ -252,7 +252,7 @@ async fn init_reader_state( /// Polls the chain to see if there's new blocks to look at, possibly reorging /// if there's a mixup and we have to go back. async fn poll_for_new_blocks( - client: &impl L1Client, + client: &impl BitcoinReader, event_tx: &mpsc::Sender, _config: &ReaderConfig, state: &mut ReaderState, @@ -261,7 +261,7 @@ async fn poll_for_new_blocks( let chain_info = client.get_blockchain_info().await?; status_updates.push(L1StatusUpdate::RpcConnected(true)); let client_height = chain_info.blocks; - let fresh_best_block = chain_info.bestblockhash(); + let fresh_best_block = chain_info.best_block_hash.parse::()?; status_updates.push(L1StatusUpdate::CurHeight(client_height)); status_updates.push(L1StatusUpdate::CurTip(fresh_best_block.to_string())); @@ -311,7 +311,7 @@ async fn poll_for_new_blocks( /// Finds the highest block index where we do agree with the node. If we never /// find one then we're really screwed. async fn find_pivot_block( - client: &impl L1Client, + client: &impl BitcoinReader, state: &ReaderState, ) -> anyhow::Result> { for (height, l1blkid) in state.iter_blocks_back() { @@ -332,7 +332,7 @@ async fn find_pivot_block( async fn fetch_and_process_block( height: u64, - client: &impl L1Client, + client: &impl BitcoinReader, event_tx: &mpsc::Sender, state: &mut ReaderState, status_updates: &mut Vec, diff --git a/crates/btcio/src/rpc/client.rs b/crates/btcio/src/rpc/client.rs index 9ae3ce892..3cfde4a57 100644 --- a/crates/btcio/src/rpc/client.rs +++ b/crates/btcio/src/rpc/client.rs @@ -1,540 +1,425 @@ -use std::{fmt::Display, str::FromStr, sync::atomic::AtomicU64, time::Duration}; +use std::{ + fmt, + sync::atomic::{AtomicUsize, Ordering}, +}; use async_trait::async_trait; use base64::{engine::general_purpose, Engine}; use bitcoin::{ - block::{Header, Version}, - consensus::{ - deserialize, - encode::{deserialize_hex, serialize_hex}, - }, - hash_types::TxMerkleNode, - hashes::Hash as _, - Address, Block, BlockHash, CompactTarget, Network, Transaction, Txid, + consensus::encode::serialize_hex, Address, Block, BlockHash, Network, Transaction, Txid, +}; +use bitcoind_json_rpc_types::v26::{ + GetBlockVerbosityZero, GetBlockchainInfo, GetNewAddress, SendToAddress, }; -use reqwest::{header::HeaderMap, StatusCode}; -use serde::{Deserialize, Serialize}; +use reqwest::{ + header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE}, + Client, +}; +use serde::{de, Deserialize, Serialize}; use serde_json::{ - json, to_value, + json, value::{RawValue, Value}, }; -use thiserror::Error; use tracing::*; -use super::{ - traits::{L1Client, SeqL1Client}, - types::{RPCTransactionInfo, RawUTXO, RpcBlockchainInfo}, +use super::traits::BitcoinWallet; +use crate::rpc::{ + error::{BitcoinRpcError, ClientError}, + traits::{BitcoinBroadcaster, BitcoinReader, BitcoinSigner}, + types::{GetTransaction, ListTransactions, ListUnspent, SignRawTransactionWithWallet}, }; -const MAX_RETRIES: u32 = 3; +/// This is an alias for the result type returned by the [`BitcoinClient`]. +pub type ClientResult = Result; -pub fn to_val(value: T) -> ClientResult +/// Custom implementation to convert a value to a `Value` type. +pub fn to_value(value: T) -> ClientResult where T: Serialize, { - to_value(value).map_err(|e| ClientError::Param(format!("Error creating value: {}", e))) -} - -// Represents a JSON-RPC error. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -pub struct RpcError { - pub code: i32, - pub message: String, + serde_json::to_value(value) + .map_err(|e| ClientError::Param(format!("Error creating value: {}", e))) } -impl Display for RpcError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "RPCError code {}: {}", self.code, self.message) - } +/// An `async` client for interacting with a `bitcoind` instance. +#[derive(Debug)] +pub struct BitcoinClient { + /// The URL of the `bitcoind` instance. + url: String, + /// The underlying `async` HTTP client. + client: Client, + /// The ID of the current request. + id: AtomicUsize, } -// Response is a struct that represents a response returned by the Bitcoin RPC -// It is generic over the type of the result field, which is usually a String in Bitcoin Core -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +/// Response returned by the `bitcoind` RPC server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] struct Response { pub result: Option, - pub error: Option, + pub error: Option, pub id: u64, } -// BitcoinClient is a struct that represents a connection to a Bitcoin RPC node -#[derive(Debug)] -pub struct BitcoinClient { - url: String, - client: reqwest::Client, - network: Network, - next_id: AtomicU64, -} - -#[derive(Debug, Error)] -pub enum ClientError { - #[error("Network: {0}")] - Network(String), - - #[error("RPC server returned error '{1}' (code {0})")] - Server(i32, String), - - #[error("Error parsing rpc response: {0}")] - Parse(String), - - #[error("Could not create RPC Param")] - Param(String), - - #[error("{0}")] - Body(String), - - #[error("Obtained failure status({0}): {1}")] - Status(StatusCode, String), - - #[error("Malformed Response: {0}")] - MalformedResponse(String), - - #[error("Could not connect: {0}")] - Connection(String), - - #[error("Timeout")] - Timeout, - - #[error("HttpRedirect: {0}")] - HttpRedirect(String), - - #[error("Could not build request: {0}")] - ReqBuilder(String), - - #[error("Max retries {0} exceeded")] - MaxRetriesExceeded(u32), - - #[error("Could not create request: {0}")] - Request(String), - - #[error("Network address: {0}")] - WrongNetworkAddress(Network), - - #[error("Could not sign")] - Signing(Vec), +impl BitcoinClient { + /// Creates a new [`BitcoinClient`] with the given URL, username, and password. + pub fn new(url: String, username: String, password: String) -> ClientResult { + if username.is_empty() || password.is_empty() { + return Err(ClientError::MissingUserPassword); + } - #[error("{0}")] - Other(String), -} + let user_pw = general_purpose::STANDARD.encode(format!("{username}:{password}")); + let authorization = format!("Basic {user_pw}") + .parse() + .map_err(|_| ClientError::Other("Error parsing header".to_string()))?; -impl From for ClientError { - fn from(value: serde_json::error::Error) -> Self { - Self::Parse(format!("Could not parse {}", value)) - } -} + let content_type = "application/json" + .parse() + .map_err(|_| ClientError::Other("Error parsing header".to_string()))?; + let headers = + HeaderMap::from_iter([(AUTHORIZATION, authorization), (CONTENT_TYPE, content_type)]); -type ClientResult = Result; + trace!(headers = ?headers); -impl BitcoinClient { - pub fn new(url: String, username: String, password: String, network: Network) -> Self { - let mut headers = HeaderMap::new(); - let mut user_pw = String::new(); - general_purpose::STANDARD.encode_string(format!("{}:{}", username, password), &mut user_pw); - - headers.insert( - "Authorization", - format!("Basic {}", user_pw) - .parse() - .expect("Failed to parse auth header!"), - ); - headers.insert( - "Content-Type", - "application/json" - .parse() - .expect("Failed to parse content type header!"), - ); - - let client = reqwest::Client::builder() + let client = Client::builder() .default_headers(headers) .build() - .expect("Failed to build client!"); + .map_err(|e| ClientError::Other(format!("Could not create client: {e}")))?; - Self { - url, - client, - network, - next_id: AtomicU64::new(0), - } - } + let id = AtomicUsize::new(0); + + trace!(url = %url, "Created bitcoin client"); - pub fn network(&self) -> Network { - self.network + Ok(Self { url, client, id }) } - fn next_id(&self) -> u64 { - self.next_id - .fetch_add(1, std::sync::atomic::Ordering::AcqRel) + fn next_id(&self) -> usize { + self.id.fetch_add(1, Ordering::AcqRel) } - async fn call( + async fn call( &self, method: &str, - params: &[serde_json::Value], + params: &[Value], ) -> ClientResult { - let mut retries = 0; - loop { - let id = self.next_id(); - let response = self - .client - .post(&self.url) - .json(&json!({ - "jsonrpc": "1.0", - "id": id, - "method": method, - "params": params - })) - .send() - .await; - - match response { - Ok(resp) => { - let data = resp - .json::>() - .await - .map_err(|e| ClientError::Parse(e.to_string()))?; - if let Some(err) = data.error { - return Err(ClientError::Server(err.code, err.message)); - } - return data - .result - .ok_or_else(|| ClientError::Other("Empty data received".to_string())); + trace!(method = %method, params = ?params, "Calling bitcoin client"); + + let id = self.next_id(); + + let response = self + .client + .post(&self.url) + .json(&json!({ + "jsonrpc": "1.0", + "id": id, + "method": method, + "params": params + })) + .send() + .await; + + match response { + Ok(resp) => { + let result = resp.json::>().await; + trace!(result = ?result, "Received response"); + let data = result.map_err(|e| ClientError::Parse(e.to_string()))?; + trace!(data = ?data); + if let Some(err) = data.error { + return Err(ClientError::Server(err.code, err.message)); } - Err(err) => { - warn!(err = %err, "Error calling bitcoin client"); - - if err.is_body() { - // Body error, unlikely to be recoverable by retrying - return Err(ClientError::Body(err.to_string())); - } else if err.is_status() { - // HTTP status error, not retryable - let e = match err.status() { - Some(code) => ClientError::Status(code, err.to_string()), - _ => ClientError::Other(err.to_string()), - }; - return Err(e); - } else if err.is_decode() { - // Error decoding the response, retry might not help - return Err(ClientError::MalformedResponse(err.to_string())); - } else if err.is_connect() { - // Connection error, retry might help - let e = ClientError::Connection(err.to_string()); - warn!(%e, "connection error, retrying..."); - } else if err.is_timeout() { - let e = ClientError::Timeout; - // Timeout error, retry might help - warn!(%e, "timeout error, retrying..."); - } else if err.is_request() { - // General request error, retry might help - let e = ClientError::Request(err.to_string()); - warn!(%e, "request error, retrying..."); - } else if err.is_builder() { - // Error building the request, unlikely to be recoverable - return Err(ClientError::ReqBuilder(err.to_string())); - } else if err.is_redirect() { - // Redirect error, not retryable - return Err(ClientError::HttpRedirect(err.to_string())); - } else { - // Unknown error, unlikely to be recoverable - return Err(ClientError::Other("Unknown error".to_string())); - } - - retries += 1; - if retries >= MAX_RETRIES { - return Err(ClientError::MaxRetriesExceeded(MAX_RETRIES)); - } - tokio::time::sleep(Duration::from_millis(1000)).await; + data.result + .ok_or_else(|| ClientError::Other("Empty data received".to_string())) + } + Err(err) => { + warn!(err = %err, "Error calling bitcoin client"); + + if err.is_body() { + Err(ClientError::Body(err.to_string())) + } else if err.is_status() { + let e = match err.status() { + Some(code) => ClientError::Status(code.to_string(), err.to_string()), + _ => ClientError::Other(err.to_string()), + }; + return Err(e); + } else if err.is_decode() { + return Err(ClientError::MalformedResponse(err.to_string())); + } else if err.is_connect() { + let e = ClientError::Connection(err.to_string()); + warn!(%e, "connection error, retrying..."); + Err(e) + } else if err.is_timeout() { + let e = ClientError::Timeout; + warn!(%e, "timeout error, retrying..."); + Err(e) + } else if err.is_request() { + let e = ClientError::Request(err.to_string()); + warn!(%e, "request error, retrying..."); + Err(e) + } else if err.is_builder() { + Err(ClientError::ReqBuilder(err.to_string())) + } else if err.is_redirect() { + Err(ClientError::HttpRedirect(err.to_string())) + } else { + Err(ClientError::Other("Unknown error".to_string())) } - }; - } - } - - // get_block_count returns the current block height - pub async fn get_block_count(&self) -> ClientResult { - self.call::("getblockcount", &[]).await - } - - // This returns [(txid, timestamp)] - pub async fn list_transactions(&self, confirmations: u32) -> ClientResult> { - let res = self - .call::("listtransactions", &[to_value(confirmations)?]) - .await?; - if let serde_json::Value::Array(array) = res { - Ok(array - .iter() - .map(|el| { - ( - serde_json::from_value::(el.get("txid").unwrap().clone()) - .unwrap() - .clone(), - serde_json::from_value::(el.get("time").unwrap().clone()).unwrap(), - ) - }) - .collect()) - } else { - Err(ClientError::MalformedResponse(res.to_string())) + } } } +} - // get_mempool_txids returns a list of txids in the current mempool - pub async fn get_mempool_txids(&self) -> ClientResult> { +#[async_trait] +impl BitcoinReader for BitcoinClient { + async fn estimate_smart_fee(&self, conf_target: u16) -> ClientResult { let result = self - .call::>("getrawmempool", &[]) + .call::>("estimatesmartfee", &[to_value(conf_target)?]) .await? .to_string(); - serde_json::from_str::>(&result) - .map_err(|e| ClientError::MalformedResponse(e.to_string())) - } + let result_map: Value = result.parse::()?; - // get_block returns the block at the given hash - pub async fn get_block(&self, hash: BlockHash) -> ClientResult { - let result = self - .call::>("getblock", &[to_value(hash.to_string())?, to_value(3)?]) - .await? - .to_string(); + let btc_vkb = result_map + .get("feerate") + .unwrap_or(&"0.00001".parse::().unwrap()) + .as_f64() + .unwrap(); - let full_block: serde_json::Value = serde_json::from_str(&result)?; - - let header: anyhow::Result
= (|| { - Ok(Header { - bits: CompactTarget::from_consensus(u32::from_str_radix( - full_block["bits"].as_str().unwrap(), - 16, - )?), - merkle_root: TxMerkleNode::from_str(full_block["merkleroot"].as_str().unwrap())?, - nonce: full_block["nonce"].as_u64().unwrap() as u32, - prev_blockhash: BlockHash::from_str( - full_block["previousblockhash"].as_str().unwrap(), - )?, - time: full_block["time"].as_u64().unwrap() as u32, - version: Version::from_consensus(full_block["version"].as_u64().unwrap() as i32), - }) - })(); - let header = header.map_err(|e| ClientError::Other(e.to_string()))?; - - let txdata = full_block["tx"].as_array().unwrap(); - - let txs: Vec = txdata - .iter() - .map(|tx| { - let tx_hex = tx["hex"].as_str().unwrap(); - deserialize(&hex::decode(tx_hex).unwrap()).unwrap() - // parse_hex_transaction(tx_hex).unwrap() // hex from rpc cannot be invalid - }) - .collect(); - - Ok(Block { - header, - txdata: txs, - }) + // convert to sat/vB and round up + Ok((btc_vkb * 100_000_000.0 / 1000.0) as u64) } - pub async fn list_since_block(&self, blockhash: String) -> ClientResult> { - let result = self - .call::>("listsinceblock", &[to_value(blockhash)?]) - .await? - .to_string(); + async fn get_block(&self, hash: &BlockHash) -> ClientResult { + let get_block = self + .call::("getblock", &[to_value(hash.to_string())?, to_value(0)?]) + .await + .expect("get_block failed"); + let block = get_block.block().expect("block failed"); + Ok(block) + } - let rawdata: serde_json::Value = serde_json::from_str(&result)?; - let rawdata = rawdata.get("transactions").unwrap(); - let rawtxns = rawdata.as_array().unwrap(); - let txids = rawtxns - .iter() - .map(|x| x.get("txid").unwrap().as_str().unwrap().to_string()) - .collect(); - Ok(txids) + async fn get_block_at(&self, height: u64) -> ClientResult { + let hash = self.get_block_hash(height).await?; + self.get_block(&hash).await } - // get_change_address returns a change address for the wallet of bitcoind - async fn get_change_address(&self) -> ClientResult
{ - let address_string = self.call::("getrawchangeaddress", &[]).await?; - let addr = Address::from_str(&address_string).and_then(|x| x.require_network(self.network)); - addr.map_err(|_| ClientError::WrongNetworkAddress(self.network)) + async fn get_block_count(&self) -> ClientResult { + self.call::("getblockcount", &[]).await } - pub async fn get_change_addresses(&self) -> ClientResult<[Address; 2]> { - let change_address = self.get_change_address().await?; - let change_address_2 = self.get_change_address().await?; + async fn get_block_hash(&self, height: u64) -> ClientResult { + self.call::("getblockhash", &[to_value(height)?]) + .await + } - Ok([change_address, change_address_2]) + async fn get_blockchain_info(&self) -> ClientResult { + self.call::("getblockchaininfo", &[]) + .await } - #[cfg(test)] - pub async fn send_to_address(&self, address: String, amt: u32) -> anyhow::Result { - if self.network == Network::Regtest { - let result = self - .call::>( - "sendtoaddress", - &[ - to_value(address)?, - to_value(amt)?, - // All the following items are needed to pass the fee-rate and fee-rate - // needs to be passed just in case the regtest - // chain cannot estimate fee rate due to - // insufficient blocks - to_value("")?, - to_value("")?, - to_value(true)?, - to_value(true)?, - to_value(>::None)?, - to_value("unset")?, - to_value(>::None)?, - to_value(1.1)?, // fee rate - ], - ) - .await; - Ok(result.unwrap().to_string()) - } else { - Err(anyhow::anyhow!( - "Cannot send_to_address on non-regtest network" - )) - } + async fn get_raw_mempool(&self) -> ClientResult> { + self.call::>("getrawmempool", &[]).await } - pub async fn list_wallets(&self) -> ClientResult> { - self.call::>("listwallets", &[]).await + + async fn network(&self) -> ClientResult { + Ok(self + .call::("getblockchaininfo", &[]) + .await? + .chain + .parse::() + .map_err(|e| ClientError::Parse(e.to_string()))?) } } #[async_trait] -impl L1Client for BitcoinClient { - async fn get_blockchain_info(&self) -> ClientResult { - let res = self - .call::("getblockchaininfo", &[]) - .await?; - Ok(res) +impl BitcoinBroadcaster for BitcoinClient { + async fn send_raw_transaction(&self, tx: &Transaction) -> ClientResult { + let txstr = serialize_hex(tx); + match self + .call::("sendrawtransaction", &[to_value(txstr)?]) + .await + { + Ok(txid) => Ok(txid), + Err(ClientError::Server(i, s)) => match i { + // Dealing with known and common errors + -27 => Ok(tx.compute_txid()), // Tx already in chain + -25 => Err(ClientError::MalformedResponse(s)), // unrecoverable + _ => Err(ClientError::Server(i, s)), + }, + Err(e) => Err(ClientError::Other(e.to_string())), + } } +} - // get_block_hash returns the block hash of the block at the given height - async fn get_block_hash(&self, height: u64) -> ClientResult { - let hash = self - .call::("getblockhash", &[to_value(height)?]) - .await?; - Ok( - BlockHash::from_str(&hash) - .map_err(|e| ClientError::MalformedResponse(e.to_string()))?, - ) +#[async_trait] +impl BitcoinWallet for BitcoinClient { + async fn get_new_address(&self) -> ClientResult
{ + let address_unchecked = self + .call::("getnewaddress", &[]) + .await? + .0 + .parse::>() + .map_err(|e| ClientError::Parse(e.to_string()))? + .assume_checked(); + Ok(address_unchecked) + } + async fn get_transaction(&self, txid: &Txid) -> ClientResult { + Ok(self + .call::("gettransaction", &[to_value(txid.to_string())?]) + .await?) } - async fn get_block_at(&self, height: u64) -> ClientResult { - let hash = self.get_block_hash(height).await?; - let block = self.get_block(hash).await?; - Ok(block) + async fn get_utxos(&self) -> ClientResult> { + let resp = self.call::>("listunspent", &[]).await?; + trace!(?resp, "Got UTXOs"); + Ok(resp) } - // send_raw_transaction sends a raw transaction to the network - async fn send_raw_transaction + Send>(&self, tx: T) -> ClientResult { - let txstr = hex::encode(tx); - let resp = self - .call::("sendrawtransaction", &[to_value(txstr)?]) - .await?; - - let hex = hex::decode(resp.clone()); - match hex { - Ok(hx) => { - if hx.len() != 32 { - return Err(ClientError::MalformedResponse(resp)); - } - let mut arr: [u8; 32] = [0; 32]; - arr.copy_from_slice(&hx); - Ok(Txid::from_slice(&arr) - .map_err(|e| ClientError::MalformedResponse(e.to_string()))?) - } - Err(e) => Err(ClientError::MalformedResponse(e.to_string())), - } + async fn list_transactions(&self, count: Option) -> ClientResult> { + self.call::>("listtransactions", &[to_value(count)?]) + .await } - async fn get_transaction_info(&self, txid: Txid) -> ClientResult { - Ok(self - .call::("gettransaction", &[to_val(txid.to_string())?]) - .await?) + async fn list_wallets(&self) -> ClientResult> { + self.call::>("listwallets", &[]).await } } #[async_trait] -impl SeqL1Client for BitcoinClient { - // get_utxos returns all unspent transaction outputs for the wallets of bitcoind - async fn get_utxos(&self) -> ClientResult> { - let utxos = self - .call::>("listunspent", &[to_value(0)?, to_value(9999999)?]) - .await?; - - if utxos.is_empty() { - return Err(ClientError::Other("No UTXOs found".to_string())); - } - - Ok(utxos) - } - - // estimate_smart_fee estimates the fee to confirm a transaction in the next block - async fn estimate_smart_fee(&self) -> ClientResult { - let result = self - .call::>("estimatesmartfee", &[to_value(1)?]) +impl BitcoinSigner for BitcoinClient { + async fn send_to_address(&self, address: &Address, amount: u64) -> ClientResult { + self.call::("sendtoaddress", &[to_value(address)?, to_value(amount)?]) .await? - .to_string(); - - let result_map: serde_json::Value = serde_json::from_str(&result)?; - - let btc_vkb = result_map - .get("feerate") - .unwrap_or(&serde_json::Value::from_str("0.00001").unwrap()) - .as_f64() - .unwrap(); + .txid() + .map_err(|e| ClientError::Parse(e.to_string())) + } - // convert to sat/vB and round up - Ok((btc_vkb * 100_000_000.0 / 1000.0).ceil() as u64) + async fn sign_raw_transaction_with_wallet( + &self, + tx: &Transaction, + ) -> ClientResult { + let tx_hex = serialize_hex(tx); + trace!(tx_hex = %tx_hex, "Signing transaction"); + self.call::( + "signrawtransactionwithwallet", + &[to_value(tx_hex)?], + ) + .await } +} - // sign_raw_transaction_with_wallet signs a raw transaction with the wallet of bitcoind - async fn sign_raw_transaction_with_wallet(&self, tx: Transaction) -> ClientResult { - #[derive(Serialize, Deserialize, Debug)] - struct SignError { - txid: String, - vout: u32, - witness: Vec, - #[serde(rename = "scriptSig")] - script_sig: String, - sequence: u32, - error: String, - } +#[cfg(test)] +mod test { + use alpen_express_common::logging; + use bitcoin::{consensus, hashes::Hash}; + use bitcoind::{bitcoincore_rpc::RpcApi, BitcoinD}; - #[derive(Serialize, Deserialize, Debug)] - struct SignRPCResponse { - hex: String, - complete: bool, - errors: Option>, - } + use super::*; - let txraw = serialize_hex(&tx); - let res = self - .call::("signrawtransactionwithwallet", &[to_value(txraw)?]) - .await?; + /// Get the authentication credentials for a given `bitcoind` instance. + fn get_auth(bitcoind: &BitcoinD) -> (String, String) { + let params = &bitcoind.params; + let cookie_values = params.get_cookie_values().unwrap().unwrap(); + (cookie_values.user, cookie_values.password) + } - match res.errors { - None => { - let hex = res.hex; - let tx = deserialize_hex(&hex).map_err(|e| ClientError::Parse(e.to_string()))?; - Ok(tx) - } - Some(ref errors) => { - warn!("Error while signing with wallet: {:?}", res.errors); - let errs = errors - .iter() - .map(|x| x.error.clone()) - .collect::>(); - - // TODO: This throws error even when a transaction is partially signed. There does - // not seem to be other way to distinguish partially signed error from other - // errors. So in future, we might need to handle that particular case where error - // message is "CHECK(MULTI)SIG failing with non-zero signature (possibly need more - // signatures)" - Err(ClientError::Signing(errs)) - } - } + /// Mine a number of blocks of a given size `count`, which may be specified to a given coinbase + /// `address`. + pub fn mine_blocks( + bitcoind: &BitcoinD, + count: usize, + address: Option
, + ) -> anyhow::Result> { + let coinbase_address = match address { + Some(address) => address, + None => bitcoind + .client + .get_new_address(None, None)? + .assume_checked(), + }; + let block_hashes = bitcoind + .client + .generate_to_address(count as _, &coinbase_address)?; + Ok(block_hashes) } - fn network(&self) -> Network { - self.network + #[tokio::test()] + async fn client_works() { + logging::init(); + let bitcoind = BitcoinD::from_downloaded().unwrap(); + let url = bitcoind.rpc_url(); + let (user, password) = get_auth(&bitcoind); + let client = BitcoinClient::new(url, user, password).unwrap(); + + // network + let got = client.network().await.unwrap(); + let expected = Network::Regtest; + + assert_eq!(expected, got); + // get_blockchain_info + let get_blockchain_info = client.get_blockchain_info().await.unwrap(); + assert_eq!(get_blockchain_info.blocks, 0); + + let blocks = mine_blocks(&bitcoind, 101, None).unwrap(); + + // get_block + let expected = blocks.last().unwrap(); + let got = client.get_block(expected).await.unwrap().block_hash(); + assert_eq!(*expected, got); + + // get_block_at + let target_height = blocks.len() as u64; + let expected = blocks.last().unwrap(); + let got = client + .get_block_at(target_height) + .await + .unwrap() + .block_hash(); + assert_eq!(*expected, got); + + // get_block_count + let expected = blocks.len() as u64; + let got = client.get_block_count().await.unwrap(); + assert_eq!(expected, got); + + // get_block_hash + let target_height = blocks.len() as u64; + let expected = blocks.last().unwrap(); + let got = client.get_block_hash(target_height).await.unwrap(); + assert_eq!(*expected, got); + + // get_new_address and send_to_address + let address = client.get_new_address().await.unwrap(); + let got = client.send_to_address(&address, 1).await.unwrap(); + assert_eq!(got.as_byte_array().len(), 32); // 32 bytes is a Txid + + // get_transaction + let txid = got; + let tx = client.get_transaction(&txid).await.unwrap().hex; + let got = client.send_raw_transaction(&tx).await.unwrap(); + let expected = txid; + assert_eq!(expected, got); + + // get_raw_mempool + let got = client.get_raw_mempool().await.unwrap(); + let expected = vec![txid]; + assert_eq!(expected, got); + + // estimate_smart_fee + let got = client.estimate_smart_fee(1).await.unwrap(); + let expected = 1; // 1 sat/vB + assert_eq!(expected, got); + + // sign_raw_transaction_with_wallet + let got = client.sign_raw_transaction_with_wallet(&tx).await.unwrap(); + assert!(got.complete); + assert!(consensus::encode::deserialize_hex::(&got.hex).is_ok()); + + // list_transactions + let got = client.list_transactions(None).await.unwrap(); + assert_eq!(got.len(), 10); + + // get_utxos + // let's mine one more block + mine_blocks(&bitcoind, 1, None).unwrap(); + let got = client.get_utxos().await.unwrap(); + assert_eq!(got.len(), 3); } } - -// TODO: Add functional tests diff --git a/crates/btcio/src/rpc/error.rs b/crates/btcio/src/rpc/error.rs new file mode 100644 index 000000000..7d1268d17 --- /dev/null +++ b/crates/btcio/src/rpc/error.rs @@ -0,0 +1,164 @@ +//! Error types for the RPC client. +use std::fmt; + +use bitcoin::Network; +use serde::{Deserialize, Serialize}; +use serde_json::Error as SerdeJsonError; +use thiserror::Error; + +/// The error type for errors produced in this library. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ClientError { + /// Network error, retry might help + #[error("Network: {0}")] + Network(String), + + /// Missing username or password for the RPC server + #[error("Missing username or password")] + MissingUserPassword, + + /// RPC server returned an error + /// + /// # Note + /// + /// These errors are ABSOLUTELY UNDOCUMENTED. + /// Check + /// + /// and good luck! + #[error("RPC server returned error '{1}' (code {0})")] + Server(i32, String), + + #[error("Error parsing rpc response: {0}")] + Parse(String), + + /// Error creating the RPC request, retry might help + #[error("Could not create RPC Param")] + Param(String), + + /// Body error, unlikely to be recoverable by retrying + #[error("{0}")] + Body(String), + + /// HTTP status error, not retryable + #[error("Obtained failure status({0}): {1}")] + Status(String, String), + + /// Error decoding the response, retry might not help + #[error("Malformed Response: {0}")] + MalformedResponse(String), + + /// Connection error, retry might help + #[error("Could not connect: {0}")] + Connection(String), + + /// Timeout error, retry might help + #[error("Timeout")] + Timeout, + + /// Redirect error, not retryable + #[error("HttpRedirect: {0}")] + HttpRedirect(String), + + /// Error building the request, unlikely to be recoverable + #[error("Could not build request: {0}")] + ReqBuilder(String), + + /// Maximum retries exceeded, not retryable + #[error("Max retries {0} exceeded")] + MaxRetriesExceeded(u32), + + /// General request error, retry might help + #[error("Could not create request: {0}")] + Request(String), + + /// Wrong network address + #[error("Network address: {0}")] + WrongNetworkAddress(Network), + + /// Server version is unexpected or incompatible + #[error(transparent)] + UnexpectedServerVersion(#[from] UnexpectedServerVersionError), + + /// Could not sign raw transaction + #[error(transparent)] + Sign(#[from] SignRawTransactionWithWalletError), + + /// Unknown error, unlikely to be recoverable + #[error("{0}")] + Other(String), +} + +impl From for ClientError { + fn from(value: SerdeJsonError) -> Self { + Self::Parse(format!("Could not parse {}", value)) + } +} + +/// `bitcoind` RPC server error. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BitcoinRpcError { + pub code: i32, + pub message: String, +} + +impl fmt::Display for BitcoinRpcError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "RPC error {}: {}", self.code, self.message) + } +} + +impl From for ClientError { + fn from(value: BitcoinRpcError) -> Self { + Self::Server(value.code, value.message) + } +} + +/// Error returned when signing a raw transaction with a wallet fails. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SignRawTransactionWithWalletError { + /// The transaction ID. + txid: String, + /// The index of the input. + vout: u32, + /// The script signature. + #[serde(rename = "scriptSig")] + script_sig: String, + /// The sequence number. + sequence: u32, + /// The error message. + error: String, +} + +impl fmt::Display for SignRawTransactionWithWalletError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "error signing raw transaction with wallet: {}", + self.error + ) + } +} + +/// Error returned when RPC client expects a different version than bitcoind reports. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UnexpectedServerVersionError { + /// Version from server. + pub got: usize, + /// Expected server version. + pub expected: Vec, +} + +impl fmt::Display for UnexpectedServerVersionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut expected = String::new(); + for version in &self.expected { + let v = format!(" {} ", version); + expected.push_str(&v); + } + write!( + f, + "unexpected bitcoind version, got: {} expected one of: {}", + self.got, expected + ) + } +} diff --git a/crates/btcio/src/rpc/mod.rs b/crates/btcio/src/rpc/mod.rs index c6b9c24a4..1e0a18047 100644 --- a/crates/btcio/src/rpc/mod.rs +++ b/crates/btcio/src/rpc/mod.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod error; pub mod traits; pub mod types; diff --git a/crates/btcio/src/rpc/traits.rs b/crates/btcio/src/rpc/traits.rs index 76798b4dd..c5d79d271 100644 --- a/crates/btcio/src/rpc/traits.rs +++ b/crates/btcio/src/rpc/traits.rs @@ -1,48 +1,158 @@ use async_trait::async_trait; -use bitcoin::{Block, BlockHash, Network, Transaction, Txid}; +use bitcoin::{Address, Block, BlockHash, Network, Transaction, Txid}; +use bitcoind_json_rpc_types::v26::GetBlockchainInfo; -use super::{ - types::{RPCTransactionInfo, RawUTXO, RpcBlockchainInfo}, - ClientError, +use crate::rpc::{ + client::ClientResult, + types::{GetTransaction, ListTransactions, ListUnspent, SignRawTransactionWithWallet}, }; +/// Basic functionality that any Bitcoin client that interacts with the +/// Bitcoin network should provide. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](tokio)'s +/// [`spawn_blocking`](tokio::task::spawn_blocking) or any other method. #[async_trait] -pub trait L1Client: Sync + Send + 'static { - /// Corresponds to `getblockchaininfo`. - async fn get_blockchain_info(&self) -> Result; +pub trait BitcoinReader: Sync + Send + 'static { + /// Estimates the approximate fee per kilobyte needed for a transaction + /// to begin confirmation within conf_target blocks if possible and return + /// the number of blocks for which the estimate is valid. + /// + /// # Parameters + /// + /// - `conf_target`: Confirmation target in blocks. + /// + /// # Note + /// + /// Uses virtual transaction size as defined in + /// [BIP 141](https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki) + /// (witness data is discounted). + /// + /// By default uses the estimate mode of `CONSERVATIVE` which is the + /// default in Bitcoin Core v27. + async fn estimate_smart_fee(&self, conf_target: u16) -> ClientResult; - /// Fetches the block at given height - async fn get_block_at(&self, height: u64) -> Result; + /// Gets a [`Block`] with the given hash. + async fn get_block(&self, hash: &BlockHash) -> ClientResult; - /// Fetches the block hash at given height - async fn get_block_hash(&self, height: u64) -> Result; + /// Gets a [`Block`] at given height. + async fn get_block_at(&self, height: u64) -> ClientResult; - /// Sends a raw transaction to the network - async fn send_raw_transaction + Send>(&self, tx: T) - -> Result; + /// Gets the height of the most-work fully-validated chain. + /// + /// # Note + /// + /// The genesis block has a height of 0. + async fn get_block_count(&self) -> ClientResult; - /// get number of confirmations for [`Txid`]. 0 confirmations means the tx is still in mempool - async fn get_transaction_info(&self, txid: Txid) -> Result; + /// Gets the [`BlockHash`] at given height. + async fn get_block_hash(&self, height: u64) -> ClientResult; - // TODO: add others as necessary + /// Gets various state info regarding blockchain processing. + async fn get_blockchain_info(&self) -> ClientResult; + + /// Gets all transaction ids in mempool. + async fn get_raw_mempool(&self) -> ClientResult>; + + /// Gets the underlying [`Network`] information. + async fn network(&self) -> ClientResult; +} + +/// Broadcasting functionality that any Bitcoin client that interacts with the +/// Bitcoin network should provide. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](https://tokio.rs)'s +/// [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) +/// or any other method. +#[async_trait] +pub trait BitcoinBroadcaster: Sync + Send + 'static { + /// Sends a raw transaction to the network. + /// + /// # Parameters + /// + /// - `tx`: The raw transaction to send. This should be a byte array containing the serialized + /// raw transaction data. + async fn send_raw_transaction(&self, tx: &Transaction) -> ClientResult; } +/// Wallet functionality that any Bitcoin client **without private keys** that +/// interacts with the Bitcoin network should provide. +/// +/// For signing transactions, see [`BitcoinSigner`]. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](https://tokio.rs)'s +/// [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) +/// or any other method. #[async_trait] -pub trait SeqL1Client: Sync + Send + 'static { - /// Get utxos - async fn get_utxos(&self) -> Result, ClientError>; +pub trait BitcoinWallet: Sync + Send + 'static { + /// Generates new address under own control for the underlying Bitcoin + /// client's wallet. + async fn get_new_address(&self) -> ClientResult
; + + /// Gets information related to a transaction. + /// + /// # Note + /// + /// This assumes that the transaction is present in the underlying Bitcoin + /// client's wallet. + async fn get_transaction(&self, txid: &Txid) -> ClientResult; - /// Estimate_smart_fee estimates the fee to confirm a transaction in the next block - async fn estimate_smart_fee(&self) -> Result; + /// Gets all Unspent Transaction Outputs (UTXOs) for the underlying Bitcoin + /// client's wallet. + async fn get_utxos(&self) -> ClientResult>; - /// Sign transaction with bitcoind wallet, returns signed transaction which might not be - /// complete if it requires multi-signature. Since this is for sequencer, we shouldn't care - /// about multi-signature. Later we can have a generic signing method to suit multisig cases. + /// Lists transactions in the underlying Bitcoin client's wallet. + /// + /// # Parameters + /// + /// - `count`: The number of transactions to list. If `None`, assumes a maximum of 10 + /// transactions. + async fn list_transactions(&self, count: Option) -> ClientResult>; + + /// Lists all wallets in the underlying Bitcoin client. + async fn list_wallets(&self) -> ClientResult>; +} + +/// Signing functionality that any Bitcoin client **with private keys** that +/// interacts with the Bitcoin network should provide. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](https://tokio.rs)'s +/// [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) +/// or any other method. +#[async_trait] +pub trait BitcoinSigner: Sync + Send + 'static { + /// Sends an amount to a given address. + async fn send_to_address(&self, address: &Address, amount: u64) -> ClientResult; + + /// Signs a transaction using the keys available in the underlying Bitcoin + /// client's wallet and returns a signed transaction. + /// + /// # Note + /// + /// The returned signed transaction might not be consensus-valid if it + /// requires additional signatures, such as in a multisignature context. async fn sign_raw_transaction_with_wallet( &self, - tx: Transaction, - ) -> Result; - - /// Network of the rpc client - fn network(&self) -> Network; + tx: &Transaction, + ) -> ClientResult; } diff --git a/crates/btcio/src/rpc/types.rs b/crates/btcio/src/rpc/types.rs index 41dfb6497..c38de512f 100644 --- a/crates/btcio/src/rpc/types.rs +++ b/crates/btcio/src/rpc/types.rs @@ -1,14 +1,60 @@ -#[cfg(test)] -use arbitrary::Arbitrary; -use bitcoin::BlockHash; -use serde::{de::Visitor, Deserialize, Deserializer, Serialize}; +use bitcoin::{ + absolute::Height, address::NetworkUnchecked, consensus, Address, Amount, BlockHash, + SignedAmount, Transaction, Txid, +}; +use bitcoind_json_rpc_types::v26::GetTransactionDetail; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, Serialize, +}; use tracing::*; -#[derive(Clone, Debug, Deserialize)] -#[cfg_attr(test, derive(Arbitrary))] -pub struct RPCTransactionInfo { - pub amount: f64, - pub fee: Option, +use crate::rpc::error::SignRawTransactionWithWalletError; + +/// The category of a transaction. +/// +/// This is one of the results of `listtransactions` RPC method. +/// +/// # Note +/// +/// This is a subset of the categories available in Bitcoin Core. +/// It also assumes that the transactions are present in the underlying Bitcoin +/// client's wallet. +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TransactionCategory { + /// Transactions sent. + Send, + /// Non-coinbase transactions received. + Receive, + /// Coinbase transactions received with more than 100 confirmations. + Generate, + /// Coinbase transactions received with 100 or less confirmations. + Immature, + /// Orphaned coinbase transactions received. + Orphan, +} + +/// Models the result of JSON-RPC method `listunspent`. +/// +/// # Note +/// +/// This assumes that the UTXOs are present in the underlying Bitcoin +/// client's wallet. +/// +/// Careful with the amount field. It is a [`SignedAmount`], hence can be negative. +/// Negative amounts for the [`TransactionCategory::Send`], and is positive +/// for all other categories. +/// +/// We can upstream this to [`bitcoind_json_rpc_types`]. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct GetTransaction { + /// The signed amount in BTC. + #[serde(deserialize_with = "deserialize_signed_bitcoin")] + pub amount: SignedAmount, + /// The signed fee in BTC. + #[serde(deserialize_with = "deserialize_signed_bitcoin")] + pub fee: SignedAmount, pub confirmations: u64, pub generated: Option, pub trusted: Option, @@ -16,7 +62,9 @@ pub struct RPCTransactionInfo { pub blockheight: Option, pub blockindex: Option, pub blocktime: Option, - pub txid: String, + /// The transaction id. + #[serde(deserialize_with = "deserialize_txid")] + pub txid: Txid, pub wtxid: String, pub walletconflicts: Vec, pub replaced_by_txid: Option, @@ -27,12 +75,13 @@ pub struct RPCTransactionInfo { pub timereceived: u64, #[serde(rename = "bip125-replaceable")] pub bip125_replaceable: String, - pub parent_descs: Option>, - pub hex: String, - // NOTE: "details", and "decoded" fields omitted as not used, add them when used + pub details: Vec, + /// The transaction itself. + #[serde(deserialize_with = "deserialize_tx")] + pub hex: Transaction, } -impl RPCTransactionInfo { +impl GetTransaction { pub fn block_height(&self) -> u64 { if self.confirmations == 0 { return 0; @@ -44,47 +93,135 @@ impl RPCTransactionInfo { } } -#[derive(Clone, Deserialize)] -#[cfg_attr(test, derive(Arbitrary))] -pub struct RawUTXO { - pub txid: String, +/// Models the result of JSON-RPC method `listunspent`. +/// +/// # Note +/// +/// This assumes that the UTXOs are present in the underlying Bitcoin +/// client's wallet. +/// +/// We can upstream this to [`bitcoind_json_rpc_types`]. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ListUnspent { + /// The transaction id. + #[serde(deserialize_with = "deserialize_txid")] + pub txid: Txid, + /// The vout value. pub vout: u32, - pub address: String, + /// The Bitcoin address. + #[serde(deserialize_with = "deserialize_address")] + pub address: Address, + // The associated label, if any. + pub label: Option, + /// The script pubkey. #[serde(rename = "scriptPubKey")] - pub script_pub_key: String, - #[serde(deserialize_with = "deserialize_satoshis")] - pub amount: u64, // satoshis - pub confirmations: u64, + pub script_pubkey: String, + /// The transaction output amount in BTC. + #[serde(deserialize_with = "deserialize_bitcoin")] + pub amount: Amount, + /// The number of confirmations. + pub confirmations: u32, + /// Whether we have the private keys to spend this output. pub spendable: bool, + /// Whether we know how to spend this output, ignoring the lack of keys. pub solvable: bool, + /// Whether this output is considered safe to spend. + /// Unconfirmed transactions from outside keys and unconfirmed replacement + /// transactions are considered unsafe and are not eligible for spending by + /// `fundrawtransaction` and `sendtoaddress`. + pub safe: bool, } -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(test, derive(Arbitrary))] -pub struct RpcBlockchainInfo { - pub blocks: u64, - pub headers: u64, - bestblockhash: String, - pub initialblockdownload: bool, - pub warnings: String, +/// Models the result of JSON-RPC method `listtransactions`. +/// +/// # Note +/// +/// This assumes that the transactions are present in the underlying Bitcoin +/// client's wallet. +/// +/// Careful with the amount field. It is a [`SignedAmount`], hence can be negative. +/// Negative amounts for the [`TransactionCategory::Send`], and is positive +/// for all other categories. +/// +/// We can upstream this to [`bitcoind_json_rpc_types`]. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct ListTransactions { + /// The Bitcoin address. + #[serde(deserialize_with = "deserialize_address")] + pub address: Address, + /// Category of the transaction. + category: TransactionCategory, + /// The signed amount in BTC. + #[serde(deserialize_with = "deserialize_signed_bitcoin")] + pub amount: SignedAmount, + /// The label associated with the address, if any. + pub label: Option, + /// The number of confirmations. + pub confirmations: u32, + pub trusted: Option, + pub generated: Option, + pub blockhash: Option, + pub blockheight: Option, + pub blockindex: Option, + pub blocktime: Option, + /// The transaction id. + #[serde(deserialize_with = "deserialize_txid")] + pub txid: Txid, } -impl RpcBlockchainInfo { - pub fn bestblockhash(&self) -> BlockHash { - self.bestblockhash - .parse::() - .expect("rpc: bad blockhash") +/// Models the result of JSON-RPC method `signrawtransactionwithwallet`. +/// +/// # Note +/// +/// This assumes that the transactions are present in the underlying Bitcoin +/// client's wallet. +/// +/// We can upstream this to [`bitcoind_json_rpc_types`]. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct SignRawTransactionWithWallet { + /// The Transaction ID. + pub hex: String, + /// If the transaction has a complete set of signatures. + pub complete: bool, + /// Errors, if any. + pub errors: Option>, +} + +/// Deserializes the amount in BTC into proper [`Amount`]s. +fn deserialize_bitcoin<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct SatVisitor; + + impl<'d> Visitor<'d> for SatVisitor { + type Value = Amount; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a float representation of btc values expected") + } + + fn visit_f64(self, v: f64) -> Result + where + E: de::Error, + { + trace!("Deserializing BTCs: {}", v); + let amount = Amount::from_btc(v).expect("Amount deserialization failed"); + Ok(amount) + } } + deserializer.deserialize_any(SatVisitor) } -fn deserialize_satoshis<'d, D>(deserializer: D) -> Result +/// Deserializes the *signed* amount in BTC into proper [`SignedAmount`]s. +fn deserialize_signed_bitcoin<'d, D>(deserializer: D) -> Result where D: Deserializer<'d>, { struct SatVisitor; impl<'d> Visitor<'d> for SatVisitor { - type Value = u64; + type Value = SignedAmount; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { write!(formatter, "a float representation of btc values expected") @@ -92,58 +229,151 @@ where fn visit_f64(self, v: f64) -> Result where - E: serde::de::Error, + E: de::Error, { - let sats = (v * 100_000_000.0).round() as u64; - Ok(sats) + trace!("Deserializing signed BTCs: {}", v); + let signed_amount = SignedAmount::from_btc(v).expect("Amount deserialization failed"); + Ok(signed_amount) } } deserializer.deserialize_any(SatVisitor) } -#[cfg(test)] -mod test { +/// Deserializes the transaction id string into proper [`Txid`]s. +fn deserialize_txid<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct TxidVisitor; - use serde::Deserialize; + impl<'d> Visitor<'d> for TxidVisitor { + type Value = Txid; - use super::*; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a transaction id string expected") + } - #[derive(Deserialize)] - struct TestStruct { - #[serde(deserialize_with = "deserialize_satoshis")] - value: u64, + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + trace!("Deserializing txid: {}", v); + let txid = + consensus::encode::deserialize_hex::(v).expect("Txid deserialization failed"); + Ok(txid) + } } + deserializer.deserialize_any(TxidVisitor) +} - #[test] - fn test_deserialize_satoshis() { - // Valid cases - let json_data = r#"{"value": 0.000042}"#; - let result: TestStruct = serde_json::from_str(json_data).unwrap(); - assert_eq!(result.value, 4200); - - let json_data = r#"{"value": 1.23456789}"#; - let result: TestStruct = serde_json::from_str(json_data).unwrap(); - assert_eq!(result.value, 123456789); - - let json_data = r#"{"value": 123.0}"#; - let result: TestStruct = serde_json::from_str(json_data).unwrap(); - assert_eq!(result.value, 12300000000); - - let json_data = r#"{"value": 123.45}"#; - let result: TestStruct = serde_json::from_str(json_data).unwrap(); - assert_eq!(result.value, 12345000000); - - // Invalid cases - let json_data = r#"{"value": 123}"#; - let result: Result = serde_json::from_str(json_data); - assert!(result.is_err()); - - let json_data = r#"{"value": "abc"}"#; - let result: Result = serde_json::from_str(json_data); - assert!(result.is_err()); - - let json_data = r#"{"value": "123.456.78"}"#; - let result: Result = serde_json::from_str(json_data); - assert!(result.is_err()); +/// Deserializes the transaction hex string into proper [`Transaction`]s. +fn deserialize_tx<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct TxVisitor; + + impl<'d> Visitor<'d> for TxVisitor { + type Value = Transaction; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a transaction hex string expected") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + trace!("Deserializing txid: {}", v); + let tx = consensus::encode::deserialize_hex::(v) + .expect("Tx deserialization failed"); + Ok(tx) + } + } + deserializer.deserialize_any(TxVisitor) +} + +/// Deserializes the address string into proper [`Address`]s. +/// +/// # Note +/// +/// The user is responsible for ensuring that the address is valid, +/// since this functions returns an [`Address`]. +fn deserialize_address<'d, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'d>, +{ + struct AddressVisitor; + impl<'d> Visitor<'d> for AddressVisitor { + type Value = Address; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a Bitcoin address string expected") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + trace!("Deserializing address: {}", v); + let address = v + .parse::>() + .expect("Address deserialization failed"); + Ok(address) + } + } + deserializer.deserialize_any(AddressVisitor) +} + +/// Deserializes the blockhash string into proper [`BlockHash`]s. +fn deserialize_blockhash<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct BlockHashVisitor; + + impl<'d> Visitor<'d> for BlockHashVisitor { + type Value = BlockHash; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a blockhash string expected") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + trace!("Deserializing blockhash: {}", v); + let blockhash = consensus::encode::deserialize_hex::(v) + .expect("BlockHash deserialization failed"); + Ok(blockhash) + } + } + deserializer.deserialize_any(BlockHashVisitor) +} + +/// Deserializes the height string into proper [`Height`]s. +fn deserialize_height<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct HeightVisitor; + + impl<'d> Visitor<'d> for HeightVisitor { + type Value = Height; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a height u32 string expected") + } + + fn visit_u32(self, v: u32) -> Result + where + E: de::Error, + { + trace!("Deserializing height: {}", v); + let height = Height::from_consensus(v).expect("Height deserialization failed"); + Ok(height) + } } + deserializer.deserialize_any(HeightVisitor) } diff --git a/crates/btcio/src/test_utils.rs b/crates/btcio/src/test_utils.rs index fd3be8cf5..5da48f829 100644 --- a/crates/btcio/src/test_utils.rs +++ b/crates/btcio/src/test_utils.rs @@ -1,17 +1,25 @@ -use alpen_test_utils::ArbitraryGenerator; +use std::collections::BTreeMap; + use async_trait::async_trait; -use bitcoin::{consensus::deserialize, hashes::Hash, Block, BlockHash, Network, Transaction, Txid}; +use bitcoin::{ + consensus::{self, deserialize}, + hashes::Hash, + Address, Amount, Block, BlockHash, Network, SignedAmount, Transaction, Txid, Work, +}; +use bitcoind_json_rpc_types::v26::GetBlockchainInfo; use crate::rpc::{ - traits::{L1Client, SeqL1Client}, - types::{RPCTransactionInfo, RawUTXO, RpcBlockchainInfo}, - ClientError, + traits::{BitcoinBroadcaster, BitcoinReader, BitcoinSigner, BitcoinWallet}, + types::{GetTransaction, ListTransactions, ListUnspent, SignRawTransactionWithWallet}, + ClientResult, }; +/// A test implementation of a Bitcoin client. +#[derive(Debug, Clone)] pub struct TestBitcoinClient { - /// Parameter that returns confirmed height for a given transaction + /// Confirmations of a given transaction. pub confs: u64, - /// Parameter that indicates which height a transaction is included in + /// Which height a transaction was included in. pub included_height: u64, } @@ -19,79 +27,173 @@ impl TestBitcoinClient { pub fn new(confs: u64) -> Self { Self { confs, - included_height: 100, // Use arbitrary value, make configurable as necessary + // Use arbitrary value, make configurable as necessary + included_height: 100, } } } const TEST_BLOCKSTR: &str = "000000207d862a78fcb02ab24ebd154a20b9992af6d2f0c94d3a67b94ad5a0009d577e70769f3ff7452ea5dd469d7d99f200d083d020f1585e4bd9f52e9d66b23891a9c6c4ea5e66ffff7f200000000001020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff04025f0200ffffffff02205fa01200000000160014d7340213b180c97bd55fedd7312b7e17389cf9bf0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000"; +/// A test transaction. +/// +/// # Note +/// +/// Taken from +/// [`rust-bitcoin` test](https://docs.rs/bitcoin/0.32.1/src/bitcoin/blockdata/transaction.rs.html#1638). +pub const SOME_TX: &str = "0100000001a15d57094aa7a21a28cb20b59aab8fc7d1149a3bdbcddba9c622e4f5f6a99ece010000006c493046022100f93bb0e7d8db7bd46e40132d1f8242026e045f03a0efe71bbb8e3f475e970d790221009337cd7f1f929f00cc6ff01f03729b069a7c21b59b1736ddfee5db5946c5da8c0121033b9b137ee87d5a812d6f506efdd37f0affa7ffc310711c06c7f3e097c9447c52ffffffff0100e1f505000000001976a9140389035a9225b3839e2bbf32d826a1e222031fd888ac00000000"; + #[async_trait] -impl L1Client for TestBitcoinClient { - async fn get_blockchain_info(&self) -> Result { - Ok(ArbitraryGenerator::new().generate()) +impl BitcoinReader for TestBitcoinClient { + async fn estimate_smart_fee(&self, _conf_target: u16) -> ClientResult { + Ok(3) } - async fn get_block_at(&self, _height: u64) -> Result { + async fn get_block(&self, _hash: &BlockHash) -> ClientResult { let block: Block = deserialize(&hex::decode(TEST_BLOCKSTR).unwrap()).unwrap(); Ok(block) } + async fn get_block_at(&self, _height: u64) -> ClientResult { + let block: Block = deserialize(&hex::decode(TEST_BLOCKSTR).unwrap()).unwrap(); + Ok(block) + } + + async fn get_block_count(&self) -> ClientResult { + Ok(100) + } + // get_block_hash returns the block hash of the block at the given height - async fn get_block_hash(&self, _h: u64) -> Result { + async fn get_block_hash(&self, _h: u64) -> ClientResult { let block: Block = deserialize(&hex::decode(TEST_BLOCKSTR).unwrap()).unwrap(); Ok(block.block_hash()) } - // send_raw_transaction sends a raw transaction to the network - async fn send_raw_transaction + Send>( - &self, - _tx: T, - ) -> Result { - Ok(Txid::from_slice(&[1u8; 32]).unwrap()) + async fn get_blockchain_info(&self) -> ClientResult { + Ok(GetBlockchainInfo { + chain: "regtest".to_string(), + blocks: 100, + headers: 100, + best_block_hash: BlockHash::all_zeros().to_string(), + difficulty: 1.0, + median_time: 10 * 60, + verification_progress: 1.0, + initial_block_download: false, + chain_work: Work::from_be_bytes([0; 32]).to_string(), + size_on_disk: 1_000_000, + pruned: false, + prune_height: None, + automatic_pruning: None, + prune_target_size: None, + softforks: BTreeMap::new(), + warnings: "".to_string(), + }) } - async fn get_transaction_info(&self, _txid: Txid) -> Result { - let mut txinfo: RPCTransactionInfo = ArbitraryGenerator::new().generate(); - txinfo.confirmations = self.confs; - txinfo.blockheight = Some(self.included_height); - Ok(txinfo) + async fn get_raw_mempool(&self) -> ClientResult> { + Ok(vec![]) + } + + async fn network(&self) -> ClientResult { + Ok(Network::Regtest) } } #[async_trait] -impl SeqL1Client for TestBitcoinClient { - // get_utxos returns all unspent transaction outputs for the wallets of bitcoind - async fn get_utxos(&self) -> Result, ClientError> { - // Generate enough utxos to cover for the costs later - let utxos: Vec<_> = (1..10) - .map(|_| ArbitraryGenerator::new().generate()) - .enumerate() - .map(|(i, x)| RawUTXO { - txid: hex::encode([i as u8; 32]), // need to do this otherwise random str is - // generated - amount: 100 * 100_000_000, - spendable: true, - solvable: true, - ..x - }) - .collect(); - Ok(utxos) +impl BitcoinBroadcaster for TestBitcoinClient { + // send_raw_transaction sends a raw transaction to the network + async fn send_raw_transaction(&self, _tx: &Transaction) -> ClientResult { + Ok(Txid::from_slice(&[1u8; 32]).unwrap()) } +} - async fn estimate_smart_fee(&self) -> Result { - Ok(3) +#[async_trait] +impl BitcoinWallet for TestBitcoinClient { + async fn get_new_address(&self) -> ClientResult
{ + // taken from https://bitcoin.stackexchange.com/q/91222 + let addr = "bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw" + .parse::>() + .unwrap() + .assume_checked(); + Ok(addr) } - /// sign transaction with bitcoind wallet - async fn sign_raw_transaction_with_wallet( + async fn get_transaction(&self, txid: &Txid) -> ClientResult { + let some_tx = consensus::encode::deserialize_hex(SOME_TX).unwrap(); + Ok(GetTransaction { + amount: SignedAmount::from_btc(100.0).unwrap(), + fee: SignedAmount::from_sat(1_000), + confirmations: self.confs, + generated: None, + trusted: None, + blockhash: None, + blockheight: Some(self.included_height), + blockindex: None, + blocktime: None, + txid: *txid, + wtxid: txid.to_string(), + walletconflicts: vec![], + replaced_by_txid: None, + replaces_txid: None, + comment: None, + to: None, + time: 0, + timereceived: 0, + bip125_replaceable: "false".to_string(), + details: vec![], + hex: some_tx, + }) + } + + async fn get_utxos(&self) -> ClientResult> { + // plenty of sats + (1..10) + .map(|i| { + Ok(ListUnspent { + txid: Txid::from_slice(&[i; 32]).unwrap(), + vout: 0, + address: "bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw" + .parse::>() + .unwrap(), + label: None, + script_pubkey: "foo".to_string(), + amount: Amount::from_btc(100.0).unwrap(), + confirmations: self.confs as u32, + spendable: true, + solvable: true, + safe: true, + }) + }) + .collect() + } + + async fn list_transactions( &self, - tx: Transaction, - ) -> Result { - Ok(tx) + _count: Option, + ) -> ClientResult> { + Ok(vec![]) } - fn network(&self) -> Network { - Network::Regtest + async fn list_wallets(&self) -> ClientResult> { + Ok(vec![]) + } +} + +#[async_trait] +impl BitcoinSigner for TestBitcoinClient { + async fn send_to_address(&self, _address: &Address, _amount: u64) -> ClientResult { + Ok(Txid::from_slice(&[1u8; 32]).unwrap()) + } + + async fn sign_raw_transaction_with_wallet( + &self, + tx: &Transaction, + ) -> ClientResult { + let tx_hex = consensus::encode::serialize_hex(tx); + Ok(SignRawTransactionWithWallet { + hex: tx_hex, + complete: true, + errors: None, + }) } } diff --git a/crates/btcio/src/writer/broadcast.rs b/crates/btcio/src/writer/broadcast.rs index f0a2cf724..e1aa35cb5 100644 --- a/crates/btcio/src/writer/broadcast.rs +++ b/crates/btcio/src/writer/broadcast.rs @@ -13,8 +13,8 @@ use tracing::*; use crate::{ rpc::{ - traits::{L1Client, SeqL1Client}, - ClientError, + traits::{BitcoinBroadcaster, BitcoinReader}, + ClientResult, }, status::{apply_status_updates, L1StatusUpdate}, writer::utils::{get_blob_by_idx, get_l1_tx}, @@ -26,7 +26,7 @@ const BROADCAST_POLL_INTERVAL: u64 = 1000; // millis /// Broadcasts the next blob to be sent pub async fn broadcaster_task( next_publish_blob_idx: u64, - rpc_client: Arc, + rpc_client: Arc, db: Arc, status_rx: Arc, ) -> anyhow::Result<()> { @@ -73,7 +73,13 @@ pub async fn broadcaster_task( .ok_or(anyhow!("Expected to find commit tx in db"))?; // Send - match send_commit_reveal_txs(commit_tx, reveal_tx, rpc_client.as_ref()).await { + match send_commit_reveal_txs( + commit_tx.to_vec(), + reveal_tx.to_vec(), + rpc_client.as_ref(), + ) + .await + { Ok(_) => { debug!("Successfully sent: {}", blobentry.reveal_txid.to_string()); blobentry.status = BlobL1Status::Published; @@ -90,16 +96,8 @@ pub async fn broadcaster_task( } curr_idx += 1; } - Err(SendError::MissingOrInvalidInput) => { - // Means need to Resign/Republish - blobentry.status = BlobL1Status::NeedsResign; - - db.sequencer_store() - .update_blob_by_idx(curr_idx, blobentry)?; - } - Err(SendError::Other(e)) => { + Err(e) => { warn!(%e, "Error sending !"); - // TODO: Maybe retry or return? } } } else { @@ -108,26 +106,14 @@ pub async fn broadcaster_task( } } -enum SendError { - MissingOrInvalidInput, - Other(String), -} - async fn send_commit_reveal_txs( - commit_tx_raw: Vec, - reveal_tx_raw: Vec, - client: &(impl SeqL1Client + L1Client), -) -> Result<(), SendError> { - send_tx(commit_tx_raw, client).await?; - send_tx(reveal_tx_raw, client).await?; + commit_tx: Vec, + reveal_tx: Vec, + client: &(impl BitcoinBroadcaster + BitcoinReader), +) -> ClientResult<()> { + let commit_tx = deserialize(&commit_tx).expect("Failed to deserialize commit tx"); + let reveal_tx = deserialize(&reveal_tx).expect("Failed to deserialize reveal tx"); + client.send_raw_transaction(&commit_tx).await?; + client.send_raw_transaction(&reveal_tx).await?; Ok(()) } - -async fn send_tx(tx_raw: Vec, client: &(impl SeqL1Client + L1Client)) -> Result<(), SendError> { - match client.send_raw_transaction(tx_raw).await { - Ok(_) => Ok(()), - Err(ClientError::Server(-27, _)) => Ok(()), // Tx already in chain - Err(ClientError::Server(-25, _)) => Err(SendError::MissingOrInvalidInput), - Err(e) => Err(SendError::Other(e.to_string())), - } -} diff --git a/crates/btcio/src/writer/builder.rs b/crates/btcio/src/writer/builder.rs index e6de745f2..00e6ce227 100644 --- a/crates/btcio/src/writer/builder.rs +++ b/crates/btcio/src/writer/builder.rs @@ -11,7 +11,6 @@ use bitcoin::{ }, script, }, - consensus::deserialize, hashes::Hash, key::{TapTweak, TweakedPublicKey, UntweakedKeypair}, script::PushBytesBuf, @@ -30,10 +29,12 @@ use bitcoin::{ use rand::RngCore; use thiserror::Error; -use super::config::{InscriptionFeePolicy, WriterConfig}; -use crate::rpc::{ - traits::{L1Client, SeqL1Client}, - types::RawUTXO, +use crate::{ + rpc::{ + traits::{BitcoinReader, BitcoinSigner, BitcoinWallet}, + types::ListUnspent, + }, + writer::config::{InscriptionFeePolicy, WriterConfig}, }; const BITCOIN_DUST_LIMIT: u64 = 546; @@ -42,49 +43,6 @@ const BITCOIN_DUST_LIMIT: u64 = 546; const BATCH_DATA_TAG: &[u8] = &[1]; const ROLLUP_NAME_TAG: &[u8] = &[3]; -#[derive(Clone, Debug, PartialEq)] -pub struct Utxo { - pub txid: Txid, - pub vout: u32, - pub address: String, - pub script_pubkey: String, - pub amount: u64, - pub confirmations: u64, - pub spendable: bool, - pub solvable: bool, -} - -#[derive(Debug, Error)] -pub enum UtxoParseError { - #[error("Hex decode error")] - InvalidTxHex(#[from] hex::FromHexError), - - #[error("Tx decode error")] - TxDecode(#[from] bitcoin::consensus::encode::Error), -} - -impl TryFrom for Utxo { - type Error = UtxoParseError; - - fn try_from(value: RawUTXO) -> Result { - let rawtxid = value.txid; - let mut decoded = hex::decode(rawtxid)?; - // Reverse because the display str has the reverse order of actual bytes - decoded.reverse(); - let txid = deserialize(&decoded)?; - Ok(Utxo { - txid, - vout: value.vout, - address: value.address, - script_pubkey: value.script_pub_key, - amount: value.amount, - confirmations: value.confirmations, - spendable: value.spendable, - solvable: value.solvable, - }) - } -} - #[derive(Debug, Error)] pub enum InscriptionError { #[error("Not enough UTXOs for transaction of {0} sats")] @@ -99,18 +57,15 @@ pub enum InscriptionError { pub async fn build_inscription_txs( payload: &[u8], - rpc_client: &Arc, + rpc_client: &Arc, config: &WriterConfig, ) -> anyhow::Result<(Transaction, Transaction)> { + // let (signature, pub_key) = sign_blob_with_private_key(&payload, &config.private_key)?; + let network = rpc_client.network().await?; let utxos = rpc_client.get_utxos().await?; - let utxos = utxos - .into_iter() - .map(>::try_into) - .collect::, UtxoParseError>>() - .map_err(|e| anyhow::anyhow!("{:?}", e))?; let fee_rate = match config.inscription_fee_policy { - InscriptionFeePolicy::Smart => rpc_client.estimate_smart_fee().await?, + InscriptionFeePolicy::Smart => rpc_client.estimate_smart_fee(1).await?, InscriptionFeePolicy::Fixed(val) => val, }; create_inscription_transactions( @@ -120,7 +75,7 @@ pub async fn build_inscription_txs( config.sequencer_address.clone(), config.amount_for_reveal_txn, fee_rate, - rpc_client.network(), + network, ) .map_err(|e| anyhow::anyhow!(e.to_string())) } @@ -129,7 +84,7 @@ pub async fn build_inscription_txs( pub fn create_inscription_transactions( rollup_name: &str, write_intent: &[u8], - utxos: Vec, + utxos: Vec, recipient: Address, reveal_value: u64, fee_rate: u64, @@ -244,9 +199,15 @@ fn get_size( } /// Choose utxos almost naively. -fn choose_utxos(utxos: &[Utxo], amount: u64) -> Result<(Vec, u64), InscriptionError> { - let mut bigger_utxos: Vec<&Utxo> = utxos.iter().filter(|utxo| utxo.amount >= amount).collect(); - let mut sum: u64 = 0; +fn choose_utxos( + utxos: &[ListUnspent], + amount: u64, +) -> Result<(Vec, u64), InscriptionError> { + let mut bigger_utxos: Vec<&ListUnspent> = utxos + .iter() + .filter(|utxo| utxo.amount.to_sat() >= amount) + .collect(); + let mut sum = 0; if !bigger_utxos.is_empty() { // sort vec by amount (small first) @@ -255,20 +216,22 @@ fn choose_utxos(utxos: &[Utxo], amount: u64) -> Result<(Vec, u64), Inscrip // single utxo will be enough // so return the transaction let utxo = bigger_utxos[0]; - sum += utxo.amount; + sum += utxo.amount.to_sat(); Ok((vec![utxo.clone()], sum)) } else { - let mut smaller_utxos: Vec<&Utxo> = - utxos.iter().filter(|utxo| utxo.amount < amount).collect(); + let mut smaller_utxos: Vec<&ListUnspent> = utxos + .iter() + .filter(|utxo| utxo.amount.to_sat() < amount) + .collect(); // sort vec by amount (large first) smaller_utxos.sort_by_key(|x| Reverse(&x.amount)); - let mut chosen_utxos: Vec = vec![]; + let mut chosen_utxos: Vec = vec![]; for utxo in smaller_utxos { - sum += utxo.amount; + sum += utxo.amount.to_sat(); chosen_utxos.push(utxo.clone()); if sum >= amount { @@ -285,12 +248,12 @@ fn choose_utxos(utxos: &[Utxo], amount: u64) -> Result<(Vec, u64), Inscrip } fn build_commit_transaction( - utxos: Vec, + utxos: Vec, recipient: Address, change_address: Address, output_value: u64, fee_rate: u64, -) -> Result<(Transaction, Vec), InscriptionError> { +) -> Result<(Transaction, Vec), InscriptionError> { // get single input single output transaction size let mut size = get_size( &default_txin(), @@ -303,9 +266,9 @@ fn build_commit_transaction( ); let mut last_size = size; - let utxos: Vec = utxos + let utxos: Vec = utxos .iter() - .filter(|utxo| utxo.spendable && utxo.solvable && utxo.amount > BITCOIN_DUST_LIMIT) + .filter(|utxo| utxo.spendable && utxo.solvable && utxo.amount.to_sat() > BITCOIN_DUST_LIMIT) .cloned() .collect(); @@ -552,68 +515,72 @@ mod tests { TxOut, Witness, }; - const BTC_TO_SATS: u64 = 100_000_000; - - use super::{Utxo, BITCOIN_DUST_LIMIT}; - use crate::{rpc::types::RawUTXO, writer::builder::InscriptionError}; + use super::*; + use crate::{rpc::types::ListUnspent, writer::builder::InscriptionError}; + const BTC_TO_SATS: u64 = 100_000_000; const REVEAL_OUTPUT_AMOUNT: u64 = BITCOIN_DUST_LIMIT; #[allow(clippy::type_complexity)] - fn get_mock_data() -> (&'static str, Vec, Vec, Vec, Address, Vec) { + fn get_mock_data() -> ( + &'static str, + Vec, + Vec, + Vec, + Address, + Vec, + ) { let rollup_name = "test_rollup"; let body = vec![100; 1000]; let signature = vec![100; 64]; let sequencer_public_key = vec![100; 33]; let address = Address::from_str("bc1pp8qru0ve43rw9xffmdd8pvveths3cx6a5t6mcr0xfn9cpxx2k24qf70xq9") - .unwrap() - .require_network(bitcoin::Network::Bitcoin) .unwrap(); let utxos = vec![ - RawUTXO { + ListUnspent { txid: "4cfbec13cf1510545f285cceceb6229bd7b6a918a8f6eba1dbee64d26226a3b7" - .to_string(), + .parse::() + .unwrap(), vout: 0, - address: "bc1pp8qru0ve43rw9xffmdd8pvveths3cx6a5t6mcr0xfn9cpxx2k24qf70xq9" - .to_string(), - script_pub_key: address.script_pubkey().to_hex_string(), - amount: 100 * BTC_TO_SATS, + address: address.clone(), + script_pubkey: "foo".to_string(), + amount: Amount::from_btc(100.0).unwrap(), confirmations: 100, spendable: true, solvable: true, - } - .try_into() - .unwrap(), - RawUTXO { + label: None, + safe: true, + }, + ListUnspent { txid: "44990141674ff56ed6fee38879e497b2a726cddefd5e4d9b7bf1c4e561de4347" - .to_string(), + .parse::() + .unwrap(), vout: 0, - address: "bc1pp8qru0ve43rw9xffmdd8pvveths3cx6a5t6mcr0xfn9cpxx2k24qf70xq9" - .to_string(), - script_pub_key: address.script_pubkey().to_hex_string(), - amount: 50 * BTC_TO_SATS, + address: address.clone(), + script_pubkey: "foo".to_string(), + amount: Amount::from_btc(50.0).unwrap(), confirmations: 100, spendable: true, solvable: true, - } - .try_into() - .unwrap(), - RawUTXO { + label: None, + safe: true, + }, + ListUnspent { txid: "4dbe3c10ee0d6bf16f9417c68b81e963b5bccef3924bbcb0885c9ea841912325" - .to_string(), + .parse::() + .unwrap(), vout: 0, - address: "bc1pp8qru0ve43rw9xffmdd8pvveths3cx6a5t6mcr0xfn9cpxx2k24qf70xq9" - .to_string(), - script_pub_key: address.script_pubkey().to_hex_string(), - amount: 10 * BTC_TO_SATS, + address: address.clone(), + script_pubkey: "foo".to_string(), + amount: Amount::from_btc(10.0).unwrap(), confirmations: 100, spendable: true, solvable: true, - } - .try_into() - .unwrap(), + label: None, + safe: true, + }, ]; ( @@ -621,7 +588,7 @@ mod tests { body, signature, sequencer_public_key, - address, + address.assume_checked(), utxos, ) } @@ -664,7 +631,7 @@ mod tests { )); } - fn get_txn_from_utxo(utxo: &Utxo, _address: &Address) -> Transaction { + fn get_txn_from_utxo(utxo: &ListUnspent, _address: &Address) -> Transaction { let inputs = vec![TxIn { previous_output: OutPoint { txid: utxo.txid, @@ -676,8 +643,8 @@ mod tests { }]; let outputs = vec![TxOut { - value: Amount::from_sat(utxo.amount), - script_pubkey: ScriptBuf::from_hex(utxo.script_pubkey.as_str()).unwrap(), + value: utxo.amount, + script_pubkey: utxo.address.clone().assume_checked().script_pubkey(), }]; Transaction { diff --git a/crates/btcio/src/writer/signer.rs b/crates/btcio/src/writer/signer.rs index 7c63bb56d..36fe9274c 100644 --- a/crates/btcio/src/writer/signer.rs +++ b/crates/btcio/src/writer/signer.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use alpen_express_db::types::{BlobEntry, L1TxEntry}; use alpen_express_primitives::buf::Buf32; -use bitcoin::Transaction; -use tracing::debug; +use bitcoin::{consensus, Transaction}; +use tracing::*; use super::{builder::build_inscription_txs, config::WriterConfig}; use crate::{ broadcaster::L1BroadcastHandle, - rpc::traits::{L1Client, SeqL1Client}, + rpc::traits::{BitcoinReader, BitcoinSigner, BitcoinWallet}, }; type BlobIdx = u64; @@ -21,14 +21,21 @@ type BlobIdx = u64; pub async fn create_and_sign_blob_inscriptions( blobentry: &BlobEntry, bhandle: &L1BroadcastHandle, - client: Arc, + client: Arc, config: &WriterConfig, ) -> anyhow::Result<(Buf32, Buf32)> { + trace!("Creating and signing blob inscriptions"); let (commit, reveal) = build_inscription_txs(&blobentry.blob, &client, config).await?; debug!("Signing commit transaction {}", commit.compute_txid()); - let signed_commit: Transaction = client.sign_raw_transaction_with_wallet(commit).await?; - + let signed_commit = client + .sign_raw_transaction_with_wallet(&commit) + .await + .expect("could not sign commit tx") + .hex; + + let signed_commit: Transaction = consensus::encode::deserialize_hex(&signed_commit) + .expect("could not deserialize transaction"); let cid: Buf32 = signed_commit.compute_txid().into(); let rid: Buf32 = reveal.compute_txid().into(); diff --git a/crates/btcio/src/writer/task.rs b/crates/btcio/src/writer/task.rs index 91365e64a..444dacc88 100644 --- a/crates/btcio/src/writer/task.rs +++ b/crates/btcio/src/writer/task.rs @@ -13,7 +13,7 @@ use tracing::*; use super::config::WriterConfig; use crate::{ broadcaster::L1BroadcastHandle, - rpc::traits::{L1Client, SeqL1Client}, + rpc::traits::{BitcoinReader, BitcoinSigner, BitcoinWallet}, status::{apply_status_updates, L1StatusUpdate}, writer::signer::create_and_sign_blob_inscriptions, }; @@ -88,7 +88,7 @@ impl InscriptionHandle { /// [`Result`](anyhow::Result) pub fn start_inscription_task( executor: &TaskExecutor, - rpc_client: Arc, + rpc_client: Arc, config: WriterConfig, db: Arc, status_tx: Arc, @@ -143,7 +143,7 @@ fn get_next_blobidx_to_watch(insc_ops: &InscriptionDataOps) -> anyhow::Result, + rpc_client: Arc, config: WriterConfig, insc_ops: Arc, bcast_handle: Arc, diff --git a/crates/btcio/src/writer/utils.rs b/crates/btcio/src/writer/utils.rs index 6937282c6..46c61b723 100644 --- a/crates/btcio/src/writer/utils.rs +++ b/crates/btcio/src/writer/utils.rs @@ -8,16 +8,20 @@ use alpen_express_primitives::buf::Buf32; use anyhow::Context; use bitcoin::{consensus::serialize, hashes::Hash, Transaction}; use sha2::{Digest, Sha256}; +use tokio::task::spawn_blocking; use super::{builder::build_inscription_txs, config::WriterConfig}; -use crate::rpc::traits::{L1Client, SeqL1Client}; +use crate::rpc::{ + traits::{BitcoinReader, BitcoinSigner, BitcoinWallet}, + types::SignRawTransactionWithWallet, +}; // Helper function to fetch a blob entry from within tokio pub async fn get_blob_by_idx( db: Arc, idx: u64, ) -> anyhow::Result> { - tokio::task::spawn_blocking(move || Ok(db.sequencer_provider().get_blob_by_idx(idx)?)).await? + spawn_blocking(move || Ok(db.sequencer_provider().get_blob_by_idx(idx)?)).await? } // Helper function to fetch a blob entry from within tokio @@ -25,7 +29,7 @@ pub async fn get_blob_by_id( db: Arc, id: Buf32, ) -> anyhow::Result> { - tokio::task::spawn_blocking(move || Ok(db.sequencer_provider().get_blob_by_id(id)?)).await? + spawn_blocking(move || Ok(db.sequencer_provider().get_blob_by_id(id)?)).await? } // Helper to put blob from within tokio's context @@ -34,7 +38,7 @@ pub async fn put_blob( id: Buf32, entry: BlobEntry, ) -> anyhow::Result { - tokio::task::spawn_blocking(move || Ok(db.sequencer_store().put_blob(id, entry)?)).await? + spawn_blocking(move || Ok(db.sequencer_store().put_blob(id, entry)?)).await? } // Helper function to update a blob entry by index from within tokio @@ -43,10 +47,7 @@ pub async fn update_blob_by_idx( idx: u64, blob_entry: BlobEntry, ) -> anyhow::Result<()> { - tokio::task::spawn_blocking(move || { - Ok(db.sequencer_store().update_blob_by_idx(idx, blob_entry)?) - }) - .await? + spawn_blocking(move || Ok(db.sequencer_store().update_blob_by_idx(idx, blob_entry)?)).await? } // Helper function to fetch a l1tx from within tokio @@ -54,7 +55,7 @@ pub async fn get_l1_tx( db: Arc, txid: Buf32, ) -> anyhow::Result>> { - tokio::task::spawn_blocking(move || Ok(db.sequencer_provider().get_l1_tx(txid)?)).await? + spawn_blocking(move || Ok(db.sequencer_provider().get_l1_tx(txid)?)).await? } // Helper function to store commit reveal txs @@ -73,7 +74,7 @@ pub async fn put_commit_reveal_txs .as_raw_hash() .to_byte_array() .into(); - tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + spawn_blocking(move || -> anyhow::Result<()> { Ok(db.sequencer_store().put_commit_reveal_txs( cid, serialize(&commit_tx), @@ -86,9 +87,9 @@ pub async fn put_commit_reveal_txs } pub async fn sign_transaction( - client: &impl SeqL1Client, - tx: Transaction, -) -> anyhow::Result { + client: &impl BitcoinSigner, + tx: &Transaction, +) -> anyhow::Result { let tx = client.sign_raw_transaction_with_wallet(tx).await?; Ok(tx) } @@ -100,16 +101,17 @@ pub type BlobIdx = u64; pub async fn create_and_sign_blob_inscriptions( blobidx: BlobIdx, db: Arc, - client: Arc, + client: Arc, config: &WriterConfig, ) -> anyhow::Result<()> { if let Some(mut entry) = get_blob_by_idx(db.clone(), blobidx).await? { // TODO: handle insufficient utxos let (commit, reveal) = build_inscription_txs(&entry.blob, &client, config).await?; - let signed_commit: Transaction = sign_transaction(client.as_ref(), commit) + let signed_commit: Transaction = sign_transaction(client.as_ref(), &commit) .await - .context(format!("Signing commit tx failed for blob {}", blobidx))?; + .context(format!("Signing commit tx failed for blob {}", blobidx))? + .hex; // We don't need to explicitly sign the reveal txn because we'll be doing key path spending // using the ephemeral key generated while building the inscriptions diff --git a/crates/btcio/src/writer/watcher.rs b/crates/btcio/src/writer/watcher.rs index 817cd2254..13b806f39 100644 --- a/crates/btcio/src/writer/watcher.rs +++ b/crates/btcio/src/writer/watcher.rs @@ -9,7 +9,7 @@ use tracing::*; use super::{config::WriterConfig, utils::update_blob_by_idx}; use crate::{ - rpc::traits::{L1Client, SeqL1Client}, + rpc::traits::{BitcoinReader, BitcoinSigner, BitcoinWallet}, writer::utils::{create_and_sign_blob_inscriptions, get_blob_by_idx}, }; @@ -18,7 +18,7 @@ const FINALITY_DEPTH: u64 = 6; /// Watches for inscription transactions status in bitcoin pub async fn watcher_task( next_to_watch: u64, - rpc_client: Arc, + rpc_client: Arc, config: WriterConfig, db: Arc, ) -> anyhow::Result<()> { @@ -35,8 +35,8 @@ pub async fn watcher_task( BlobL1Status::Published | BlobL1Status::Confirmed => { debug!(%curr_blobidx, "blobentry is published or confirmed"); let confs = check_confirmations_and_update_entry( - rpc_client.clone(), curr_blobidx, + rpc_client.clone(), blobentry, db.clone(), ) @@ -70,13 +70,13 @@ pub async fn watcher_task( } async fn check_confirmations_and_update_entry( - rpc_client: Arc, curr_blobidx: u64, + rpc_client: Arc, mut blobentry: BlobEntry, db: Arc, ) -> anyhow::Result { let txid = Txid::from_slice(blobentry.reveal_txid.0.as_slice())?; - let confs = rpc_client.get_transaction_info(txid).await?.confirmations; + let confs = rpc_client.get_transaction(&txid).await?.confirmations as u64; // If confs is 0 then it is yet in mempool // TODO: But if confs is error(saying txn not found, TODO: check this) then it // could possibly have reorged and we might need to diff --git a/crates/btcio/src/writer/writer_handler.rs b/crates/btcio/src/writer/writer_handler.rs index e237a6f58..05cd40a51 100644 --- a/crates/btcio/src/writer/writer_handler.rs +++ b/crates/btcio/src/writer/writer_handler.rs @@ -17,7 +17,7 @@ use super::{ utils::{create_and_sign_blob_inscriptions, get_blob_by_id, put_blob, BlobIdx}, }; use crate::{ - rpc::traits::{L1Client, SeqL1Client}, + rpc::traits::{BitcoinBroadcaster, BitcoinReader, BitcoinSigner, BitcoinWallet}, writer::watcher::watcher_task, }; @@ -63,7 +63,7 @@ impl DaWriter { pub fn start_writer_task( executor: &TaskExecutor, - rpc_client: Arc, + rpc_client: Arc, config: WriterConfig, db: Arc, status_tx: Arc, @@ -108,7 +108,7 @@ pub fn start_writer_task( async fn listen_for_signing_intents( mut sign_rx: Receiver, - rpc_client: Arc, + rpc_client: Arc, config: WriterConfig, db: Arc, ) -> anyhow::Result<()> diff --git a/crates/db/src/types.rs b/crates/db/src/types.rs index 565b0b0eb..d27affde7 100644 --- a/crates/db/src/types.rs +++ b/crates/db/src/types.rs @@ -2,7 +2,10 @@ use alpen_express_primitives::buf::Buf32; use arbitrary::Arbitrary; -use bitcoin::{consensus::serialize, Transaction}; +use bitcoin::{ + consensus::{self, deserialize, serialize}, + Transaction, +}; use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; @@ -71,6 +74,7 @@ pub struct L1TxEntry { } impl L1TxEntry { + /// Create a new [`L1TxEntry`] from a [`Transaction`]. pub fn from_tx(tx: &Transaction) -> Self { Self { tx_raw: serialize(tx), @@ -78,9 +82,20 @@ impl L1TxEntry { } } + /// Returns the raw serialized transaction. + /// + /// # Note + /// + /// Whenever possible use [`to_tx()`] to deserialize the transaction. + /// This imposes more strict type checks. pub fn tx_raw(&self) -> &[u8] { &self.tx_raw } + + /// Deserializes the raw transaction into a [`Transaction`]. + pub fn to_tx(&self) -> Result { + deserialize(&self.tx_raw) + } } /// The possible statuses of a publishable transaction @@ -93,10 +108,10 @@ pub enum L1TxStatus { Unpublished, /// The transaction is published Published, - /// The transaction is included in L1 at given height - Confirmed { height: u64 }, - /// The transaction is finalized in L1 at given height - Finalized { height: u64 }, + /// The transaction is included in L1 and has `u64` confirmations + Confirmed { confirmations: u64 }, + /// The transaction is finalized in L1 and has `u64` confirmations + Finalized { confirmations: u64 }, /// The transaction is not included in L1 and has errored with some error code Excluded { reason: ExcludeReason }, } @@ -126,12 +141,12 @@ mod tests { (L1TxStatus::Unpublished, r#"{"status":"Unpublished"}"#), (L1TxStatus::Published, r#"{"status":"Published"}"#), ( - L1TxStatus::Confirmed { height: 10 }, - r#"{"status":"Confirmed","height":10}"#, + L1TxStatus::Confirmed { confirmations: 10 }, + r#"{"status":"Confirmed","confirmations":10}"#, ), ( - L1TxStatus::Finalized { height: 100 }, - r#"{"status":"Finalized","height":100}"#, + L1TxStatus::Finalized { confirmations: 100 }, + r#"{"status":"Finalized","confirmations":100}"#, ), ( L1TxStatus::Excluded { diff --git a/crates/prover/btc-headerchain/Cargo.toml b/crates/prover/btc-headerchain/Cargo.toml index 021003a8e..cd7f9bec2 100644 --- a/crates/prover/btc-headerchain/Cargo.toml +++ b/crates/prover/btc-headerchain/Cargo.toml @@ -1,7 +1,7 @@ [package] +edition = "2021" name = "btc-headerchain" version = "0.1.0" -edition = "2021" [dependencies] anyhow.workspace = true diff --git a/crates/rocksdb-store/src/broadcaster/db.rs b/crates/rocksdb-store/src/broadcaster/db.rs index 3d049eb35..401d8f284 100644 --- a/crates/rocksdb-store/src/broadcaster/db.rs +++ b/crates/rocksdb-store/src/broadcaster/db.rs @@ -202,7 +202,7 @@ mod tests { .unwrap(); let mut updated_txentry = txentry; - updated_txentry.status = L1TxStatus::Finalized { height: 1 }; + updated_txentry.status = L1TxStatus::Finalized { confirmations: 1 }; broadcast_db .update_tx_entry_by_id(txid, updated_txentry.clone()) @@ -228,7 +228,7 @@ mod tests { .unwrap(); let mut updated_txentry = txentry; - updated_txentry.status = L1TxStatus::Finalized { height: 1 }; + updated_txentry.status = L1TxStatus::Finalized { confirmations: 1 }; broadcast_db .update_tx_entry(idx, updated_txentry.clone()) diff --git a/crates/storage/src/ops/l1tx_broadcast.rs b/crates/storage/src/ops/l1tx_broadcast.rs index 90447a851..bdb8af912 100644 --- a/crates/storage/src/ops/l1tx_broadcast.rs +++ b/crates/storage/src/ops/l1tx_broadcast.rs @@ -80,6 +80,8 @@ fn insert_new_tx_entry( id: Buf32, entry: L1TxEntry, ) -> DbResult { + trace!("insert_new_tx_entry: id={}", id); + assert!(entry.to_tx().is_ok(), "invalid tx entry {entry:?}"); let bcast_store = context.db.broadcast_store(); bcast_store.insert_new_tx_entry(id, entry) } diff --git a/sequencer/Cargo.toml b/sequencer/Cargo.toml index a5744a06d..fedcdae1f 100644 --- a/sequencer/Cargo.toml +++ b/sequencer/Cargo.toml @@ -31,7 +31,6 @@ format_serde_error = { workspace = true } hex = { workspace = true } jsonrpsee = { workspace = true, features = ["server", "macros"] } parking_lot = { workspace = true } -reqwest = { workspace = true } reth-ipc = { workspace = true } reth-primitives = { workspace = true } reth-rpc-api = { workspace = true } diff --git a/sequencer/src/l1_reader.rs b/sequencer/src/l1_reader.rs index 96ea6f39c..0536ed0dc 100644 --- a/sequencer/src/l1_reader.rs +++ b/sequencer/src/l1_reader.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use alpen_express_btcio::{ reader::{config::ReaderConfig, messages::L1Event, query::bitcoin_data_reader_task}, - rpc::traits::L1Client, + rpc::traits::BitcoinReader, }; use alpen_express_consensus_logic::{ctl::CsmController, l1_handler::bitcoin_data_handler_task}; use alpen_express_db::traits::{Database, L1DataProvider}; @@ -17,7 +17,7 @@ pub fn start_reader_tasks( executor: &TaskExecutor, params: Arc, config: &Config, - rpc_client: Arc, + rpc_client: Arc, db: Arc, csm_ctl: Arc, status_rx: Arc, diff --git a/sequencer/src/main.rs b/sequencer/src/main.rs index d65178932..f4b9e8c75 100644 --- a/sequencer/src/main.rs +++ b/sequencer/src/main.rs @@ -8,6 +8,7 @@ use std::{ use alpen_express_btcio::{ broadcaster::{spawn_broadcaster_task, L1BroadcastHandle}, + rpc::BitcoinClient, writer::{config::WriterConfig, start_inscription_task, InscriptionHandle}, }; use alpen_express_common::logging; @@ -215,16 +216,16 @@ fn main_inner(args: Args) -> anyhow::Result<()> { // Set up Bitcoin client RPC. let bitcoind_url = format!("http://{}", config.bitcoind_rpc.rpc_url); - let btc_rpc = alpen_express_btcio::rpc::BitcoinClient::new( + let btc_rpc = BitcoinClient::new( bitcoind_url, config.bitcoind_rpc.rpc_user.clone(), config.bitcoind_rpc.rpc_password.clone(), - bitcoin::Network::Regtest, - ); + ) + .map_err(anyhow::Error::from)?; let btc_rpc = Arc::new(btc_rpc); // TODO remove this - if config.bitcoind_rpc.network == Network::Regtest { + if config.bitcoind_rpc.network != Network::Regtest { warn!("network not set to regtest, ignoring"); }