diff --git a/Cargo.lock b/Cargo.lock index a990898..5eae30e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,6 +543,34 @@ dependencies = [ "thiserror", ] +[[package]] +name = "btc-light-client" +version = "0.13.0" +dependencies = [ + "anyhow", + "assert_matches", + "babylon-apis", + "babylon-bindings", + "babylon-bindings-test", + "babylon-bitcoin", + "babylon-proto", + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-vm", + "criterion", + "cw-multi-test", + "cw-storage-plus", + "cw-utils", + "cw2", + "derivative", + "hex", + "prost 0.11.9", + "serde", + "test-utils", + "thiserror", + "thousands", +] + [[package]] name = "btc-staking" version = "0.13.0" diff --git a/contracts/btc-light-client/.cargo/config b/contracts/btc-light-client/.cargo/config new file mode 100644 index 0000000..9bf9392 --- /dev/null +++ b/contracts/btc-light-client/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" \ No newline at end of file diff --git a/contracts/btc-light-client/Cargo.toml b/contracts/btc-light-client/Cargo.toml new file mode 100644 index 0000000..9401295 --- /dev/null +++ b/contracts/btc-light-client/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "btc-light-client" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] +# See https://bheisler.github.io/criterion.rs/book/faq.html#cargo-bench-gives-unrecognized-option-errors-for-valid-command-line-options +bench = false +doctest = false + +[[bin]] +name = "schema" +path = "src/bin/schema.rs" +bench = false +test = false + +[features] +# Add feature "cranelift" to default if you need 32 bit or ARM support +default = [] +# Use cranelift backend instead of singlepass. This is required for development on 32 bit or ARM machines. +cranelift = ["cosmwasm-vm/cranelift"] +# for quicker tests, cargo test --lib +library = [] + +[dependencies] +babylon-apis = { path = "../../packages/apis" } +babylon-bindings = { path = "../../packages/bindings" } +babylon-proto = { path = "../../packages/proto" } +babylon-bitcoin = { path = "../../packages/bitcoin" } +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +hex = { workspace = true } +thiserror = { workspace = true } +prost = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +babylon-bindings-test = { path = "../../packages/bindings-test" } +test-utils = { path = "../../packages/test-utils" } + +cosmwasm-vm = { workspace = true } +cw-multi-test = { workspace = true } + +anyhow = { workspace = true } +assert_matches = { workspace = true } +derivative = { workspace = true } +# bench dependencies +criterion = { workspace = true } +thousands = { workspace = true } + +[[bench]] +name = "main" +harness = false \ No newline at end of file diff --git a/contracts/btc-light-client/benches/main.rs b/contracts/btc-light-client/benches/main.rs new file mode 100644 index 0000000..552ba8e --- /dev/null +++ b/contracts/btc-light-client/benches/main.rs @@ -0,0 +1,169 @@ +//! This benchmark tries to run and call the generated wasm. +//! It depends on a Wasm build being available, which you can create by running `cargo optimize` in +//! the workspace root. +//! Then running `cargo bench` will validate we can properly call into that generated Wasm. +//! +use criterion::{criterion_group, criterion_main, Criterion, PlottingBackend}; + +use std::time::Duration; +use test_utils::get_btc_lc_mainchain_resp; +use thousands::Separable; + +use cosmwasm_std::{Env, MessageInfo, Response}; +use cosmwasm_vm::testing::{ + execute, instantiate, mock_env, mock_info, mock_instance_with_gas_limit, MockApi, MockQuerier, + MockStorage, +}; +use cosmwasm_vm::Instance; + +use babylon_bindings::BabylonMsg; +use btc_light_client::msg::btc_header::BtcHeader; +use btc_light_client::msg::contract::{ExecuteMsg, InstantiateMsg}; + +// Output of `cargo optimize` +static WASM: &[u8] = include_bytes!("../../../artifacts/btc_light_client.wasm"); + +// From https://github.com/CosmWasm/wasmd/blob/7ea00e2ea858ed599141e322bd68171998a3259a/x/wasm/types/gas_register.go#L33 +const GAS_MULTIPLIER: u64 = 140_000_000; + +const CREATOR: &str = "creator"; + +#[track_caller] +pub fn get_main_msg_test_headers() -> Vec { + let res = get_btc_lc_mainchain_resp(); + res.headers + .iter() + .map(TryInto::try_into) + .collect::>() + .unwrap() +} + +#[track_caller] +pub fn setup_instance() -> Instance { + let mut deps = mock_instance_with_gas_limit(WASM, 10_000_000_000_000); + let msg = InstantiateMsg { + network: babylon_bitcoin::chain_params::Network::Regtest, + btc_confirmation_depth: 10, + }; + let info = mock_info(CREATOR, &[]); + let res: Response = instantiate(&mut deps, mock_env(), info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + deps +} + +#[track_caller] +fn setup_benchmark() -> ( + Instance, + MessageInfo, + Env, + Vec, +) { + let mut deps = setup_instance(); + let info = mock_info(CREATOR, &[]); + let env = mock_env(); + + let test_headers = get_main_msg_test_headers(); + + let benchmark_msg = ExecuteMsg::InitBtcLightClient { + headers: test_headers[0..=1].to_owned(), + }; + + // init call + execute::<_, _, _, _, BabylonMsg>(&mut deps, env.clone(), info.clone(), benchmark_msg.clone()) + .unwrap(); + (deps, info, env, test_headers) +} + +fn bench_btc_light_client(c: &mut Criterion) { + let mut group = c.benchmark_group("BTC Light Client"); + + group.bench_function("btc_headers_verify cpu", |b| { + let (mut deps, info, env, test_headers) = setup_benchmark(); + + let headers_len = test_headers.len(); + let mut i = 1; + b.iter(|| { + let benchmark_msg = ExecuteMsg::UpdateBtcLightClient { + headers: test_headers[i..=i + 1].to_owned(), + }; + execute::<_, _, _, _, BabylonMsg>(&mut deps, env.clone(), info.clone(), benchmark_msg) + .unwrap(); + i = (i + 1) % (headers_len - 1); + }); + }); + + group.bench_function("btc_headers_verify gas", |b| { + let (mut deps, info, env, test_headers) = setup_benchmark(); + + let headers_len = test_headers.len(); + let mut i = 1; + b.iter_custom(|iter| { + let mut gas_used = 0; + for _ in 0..iter { + let benchmark_msg = ExecuteMsg::UpdateBtcLightClient { + headers: test_headers[i..=i + 1].to_owned(), + }; + let gas_before = deps.get_gas_left(); + execute::<_, _, _, _, BabylonMsg>( + &mut deps, + env.clone(), + info.clone(), + benchmark_msg, + ) + .unwrap(); + gas_used += gas_before - deps.get_gas_left(); + i = (i + 1) % (headers_len - 1); + } + println!( + "BTC header avg call gas: {}", + (gas_used / (2 * iter)).separate_with_underscores() + ); + Duration::new(0, gas_used as u32 / 2) + }); + }); + + group.bench_function("btc_headers_verify SDK gas", |b| { + let (mut deps, info, env, test_headers) = setup_benchmark(); + + let headers_len = test_headers.len(); + let mut i = 1; + b.iter_custom(|iter| { + let mut gas_used = 0; + for _ in 0..iter { + let benchmark_msg = ExecuteMsg::UpdateBtcLightClient { + headers: test_headers[i..=i + 1].to_owned(), + }; + let gas_before = deps.get_gas_left(); + execute::<_, _, _, _, BabylonMsg>( + &mut deps, + env.clone(), + info.clone(), + benchmark_msg, + ) + .unwrap(); + gas_used += (gas_before - deps.get_gas_left()) / GAS_MULTIPLIER; + i = (i + 1) % (headers_len - 1); + } + println!("BTC header avg call SDK gas: {}", gas_used / (2 * iter)); + Duration::new(0, gas_used as u32 / 2) + }); + }); + + group.finish(); +} + +fn make_config() -> Criterion { + Criterion::default() + .plotting_backend(PlottingBackend::Plotters) + .without_plots() + .warm_up_time(Duration::new(0, 1_000_000)) + .measurement_time(Duration::new(0, 10_000_000)) + .sample_size(10) +} + +criterion_group!( + name = btc_light_client; + config = make_config(); + targets = bench_btc_light_client +); +criterion_main!(btc_light_client); diff --git a/contracts/btc-light-client/src/bin/schema.rs b/contracts/btc-light-client/src/bin/schema.rs new file mode 100644 index 0000000..9dfaf61 --- /dev/null +++ b/contracts/btc-light-client/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use btc_light_client::msg::contract::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/btc-light-client/src/contract.rs b/contracts/btc-light-client/src/contract.rs new file mode 100644 index 0000000..404a26a --- /dev/null +++ b/contracts/btc-light-client/src/contract.rs @@ -0,0 +1,173 @@ +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult, +}; +use cw2::set_contract_version; + +use babylon_bindings::BabylonMsg; +use babylon_bitcoin::{chain_params, BlockHash}; +use babylon_proto::babylon::btclightclient::v1::BtcHeaderInfo; + +use crate::error::ContractError; +use crate::msg::btc_header::{BtcHeader, BtcHeaderResponse, BtcHeadersResponse}; +use crate::msg::contract::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::btc_light_client::{ + get_base_header, get_header, get_header_by_hash, get_headers, get_tip, insert_headers, + is_initialized, set_base_header, set_tip, +}; +use crate::state::config::{Config, CONFIG}; +use crate::utils::btc_light_client::{total_work, verify_headers, zero_work}; +use std::str::FromStr; + +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result, ContractError> { + msg.validate()?; + + // Initialize config + let cfg = Config { + network: msg.network, + btc_confirmation_depth: msg.btc_confirmation_depth, + }; + CONFIG.save(deps.storage, &cfg)?; + + // Set contract version + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::new().add_attribute("action", "instantiate")) +} + +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: Empty, +) -> Result, ContractError> { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::new().add_attribute("action", "migrate")) +} + +pub fn execute( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::InitBtcLightClient { headers } => init_btc_light_client(deps, headers), + ExecuteMsg::UpdateBtcLightClient { headers } => update_btc_light_client(deps, headers), + } +} + +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result { + let result = match msg { + QueryMsg::BtcBaseHeader {} => { + let header = get_base_header(deps.storage)?; + to_json_binary(&BtcHeaderResponse::try_from(&header)?)? + } + QueryMsg::BtcTipHeader {} => { + let header = get_tip(deps.storage)?; + to_json_binary(&BtcHeaderResponse::try_from(&header)?)? + } + QueryMsg::BtcHeader { height } => { + let header = get_header(deps.storage, height)?; + to_json_binary(&BtcHeaderResponse::try_from(&header)?)? + } + QueryMsg::BtcHeaderByHash { hash } => { + let hash = BlockHash::from_str(&hash).map_err(ContractError::HashError)?; + let header = get_header_by_hash(deps.storage, hash.as_ref())?; + to_json_binary(&BtcHeaderResponse::try_from(&header)?)? + } + QueryMsg::BtcHeaders { + start_after, + limit, + reverse, + } => { + let headers = get_headers(deps.storage, start_after, limit, reverse)?; + to_json_binary(&BtcHeadersResponse::try_from(headers.as_slice())?)? + } + }; + Ok(result) +} + +fn init_btc_light_client( + deps: DepsMut, + headers: Vec, +) -> Result, ContractError> { + // Check if the BTC light client has been initialized + if is_initialized(deps.storage) { + return Err(ContractError::InitError {}); + } + + // Check if there are enough headers + let cfg = CONFIG.load(deps.storage)?; + if headers.len() < cfg.btc_confirmation_depth as usize { + return Err(ContractError::InitErrorLength(cfg.btc_confirmation_depth)); + } + + // Convert headers to BtcHeaderInfo + let mut btc_headers = Vec::with_capacity(headers.len()); + let mut prev_work = zero_work(); + for (i, header) in headers.iter().enumerate() { + let btc_header_info = header.to_btc_header_info(i as u32, prev_work)?; + prev_work = total_work(&btc_header_info)?; + btc_headers.push(btc_header_info); + } + + // Verify headers + verify_headers( + &chain_params::get_chain_params(cfg.network), + &btc_headers[0], + &btc_headers[1..], + )?; + + // Save headers + insert_headers(deps.storage, &btc_headers)?; + + // Save base header and tip + set_base_header(deps.storage, &btc_headers[0])?; + set_tip(deps.storage, btc_headers.last().unwrap())?; + + Ok(Response::new().add_attribute("action", "init_btc_light_client")) +} + +fn update_btc_light_client( + deps: DepsMut, + headers: Vec, +) -> Result, ContractError> { + // Check if the BTC light client has been initialized + if !is_initialized(deps.storage) { + return Err(ContractError::InitError {}); + } + + // Get the current tip + let tip = get_tip(deps.storage)?; + + // Convert headers to BtcHeaderInfo + let mut btc_headers = Vec::with_capacity(headers.len()); + let mut prev_work = total_work(&tip)?; + for (i, header) in headers.iter().enumerate() { + let btc_header_info = header.to_btc_header_info(tip.height + i as u32 + 1, prev_work)?; + prev_work = total_work(&btc_header_info)?; + btc_headers.push(btc_header_info); + } + + // Verify headers + verify_headers( + &chain_params::get_chain_params(CONFIG.load(deps.storage)?.network), + &tip, + &btc_headers, + )?; + + // Save headers + insert_headers(deps.storage, &btc_headers)?; + + // Update tip + set_tip(deps.storage, btc_headers.last().unwrap())?; + + Ok(Response::new().add_attribute("action", "update_btc_light_client")) +} diff --git a/contracts/btc-light-client/src/error.rs b/contracts/btc-light-client/src/error.rs new file mode 100644 index 0000000..a5d8156 --- /dev/null +++ b/contracts/btc-light-client/src/error.rs @@ -0,0 +1,73 @@ +use babylon_bitcoin::Work; +use cosmwasm_std::StdError; +use cw_utils::ParseReplyError; +use hex::FromHexError; +use prost::DecodeError; +use std::str::Utf8Error; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + ParseReply(#[from] ParseReplyError), + + #[error("Invalid reply id: {0}")] + InvalidReplyId(u64), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Invalid configuration: {msg}")] + InvalidConfig { msg: String }, + + #[error("The given headers during initialization cannot be verified")] + InitError {}, + + #[error("The given headers during initialization cannot be verified. Less than {0} headers")] + InitErrorLength(u32), + + #[error("The bytes cannot be decoded")] + DecodeError(#[from] DecodeError), + + #[error("{0}")] + HashError(#[from] babylon_bitcoin::HexError), + + #[error("The hex cannot be decoded")] + DecodeHexError(#[from] FromHexError), + + #[error("The bytes cannot be decoded as string")] + DecodeUtf8Error(#[from] Utf8Error), + + #[error("The BTC header cannot be decoded")] + BTCHeaderDecodeError {}, + + #[error("The BTC header cannot be encoded")] + BTCHeaderEncodeError {}, + + #[error("The BTC header is not being sent")] + BTCHeaderEmpty {}, + + #[error("The BTC header does not satisfy the difficulty requirement or is not consecutive")] + BTCHeaderError {}, + + #[error("The BTC header with height {height} is not found in the storage")] + BTCHeaderNotFoundError { height: u32 }, + + #[error("The BTC height with hash {hash} is not found in the storage")] + BTCHeightNotFoundError { hash: String }, + + #[error("The BTC header info cumulative work encoding is wrong")] + BTCWrongCumulativeWorkEncoding {}, + + #[error("The BTC header info {0} cumulative work is wrong. Expected {1}, got {2}")] + BTCWrongCumulativeWork(usize, Work, Work), + + #[error("The BTC header info {0} height is wrong. Expected {1}, got {2}")] + BTCWrongHeight(usize, u32, u32), + + #[error("The new chain's work ({0}), is not better than the current chain's work ({1})")] + BTCChainWithNotEnoughWork(Work, Work), +} diff --git a/contracts/btc-light-client/src/lib.rs b/contracts/btc-light-client/src/lib.rs new file mode 100644 index 0000000..b713b5c --- /dev/null +++ b/contracts/btc-light-client/src/lib.rs @@ -0,0 +1,46 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult}; + +use babylon_bindings::BabylonMsg; + +use crate::error::ContractError; +pub use crate::msg::ExecuteMsg; +use crate::msg::InstantiateMsg; +use crate::msg::QueryMsg; + +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; +pub mod utils; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result, ContractError> { + contract::instantiate(deps, env, info, msg) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + contract::query(deps, env, msg) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, env: Env, msg: Empty) -> Result, ContractError> { + contract::migrate(deps, env, msg) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + contract::execute(deps, env, info, msg) +} diff --git a/contracts/btc-light-client/src/msg/btc_header.rs b/contracts/btc-light-client/src/msg/btc_header.rs new file mode 100644 index 0000000..d654d39 --- /dev/null +++ b/contracts/btc-light-client/src/msg/btc_header.rs @@ -0,0 +1,178 @@ +use std::str::{from_utf8, FromStr}; + +use cosmwasm_schema::cw_serde; + +use babylon_bitcoin::hash_types::TxMerkleNode; +use babylon_bitcoin::{BlockHash, BlockHeader}; +use babylon_proto::babylon::btclightclient::v1::{BtcHeaderInfo, BtcHeaderInfoResponse}; + +use crate::error::ContractError; + +/// Bitcoin header. +/// +/// Contains all the block's information except the actual transactions, but +/// including a root of a [merkle tree] committing to all transactions in the block. +/// +/// This struct is for use in RPC requests and responses. It has convenience trait impls to convert +/// to the internal representation (`BlockHeader`), and to the Babylon extended representation +/// (`BtcHeaderInfo`). +/// Adapted from `BlockHeader`. +#[cw_serde] +pub struct BtcHeader { + /// Originally protocol version, but repurposed for soft-fork signaling. + /// + /// ### Relevant BIPs + /// + /// * [BIP9 - Version bits with timeout and delay](https://github.com/bitcoin/bips/blob/master/bip-0009.mediawiki) (current usage) + /// * [BIP34 - Block v2, Height in Coinbase](https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki) + pub version: i32, + /// Reference to the previous block in the chain. + /// Encoded as a (byte-reversed) hex string. + pub prev_blockhash: String, + /// The root hash of the merkle tree of transactions in the block. + /// Encoded as a (byte-reversed) hex string. + pub merkle_root: String, + pub time: u32, + /// The target value below which the blockhash must lie, encoded as a + /// a float (with well-defined rounding, of course). + pub bits: u32, + /// The nonce, selected to obtain a low enough blockhash. + pub nonce: u32, +} + +impl BtcHeader { + pub fn to_btc_header_info( + &self, + prev_height: u32, + prev_work: babylon_bitcoin::Work, + ) -> Result { + let block_header: BlockHeader = self.try_into()?; + let total_work = prev_work + block_header.work(); + // To be able to print the decimal repr of the number + let total_work_cw = cosmwasm_std::Uint256::from_be_bytes(total_work.to_be_bytes()); + + Ok(BtcHeaderInfo { + header: ::prost::bytes::Bytes::from(babylon_bitcoin::serialize(&block_header)), + hash: ::prost::bytes::Bytes::from(babylon_bitcoin::serialize( + &block_header.block_hash(), + )), + height: prev_height + 1, + work: prost::bytes::Bytes::from(total_work_cw.to_string()), + }) + } +} + +/// Try to convert &BtcHeaderInfo to/into BtcHeader +impl TryFrom<&BtcHeaderInfo> for BtcHeader { + type Error = ContractError; + fn try_from(btc_header_info: &BtcHeaderInfo) -> Result { + let block_header: BlockHeader = babylon_bitcoin::deserialize(&btc_header_info.header) + .map_err(|_| ContractError::BTCHeaderDecodeError {})?; + Ok(Self { + version: block_header.version.to_consensus(), + prev_blockhash: block_header.prev_blockhash.to_string(), + merkle_root: block_header.merkle_root.to_string(), + time: block_header.time, + bits: block_header.bits.to_consensus(), + nonce: block_header.nonce, + }) + } +} + +/// Try to convert BtcHeaderInfo to/into BtcHeader +impl TryFrom for BtcHeader { + type Error = ContractError; + fn try_from(btc_header_info: BtcHeaderInfo) -> Result { + Self::try_from(&btc_header_info) + } +} + +/// Try to convert &BtcHeaderInfoResponse to/into BtcHeader +impl TryFrom<&BtcHeaderInfoResponse> for BtcHeader { + type Error = ContractError; + fn try_from(btc_header_info_response: &BtcHeaderInfoResponse) -> Result { + let block_header: BlockHeader = + babylon_bitcoin::deserialize(&hex::decode(&btc_header_info_response.header_hex)?) + .map_err(|_| ContractError::BTCHeaderDecodeError {})?; + Ok(Self { + version: block_header.version.to_consensus(), + prev_blockhash: block_header.prev_blockhash.to_string(), + merkle_root: block_header.merkle_root.to_string(), + time: block_header.time, + bits: block_header.bits.to_consensus(), + nonce: block_header.nonce, + }) + } +} + +/// Try to convert BtcHeaderInfoResponse to/into BtcHeader +impl TryFrom for BtcHeader { + type Error = ContractError; + fn try_from(btc_header_info_response: BtcHeaderInfoResponse) -> Result { + Self::try_from(&btc_header_info_response) + } +} + +/// Try to convert &BtcHeader to/into BlockHeader +impl TryFrom<&BtcHeader> for BlockHeader { + type Error = ContractError; + fn try_from(btc_header: &BtcHeader) -> Result { + Ok(BlockHeader { + version: babylon_bitcoin::Version::from_consensus(btc_header.version), + prev_blockhash: BlockHash::from_str(&btc_header.prev_blockhash)?, + merkle_root: TxMerkleNode::from_str(&btc_header.merkle_root)?, + time: btc_header.time, + bits: babylon_bitcoin::CompactTarget::from_consensus(btc_header.bits), + nonce: btc_header.nonce, + }) + } +} + +/// Try to convert BtcHeader to/into BlockHeader +impl TryFrom for BlockHeader { + type Error = ContractError; + fn try_from(btc_header: BtcHeader) -> Result { + Self::try_from(&btc_header) + } +} + +/// Response for a BTC header query +#[cw_serde] +pub struct BtcHeaderResponse { + pub header: BtcHeader, + pub hash: String, + pub height: u32, + pub work: String, +} + +impl TryFrom<&BtcHeaderInfo> for BtcHeaderResponse { + type Error = ContractError; + fn try_from(btc_header_info: &BtcHeaderInfo) -> Result { + let header = BtcHeader::try_from(btc_header_info)?; + let block_header: BlockHeader = babylon_bitcoin::deserialize(&btc_header_info.header) + .map_err(|_| ContractError::BTCHeaderDecodeError {})?; + Ok(Self { + header, + hash: block_header.block_hash().to_string(), + height: btc_header_info.height, + work: from_utf8(&btc_header_info.work)?.to_string(), + }) + } +} + +/// Response for a BTC headers query +#[cw_serde] +pub struct BtcHeadersResponse { + pub headers: Vec, +} + +impl TryFrom<&[BtcHeaderInfo]> for BtcHeadersResponse { + type Error = ContractError; + fn try_from(btc_header_infos: &[BtcHeaderInfo]) -> Result { + let headers = btc_header_infos + .iter() + .map(TryInto::try_into) + .collect::, _>>()?; + Ok(Self { headers }) + } +} diff --git a/contracts/btc-light-client/src/msg/contract.rs b/contracts/btc-light-client/src/msg/contract.rs new file mode 100644 index 0000000..08dbecb --- /dev/null +++ b/contracts/btc-light-client/src/msg/contract.rs @@ -0,0 +1,48 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Binary, StdError, StdResult}; + +use crate::msg::btc_header::{BtcHeader, BtcHeaderResponse, BtcHeadersResponse}; + +#[cw_serde] +pub struct InstantiateMsg { + pub network: babylon_bitcoin::chain_params::Network, + pub btc_confirmation_depth: u32, +} + +impl InstantiateMsg { + pub fn validate(&self) -> StdResult<()> { + if self.btc_confirmation_depth == 0 { + return Err(StdError::generic_err( + "BTC confirmation depth must be greater than 0", + )); + } + Ok(()) + } +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Initialize the BTC light client with a list of consecutive headers + InitBtcLightClient { headers: Vec }, + /// Update the BTC light client with a list of consecutive headers + UpdateBtcLightClient { headers: Vec }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(BtcHeaderResponse)] + BtcBaseHeader {}, + #[returns(BtcHeaderResponse)] + BtcTipHeader {}, + #[returns(BtcHeaderResponse)] + BtcHeader { height: u32 }, + #[returns(BtcHeaderResponse)] + BtcHeaderByHash { hash: String }, + #[returns(BtcHeadersResponse)] + BtcHeaders { + start_after: Option, + limit: Option, + reverse: Option, + }, +} diff --git a/contracts/btc-light-client/src/msg/mod.rs b/contracts/btc-light-client/src/msg/mod.rs new file mode 100644 index 0000000..dd11bf5 --- /dev/null +++ b/contracts/btc-light-client/src/msg/mod.rs @@ -0,0 +1,4 @@ +pub mod btc_header; +pub mod contract; + +pub use contract::{ExecuteMsg, InstantiateMsg, QueryMsg}; diff --git a/contracts/btc-light-client/src/state/btc_light_client.rs b/contracts/btc-light-client/src/state/btc_light_client.rs new file mode 100644 index 0000000..41494ca --- /dev/null +++ b/contracts/btc-light-client/src/state/btc_light_client.rs @@ -0,0 +1,136 @@ +use babylon_proto::babylon::btclightclient::v1::BtcHeaderInfo; +use cosmwasm_std::Order::{Ascending, Descending}; +use cosmwasm_std::{StdResult, Storage}; +use cw_storage_plus::{Bound, Item, Map}; +use hex::ToHex; +use prost::Message; + +use crate::error::ContractError; + +pub const BTC_TIP_KEY: &str = "btc_lc_tip"; + +pub const BTC_HEADERS: Map> = Map::new("btc_lc_headers"); +pub const BTC_HEADER_BASE: Item> = Item::new("btc_lc_header_base"); +pub const BTC_HEIGHTS: Map<&[u8], u32> = Map::new("btc_lc_heights"); +pub const BTC_TIP: Item> = Item::new(BTC_TIP_KEY); + +// getters for storages + +// is_initialized checks if the BTC light client has been initialised or not +// the check is done by checking the existence of the base header +pub fn is_initialized(storage: &mut dyn Storage) -> bool { + BTC_HEADER_BASE.load(storage).is_ok() +} + +// getter/setter for base header +pub fn get_base_header(storage: &dyn Storage) -> Result { + // NOTE: if init is successful, then base header is guaranteed to be in storage and decodable + let base_header_bytes = BTC_HEADER_BASE.load(storage)?; + BtcHeaderInfo::decode(base_header_bytes.as_slice()).map_err(ContractError::DecodeError) +} + +pub fn set_base_header(storage: &mut dyn Storage, base_header: &BtcHeaderInfo) -> StdResult<()> { + let base_header_bytes = base_header.encode_to_vec(); + BTC_HEADER_BASE.save(storage, &base_header_bytes) +} + +// getter/setter for chain tip +pub fn get_tip(storage: &dyn Storage) -> Result { + let tip_bytes = BTC_TIP.load(storage)?; + // NOTE: if init is successful, then tip header is guaranteed to be correct + BtcHeaderInfo::decode(tip_bytes.as_slice()).map_err(ContractError::DecodeError) +} + +pub fn set_tip(storage: &mut dyn Storage, tip: &BtcHeaderInfo) -> StdResult<()> { + let tip_bytes = &tip.encode_to_vec(); + BTC_TIP.save(storage, tip_bytes) +} + +// insert_headers inserts BTC headers that have passed the verification to the header chain +// storages, including +// - insert all headers +// - insert all hash-to-height indices +pub fn insert_headers(storage: &mut dyn Storage, new_headers: &[BtcHeaderInfo]) -> StdResult<()> { + // Add all the headers by height + for new_header in new_headers.iter() { + // insert header + let hash_bytes: &[u8] = new_header.hash.as_ref(); + let header_bytes = new_header.encode_to_vec(); + BTC_HEADERS.save(storage, new_header.height, &header_bytes)?; + BTC_HEIGHTS.save(storage, hash_bytes, &new_header.height)?; + } + Ok(()) +} + +// remove_headers removes BTC headers from the header chain storages, including +// - remove all hash-to-height indices +pub fn remove_headers( + storage: &mut dyn Storage, + tip_header: &BtcHeaderInfo, + parent_header: &BtcHeaderInfo, +) -> Result<(), ContractError> { + // Remove all the headers by hash starting from the tip, until hitting the parent header + let mut rem_header = tip_header.clone(); + while rem_header.hash != parent_header.hash { + // Remove header from storage + BTC_HEIGHTS.remove(storage, rem_header.hash.as_ref()); + // Obtain the previous header + rem_header = get_header(storage, rem_header.height - 1)?; + } + Ok(()) +} + +// get_header retrieves the BTC header of a given height +pub fn get_header(storage: &dyn Storage, height: u32) -> Result { + // Try to find the header with the given hash + let header_bytes = BTC_HEADERS + .load(storage, height) + .map_err(|_| ContractError::BTCHeaderNotFoundError { height })?; + + BtcHeaderInfo::decode(header_bytes.as_slice()).map_err(ContractError::DecodeError) +} + +// get_header_by_hash retrieves the BTC header of a given hash +pub fn get_header_by_hash( + storage: &dyn Storage, + hash: &[u8], +) -> Result { + // Try to find the height with the given hash + let height = + BTC_HEIGHTS + .load(storage, hash) + .map_err(|_| ContractError::BTCHeightNotFoundError { + hash: hash.encode_hex::(), + })?; + + get_header(storage, height) +} + +// get_headers retrieves BTC headers in a given range +pub fn get_headers( + storage: &dyn Storage, + start_after: Option, + limit: Option, + reverse: Option, +) -> Result, ContractError> { + let limit = limit.unwrap_or(10) as usize; + let reverse = reverse.unwrap_or(false); + + let (start, end, order) = match (start_after, reverse) { + (Some(start), true) => (None, Some(Bound::exclusive(start)), Descending), + (Some(start), false) => (Some(Bound::exclusive(start)), None, Ascending), + (None, true) => (None, None, Descending), + (None, false) => (None, None, Ascending), + }; + + let headers = BTC_HEADERS + .range(storage, start, end, order) + .take(limit) + .map(|item| { + let (_, header_bytes) = item?; + BtcHeaderInfo::decode(header_bytes.as_slice()).map_err(ContractError::DecodeError) + }) + .collect::, _>>()?; + + Ok(headers) +} diff --git a/contracts/btc-light-client/src/state/config.rs b/contracts/btc-light-client/src/state/config.rs new file mode 100644 index 0000000..5511170 --- /dev/null +++ b/contracts/btc-light-client/src/state/config.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::StdResult; +use cw_storage_plus::Item; + +#[cw_serde] +pub struct Config { + pub network: babylon_bitcoin::chain_params::Network, + pub btc_confirmation_depth: u32, +} + +pub const CONFIG: Item = Item::new("config"); diff --git a/contracts/btc-light-client/src/state/mod.rs b/contracts/btc-light-client/src/state/mod.rs new file mode 100644 index 0000000..63ea3fc --- /dev/null +++ b/contracts/btc-light-client/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod btc_light_client; +pub mod config; + +pub use btc_light_client::{get_base_header, get_header, get_header_by_hash, get_headers, get_tip}; +pub use config::{Config, CONFIG}; diff --git a/contracts/btc-light-client/src/test_utils.rs b/contracts/btc-light-client/src/test_utils.rs new file mode 100644 index 0000000..8b4da00 --- /dev/null +++ b/contracts/btc-light-client/src/test_utils.rs @@ -0,0 +1,21 @@ +use cosmwasm_std::testing::mock_dependencies; +use cosmwasm_std::Storage; + +use crate::state::config::{Config, CONFIG}; +use babylon_bitcoin::chain_params::Network; + +pub(crate) fn setup(storage: &mut dyn Storage) -> u32 { + // set config first + let w: u32 = 2; + let cfg = Config { + network: Network::Regtest, + btc_confirmation_depth: 1, + }; + CONFIG.save(storage, &cfg).unwrap(); + w +} + +pub(crate) fn mock_storage() -> Box { + let deps = mock_dependencies(); + Box::new(deps.storage) +} diff --git a/contracts/btc-light-client/src/utils/btc_light_client.rs b/contracts/btc-light-client/src/utils/btc_light_client.rs new file mode 100644 index 0000000..22bcd23 --- /dev/null +++ b/contracts/btc-light-client/src/utils/btc_light_client.rs @@ -0,0 +1,69 @@ +use crate::error; +use babylon_bitcoin::{BlockHeader, Work}; +use babylon_proto::babylon::btclightclient::v1::BtcHeaderInfo; +use cosmwasm_std::{StdResult, Uint256}; +use std::str::{from_utf8, FromStr}; + +/// verify_headers verifies whether `new_headers` are valid consecutive headers +/// after the given `first_header` +pub fn verify_headers( + btc_network: &babylon_bitcoin::chain_params::Params, + first_header: &BtcHeaderInfo, + new_headers: &[BtcHeaderInfo], +) -> Result<(), error::ContractError> { + // verify each new header iteratively + let mut last_header = first_header.clone(); + let mut cum_work_old = total_work(&last_header)?; + for (i, new_header) in new_headers.iter().enumerate() { + // decode last header to rust-bitcoin's type + let last_btc_header: BlockHeader = + babylon_bitcoin::deserialize(last_header.header.as_ref()) + .map_err(|_| error::ContractError::BTCHeaderDecodeError {})?; + // decode this header to rust-bitcoin's type + let btc_header: BlockHeader = babylon_bitcoin::deserialize(new_header.header.as_ref()) + .map_err(|_| error::ContractError::BTCHeaderDecodeError {})?; + + // validate whether btc_header extends last_btc_header + babylon_bitcoin::pow::verify_next_header_pow(btc_network, &last_btc_header, &btc_header) + .map_err(|_| error::ContractError::BTCHeaderError {})?; + + let header_work = btc_header.work(); + let cum_work = total_work(new_header)?; + + // Validate cumulative work + if cum_work_old + header_work != cum_work { + return Err(error::ContractError::BTCWrongCumulativeWork( + i, + cum_work_old + header_work, + cum_work, + )); + } + cum_work_old = cum_work; + // Validate height + if new_header.height != last_header.height + 1 { + return Err(error::ContractError::BTCWrongHeight( + i, + last_header.height + 1, + new_header.height, + )); + } + + // this header is good, verify the next one + last_header = new_header.clone(); + } + Ok(()) +} + +/// Zero work helper / constructor +pub fn zero_work() -> Work { + Work::from_be_bytes(Uint256::zero().to_be_bytes()) +} + +/// Returns the total work of the given header. +/// The total work is the cumulative work of the given header and all of its ancestors. +pub fn total_work(header: &BtcHeaderInfo) -> StdResult { + // TODO: Use a better encoding (String / binary) + let header_work = from_utf8(header.work.as_ref())?; + let header_work_cw = cosmwasm_std::Uint256::from_str(header_work)?; + Ok(Work::from_be_bytes(header_work_cw.to_be_bytes())) +} diff --git a/contracts/btc-light-client/src/utils/mod.rs b/contracts/btc-light-client/src/utils/mod.rs new file mode 100644 index 0000000..91f39b8 --- /dev/null +++ b/contracts/btc-light-client/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod btc_light_client;