diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index 2925280f8..be9f53f65 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -8198,7 +8198,7 @@ pub struct BrkClient { impl BrkClient { /// Client version. - pub const VERSION: &'static str = "v0.3.0-alpha.2"; + pub const VERSION: &'static str = "v0.3.0-alpha.3"; /// Create a new client with the given base URL. pub fn new(base_url: impl Into) -> Self { diff --git a/crates/brk_query/src/impl/block/info.rs b/crates/brk_query/src/impl/block/info.rs index b3eb52e8e..9aa38c5f7 100644 --- a/crates/brk_query/src/impl/block/info.rs +++ b/crates/brk_query/src/impl/block/info.rs @@ -4,7 +4,7 @@ use brk_error::{Error, Result}; use brk_reader::Reader; use brk_types::{ BlkPosition, BlockExtras, BlockHash, BlockHashPrefix, BlockHeader, BlockInfo, BlockInfoV1, - BlockPool, FeeRate, Height, Sats, Timestamp, TxIndex, VSize, pools, + BlockPool, FeeRate, Height, PoolSlug, Sats, Timestamp, TxIndex, VSize, pools, }; use vecdb::{AnyVec, ReadableVec, VecIndex}; @@ -126,15 +126,15 @@ impl Query { height: Height::from(begin + i), version: header.version, timestamp: timestamps[i], + bits: header.bits, + nonce: header.nonce, + difficulty: *difficulties[i], + merkle_root: header.merkle_root, tx_count, size: *sizes[i], weight: weights[i], - merkle_root: header.merkle_root, previous_block_hash: header.previous_block_hash, median_time, - nonce: header.nonce, - bits: header.bits, - difficulty: *difficulties[i], }); } @@ -251,14 +251,6 @@ impl Query { let fa_pct90 = fad.pct90.height.collect_range_at(begin, end); let fa_max = fad.max.height.collect_range_at(begin, end); - // Bulk read tx positions range covering all coinbase txs (first tx of each block) - let tx_pos_begin = first_tx_indexes[0].to_usize(); - let tx_pos_end = first_tx_indexes[count - 1].to_usize() + 1; - let all_tx_positions = indexer - .vecs - .transactions - .position - .collect_range_at(tx_pos_begin, tx_pos_end); // Bulk read median time window let median_start = begin.saturating_sub(10); @@ -293,16 +285,25 @@ impl Query { let pool_slug = pool_slugs[i]; let pool = all_pools.get(pool_slug); + let varint_len = Self::compact_size_len(tx_count); + let coinbase_offset = HEADER_SIZE as u32 + varint_len; + let coinbase_pos = positions[i] + coinbase_offset; + let coinbase_read_len = size as usize - coinbase_offset as usize; + let ( coinbase_raw, coinbase_address, coinbase_addresses, coinbase_signature, coinbase_signature_ascii, - ) = Self::parse_coinbase_tx( - reader, - all_tx_positions[first_tx_indexes[i].to_usize() - tx_pos_begin], - ); + scriptsig_bytes, + ) = Self::parse_coinbase_tx(reader, coinbase_pos, coinbase_read_len); + + let miner_names = if pool_slug == PoolSlug::Ocean { + Self::parse_datum_miner_names(&scriptsig_bytes) + } else { + None + }; let median_time = Self::compute_median_time(&median_timestamps, begin + i, median_start); @@ -312,15 +313,15 @@ impl Query { height: Height::from(begin + i), version: header.version, timestamp: timestamps[i], + bits: header.bits, + nonce: header.nonce, + difficulty: *difficulties[i], + merkle_root: header.merkle_root, tx_count, size, weight, - merkle_root: header.merkle_root, previous_block_hash: header.previous_block_hash, median_time, - nonce: header.nonce, - bits: header.bits, - difficulty: *difficulties[i], }; let total_input_amt = input_volumes[i]; @@ -343,7 +344,7 @@ impl Query { id: pool.unique_id(), name: pool.name.to_string(), slug: pool_slug, - miner_names: None, + miner_names, }, avg_fee: Sats::from(if non_coinbase > 0 { total_fees_u64 / non_coinbase @@ -383,6 +384,8 @@ impl Query { total_input_amt, virtual_size: vsize as f64, price: prices[i], + orphans: vec![], + first_seen: None, }; blocks.push(BlockInfoV1 { info, extras }); @@ -448,38 +451,94 @@ impl Query { Timestamp::from(sorted[sorted.len() / 2]) } + fn compact_size_len(tx_count: u32) -> u32 { + if tx_count <= 0xFC { + 1 + } else if tx_count <= 0xFFFF { + 3 + } else { + 5 + } + } + + /// Parse OCEAN DATUM protocol miner names from coinbase scriptsig. + /// Skips BIP34 height push, reads tag payload, splits on 0x0F delimiter. + fn parse_datum_miner_names(scriptsig: &[u8]) -> Option> { + if scriptsig.is_empty() { + return None; + } + + // Skip BIP34 height push: first byte is length of height data + let height_len = scriptsig[0] as usize; + let mut tag_len_idx = 1 + height_len; + if tag_len_idx >= scriptsig.len() { + return None; + } + + // Read tags payload length (may use OP_PUSHDATA1 for >75 bytes) + let mut tags_len = scriptsig[tag_len_idx] as usize; + if tags_len == 0x4c { + tag_len_idx += 1; + if tag_len_idx >= scriptsig.len() { + return None; + } + tags_len = scriptsig[tag_len_idx] as usize; + } + + let tag_start = tag_len_idx + 1; + if tag_start + tags_len > scriptsig.len() { + return None; + } + + // Decode tag bytes, strip nulls, split on 0x0F, keep only alphanumeric + space + let tag_bytes = &scriptsig[tag_start..tag_start + tags_len]; + let tag_string: String = tag_bytes + .iter() + .filter(|&&b| b != 0x00) + .map(|&b| b as char) + .collect(); + + let names: Vec = tag_string + .split('\x0f') + .map(|s| { + s.chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == ' ') + .collect::() + }) + .filter(|s| !s.trim().is_empty()) + .collect(); + + if names.is_empty() { None } else { Some(names) } + } + fn parse_coinbase_tx( reader: &Reader, position: BlkPosition, - ) -> (String, Option, Vec, String, String) { - let raw_bytes = match reader.read_raw_bytes(position, 1000) { + len: usize, + ) -> (String, Option, Vec, String, String, Vec) { + let empty = (String::new(), None, vec![], String::new(), String::new(), vec![]); + let raw_bytes = match reader.read_raw_bytes(position, len) { Ok(bytes) => bytes, - Err(_) => return (String::new(), None, vec![], String::new(), String::new()), + Err(_) => return empty, }; let tx = match bitcoin::Transaction::consensus_decode(&mut raw_bytes.as_slice()) { Ok(tx) => tx, - Err(_) => return (String::new(), None, vec![], String::new(), String::new()), + Err(_) => return empty, }; - let coinbase_raw = tx + let scriptsig_bytes: Vec = tx .input .first() - .map(|input| input.script_sig.as_bytes().to_lower_hex_string()) + .map(|input| input.script_sig.as_bytes().to_vec()) .unwrap_or_default(); - let coinbase_signature_ascii = tx - .input - .first() - .map(|input| { - input - .script_sig - .as_bytes() - .iter() - .map(|&b| b as char) - .collect::() - }) - .unwrap_or_default(); + let coinbase_raw = scriptsig_bytes.to_lower_hex_string(); + + let coinbase_signature_ascii: String = scriptsig_bytes + .iter() + .map(|&b| b as char) + .collect(); let coinbase_addresses: Vec = tx .output @@ -494,7 +553,12 @@ impl Query { let coinbase_signature = tx .output - .first() + .iter() + .find(|output| { + bitcoin::Address::from_script(&output.script_pubkey, bitcoin::Network::Bitcoin) + .is_ok() + }) + .or(tx.output.first()) .map(|output| output.script_pubkey.to_asm_string()) .unwrap_or_default(); @@ -504,6 +568,7 @@ impl Query { coinbase_addresses, coinbase_signature, coinbase_signature_ascii, + scriptsig_bytes, ) } } diff --git a/crates/brk_query/src/impl/block/txs.rs b/crates/brk_query/src/impl/block/txs.rs index 34199d249..9cb9bc8ad 100644 --- a/crates/brk_query/src/impl/block/txs.rs +++ b/crates/brk_query/src/impl/block/txs.rs @@ -170,7 +170,16 @@ impl Query { .collect(); let weight = Weight::from(tx.weight()); - let total_sigop_cost = tx.total_sigop_cost(|_| None); + let total_sigop_cost = tx.total_sigop_cost(|outpoint| { + tx.input + .iter() + .position(|i| i.previous_output == *outpoint) + .and_then(|j| input[j].prevout.as_ref()) + .map(|p| bitcoin::TxOut { + value: bitcoin::Amount::from_sat(u64::from(p.value)), + script_pubkey: p.script_pubkey.clone(), + }) + }); let output: Vec = tx.output.into_iter().map(TxOut::from).collect(); let mut transaction = Transaction { diff --git a/crates/brk_types/src/block_extras.rs b/crates/brk_types/src/block_extras.rs index cf50f42e7..19081b6a0 100644 --- a/crates/brk_types/src/block_extras.rs +++ b/crates/brk_types/src/block_extras.rs @@ -107,6 +107,13 @@ pub struct BlockExtras { #[serde(rename = "virtualSize")] pub virtual_size: f64, + /// Timestamp when the block was first seen (always null, not yet supported) + #[serde(rename = "firstSeen")] + pub first_seen: Option, + + /// Orphaned blocks (always empty) + pub orphans: Vec, + /// USD price at block height pub price: Dollars, } diff --git a/crates/brk_types/src/block_info.rs b/crates/brk_types/src/block_info.rs index 3ed62e094..2356a2a98 100644 --- a/crates/brk_types/src/block_info.rs +++ b/crates/brk_types/src/block_info.rs @@ -14,24 +14,24 @@ pub struct BlockInfo { pub version: u32, /// Block timestamp (Unix time) pub timestamp: Timestamp, + /// Compact target (bits) + pub bits: u32, + /// Nonce + pub nonce: u32, + /// Block difficulty + pub difficulty: f64, + /// Merkle root of the transaction tree + pub merkle_root: String, /// Number of transactions pub tx_count: u32, /// Block size in bytes pub size: u64, /// Block weight in weight units pub weight: Weight, - /// Merkle root of the transaction tree - pub merkle_root: String, /// Previous block hash #[serde(rename = "previousblockhash")] pub previous_block_hash: BlockHash, /// Median time of the last 11 blocks #[serde(rename = "mediantime")] pub median_time: Timestamp, - /// Nonce - pub nonce: u32, - /// Compact target (bits) - pub bits: u32, - /// Block difficulty - pub difficulty: f64, } diff --git a/crates/brk_types/src/block_pool.rs b/crates/brk_types/src/block_pool.rs index 0d36d122d..f35d25af4 100644 --- a/crates/brk_types/src/block_pool.rs +++ b/crates/brk_types/src/block_pool.rs @@ -16,6 +16,6 @@ pub struct BlockPool { /// URL-friendly pool identifier pub slug: PoolSlug, - /// Alternative miner names (if identified) - pub miner_names: Option, + /// Miner name tags found in coinbase scriptsig + pub miner_names: Option>, } diff --git a/crates/brk_types/src/output_type.rs b/crates/brk_types/src/output_type.rs index b55e4ed0a..7a191e160 100644 --- a/crates/brk_types/src/output_type.rs +++ b/crates/brk_types/src/output_type.rs @@ -31,9 +31,17 @@ pub enum OutputType { P2PKH, P2MS, P2SH, + #[serde(rename = "op_return")] + #[strum(serialize = "op_return")] OpReturn, + #[serde(rename = "v0_p2wpkh")] + #[strum(serialize = "v0_p2wpkh")] P2WPKH, + #[serde(rename = "v0_p2wsh")] + #[strum(serialize = "v0_p2wsh")] P2WSH, + #[serde(rename = "v1_p2tr")] + #[strum(serialize = "v1_p2tr")] P2TR, P2A, Empty, diff --git a/crates/brk_types/src/txin.rs b/crates/brk_types/src/txin.rs index 17f449fa3..50f6f1fc5 100644 --- a/crates/brk_types/src/txin.rs +++ b/crates/brk_types/src/txin.rs @@ -10,7 +10,7 @@ pub struct TxIn { #[schemars(example = "0000000000000000000000000000000000000000000000000000000000000000")] pub txid: Txid, - /// Output index being spent + /// Output index being spent (u16: coinbase is 65535, mempool.space uses u32: 4294967295) #[schemars(example = 0)] pub vout: Vout, @@ -54,8 +54,8 @@ impl Serialize for TxIn { let has_witness = !self.witness.is_empty(); let has_scriptsig = !self.script_sig.is_empty(); - // P2SH-wrapped SegWit: both scriptsig and witness present - let inner_redeem = if has_scriptsig && has_witness { + // P2SH-wrapped SegWit: both scriptsig and witness present (not coinbase) + let inner_redeem = if has_scriptsig && has_witness && !self.is_coinbase { self.script_sig .redeem_script() .map(|s| s.to_asm_string()) @@ -64,8 +64,8 @@ impl Serialize for TxIn { String::new() }; - // P2WSH: witness has >2 items, last is the witnessScript - let inner_witness = if has_witness && !has_scriptsig && self.witness.len() > 2 { + // P2WSH / P2SH-P2WSH: witness has >2 items, last is the witnessScript + let inner_witness = if has_witness && self.witness.len() > 2 { if let Some(last) = self.witness.last() { let bytes: Vec = bitcoin::hex::FromHex::from_hex(last).unwrap_or_default(); ScriptBuf::from(bytes).to_asm_string() diff --git a/crates/brk_types/src/txout.rs b/crates/brk_types/src/txout.rs index 10986ce55..b5b753e0b 100644 --- a/crates/brk_types/src/txout.rs +++ b/crates/brk_types/src/txout.rs @@ -92,11 +92,15 @@ impl Serialize for TxOut { where S: Serializer, { - let mut state = serializer.serialize_struct("TxOut", 5)?; + let addr = self.addr(); + let field_count = if addr.is_some() { 5 } else { 4 }; + let mut state = serializer.serialize_struct("TxOut", field_count)?; state.serialize_field("scriptpubkey", &self.script_pubkey.to_hex_string())?; state.serialize_field("scriptpubkey_asm", &self.script_pubkey_asm())?; state.serialize_field("scriptpubkey_type", &self.type_())?; - state.serialize_field("scriptpubkey_address", &self.addr())?; + if let Some(addr) = &addr { + state.serialize_field("scriptpubkey_address", addr)?; + } state.serialize_field("value", &self.value)?; state.end() } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index a839dd480..a8d32eef0 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -139,6 +139,8 @@ * @property {number} utxoSetSize - Total UTXO set size at this height * @property {Sats} totalInputAmt - Total input amount in satoshis * @property {number} virtualSize - Virtual size in vbytes + * @property {?number=} firstSeen - Timestamp when the block was first seen (always null, not yet supported) + * @property {string[]} orphans - Orphaned blocks (always empty) * @property {Dollars} price - USD price at block height */ /** @@ -177,15 +179,15 @@ * @property {Height} height - Block height * @property {number} version - Block version * @property {Timestamp} timestamp - Block timestamp (Unix time) + * @property {number} bits - Compact target (bits) + * @property {number} nonce - Nonce + * @property {number} difficulty - Block difficulty + * @property {string} merkleRoot - Merkle root of the transaction tree * @property {number} txCount - Number of transactions * @property {number} size - Block size in bytes * @property {Weight} weight - Block weight in weight units - * @property {string} merkleRoot - Merkle root of the transaction tree * @property {BlockHash} previousblockhash - Previous block hash * @property {Timestamp} mediantime - Median time of the last 11 blocks - * @property {number} nonce - Nonce - * @property {number} bits - Compact target (bits) - * @property {number} difficulty - Block difficulty */ /** * Block information with extras, matching mempool.space /api/v1/blocks @@ -195,15 +197,15 @@ * @property {Height} height - Block height * @property {number} version - Block version * @property {Timestamp} timestamp - Block timestamp (Unix time) + * @property {number} bits - Compact target (bits) + * @property {number} nonce - Nonce + * @property {number} difficulty - Block difficulty + * @property {string} merkleRoot - Merkle root of the transaction tree * @property {number} txCount - Number of transactions * @property {number} size - Block size in bytes * @property {Weight} weight - Block weight in weight units - * @property {string} merkleRoot - Merkle root of the transaction tree * @property {BlockHash} previousblockhash - Previous block hash * @property {Timestamp} mediantime - Median time of the last 11 blocks - * @property {number} nonce - Nonce - * @property {number} bits - Compact target (bits) - * @property {number} difficulty - Block difficulty * @property {BlockExtras} extras - Extended block data */ /** @@ -213,7 +215,7 @@ * @property {number} id - Unique pool identifier * @property {string} name - Pool name * @property {PoolSlug} slug - URL-friendly pool identifier - * @property {?string=} minerNames - Alternative miner names (if identified) + * @property {?string[]=} minerNames - Miner name tags found in coinbase scriptsig */ /** * A single block rewards data point. @@ -672,7 +674,7 @@ /** * Type (P2PKH, P2WPKH, P2SH, P2TR, etc.) * - * @typedef {("p2pk65"|"p2pk33"|"p2pkh"|"p2ms"|"p2sh"|"opreturn"|"p2wpkh"|"p2wsh"|"p2tr"|"p2a"|"empty"|"unknown")} OutputType + * @typedef {("p2pk65"|"p2pk33"|"p2pkh"|"p2ms"|"p2sh"|"op_return"|"v0_p2wpkh"|"v0_p2wsh"|"v1_p2tr"|"p2a"|"empty"|"unknown")} OutputType */ /** @typedef {TypeIndex} P2AAddrIndex */ /** @typedef {U8x2} P2ABytes */ @@ -1022,7 +1024,7 @@ * * @typedef {Object} TxIn * @property {Txid} txid - Transaction ID of the output being spent - * @property {Vout} vout - Output index being spent + * @property {Vout} vout - Output index being spent (u16: coinbase is 65535, mempool.space uses u32: 4294967295) * @property {(TxOut|null)=} prevout - Information about the previous output being spent * @property {string} scriptsig - Signature script (hex, for non-SegWit inputs) * @property {string} scriptsigAsm - Signature script in assembly format @@ -6564,7 +6566,7 @@ function createTransferPattern(client, acc) { * @extends BrkClientBase */ class BrkClient extends BrkClientBase { - VERSION = "v0.3.0-alpha.2"; + VERSION = "v0.3.0-alpha.3"; INDEXES = /** @type {const} */ ([ "minute10", diff --git a/modules/brk-client/package.json b/modules/brk-client/package.json index 0609001c0..c8d625439 100644 --- a/modules/brk-client/package.json +++ b/modules/brk-client/package.json @@ -40,5 +40,5 @@ "url": "git+https://github.com/bitcoinresearchkit/brk.git" }, "type": "module", - "version": "0.3.0-alpha.2" + "version": "0.3.0-alpha.3" } diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 05f53a86e..d2b526d0d 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -133,7 +133,7 @@ Open = Dollars OpReturnIndex = TypeIndex OutPoint = int # Type (P2PKH, P2WPKH, P2SH, P2TR, etc.) -OutputType = Literal["p2pk65", "p2pk33", "p2pkh", "p2ms", "p2sh", "opreturn", "p2wpkh", "p2wsh", "p2tr", "p2a", "empty", "unknown"] +OutputType = Literal["p2pk65", "p2pk33", "p2pkh", "p2ms", "p2sh", "op_return", "v0_p2wpkh", "v0_p2wsh", "v1_p2tr", "p2a", "empty", "unknown"] P2AAddrIndex = TypeIndex U8x2 = List[int] P2ABytes = U8x2 @@ -316,12 +316,12 @@ class BlockPool(TypedDict): id: Unique pool identifier name: Pool name slug: URL-friendly pool identifier - minerNames: Alternative miner names (if identified) + minerNames: Miner name tags found in coinbase scriptsig """ id: int name: str slug: PoolSlug - minerNames: Optional[str] + minerNames: Optional[List[str]] class BlockExtras(TypedDict): """ @@ -354,6 +354,8 @@ class BlockExtras(TypedDict): utxoSetSize: Total UTXO set size at this height totalInputAmt: Total input amount in satoshis virtualSize: Virtual size in vbytes + firstSeen: Timestamp when the block was first seen (always null, not yet supported) + orphans: Orphaned blocks (always empty) price: USD price at block height """ totalFees: Sats @@ -382,6 +384,8 @@ class BlockExtras(TypedDict): utxoSetSize: int totalInputAmt: Sats virtualSize: float + firstSeen: Optional[int] + orphans: List[str] price: Dollars class BlockFeesEntry(TypedDict): @@ -429,29 +433,29 @@ class BlockInfo(TypedDict): height: Block height version: Block version timestamp: Block timestamp (Unix time) + bits: Compact target (bits) + nonce: Nonce + difficulty: Block difficulty + merkle_root: Merkle root of the transaction tree tx_count: Number of transactions size: Block size in bytes weight: Block weight in weight units - merkle_root: Merkle root of the transaction tree previousblockhash: Previous block hash mediantime: Median time of the last 11 blocks - nonce: Nonce - bits: Compact target (bits) - difficulty: Block difficulty """ id: BlockHash height: Height version: int timestamp: Timestamp + bits: int + nonce: int + difficulty: float + merkle_root: str tx_count: int size: int weight: Weight - merkle_root: str previousblockhash: BlockHash mediantime: Timestamp - nonce: int - bits: int - difficulty: float class BlockInfoV1(TypedDict): """ @@ -462,30 +466,30 @@ class BlockInfoV1(TypedDict): height: Block height version: Block version timestamp: Block timestamp (Unix time) + bits: Compact target (bits) + nonce: Nonce + difficulty: Block difficulty + merkle_root: Merkle root of the transaction tree tx_count: Number of transactions size: Block size in bytes weight: Block weight in weight units - merkle_root: Merkle root of the transaction tree previousblockhash: Previous block hash mediantime: Median time of the last 11 blocks - nonce: Nonce - bits: Compact target (bits) - difficulty: Block difficulty extras: Extended block data """ id: BlockHash height: Height version: int timestamp: Timestamp + bits: int + nonce: int + difficulty: float + merkle_root: str tx_count: int size: int weight: Weight - merkle_root: str previousblockhash: BlockHash mediantime: Timestamp - nonce: int - bits: int - difficulty: float extras: BlockExtras class BlockRewardsEntry(TypedDict): @@ -1333,7 +1337,7 @@ class TxIn(TypedDict): Attributes: txid: Transaction ID of the output being spent - vout: Output index being spent + vout: Output index being spent (u16: coinbase is 65535, mempool.space uses u32: 4294967295) prevout: Information about the previous output being spent scriptsig: Signature script (hex, for non-SegWit inputs) scriptsig_asm: Signature script in assembly format @@ -6003,7 +6007,7 @@ class SeriesTree: class BrkClient(BrkClientBase): """Main BRK client with series tree and API methods.""" - VERSION = "v0.3.0-alpha.2" + VERSION = "v0.3.0-alpha.3" INDEXES = [ "minute10", diff --git a/packages/brk_client/pyproject.toml b/packages/brk_client/pyproject.toml index 2f75f7232..d6210e050 100644 --- a/packages/brk_client/pyproject.toml +++ b/packages/brk_client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brk-client" -version = "0.3.0-alpha.2" +version = "0.3.0-alpha.3" description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index" readme = "README.md" requires-python = ">=3.9"