mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 22:34:46 -07:00
global: fixes
This commit is contained in:
@@ -27,6 +27,7 @@ parking_lot = { workspace = true }
|
||||
# quickmatch = { path = "../../../quickmatch" }
|
||||
quickmatch = "0.4.0"
|
||||
rustc-hash = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true }
|
||||
vecdb = { workspace = true }
|
||||
|
||||
@@ -13,9 +13,7 @@ use crate::Query;
|
||||
|
||||
impl Query {
|
||||
pub fn addr(&self, addr: Addr) -> Result<AddrStats> {
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let stores = &indexer.stores;
|
||||
|
||||
let script = if let Ok(addr) = bitcoin::Address::from_str(&addr) {
|
||||
if !addr.is_valid_for_network(Network::Bitcoin) {
|
||||
@@ -34,13 +32,7 @@ impl Query {
|
||||
return Err(Error::InvalidAddr);
|
||||
};
|
||||
let hash = AddrHash::from(&bytes);
|
||||
|
||||
let Some(store) = stores.addr_type_to_addr_hash_to_addr_index.get(output_type) else {
|
||||
return Err(Error::InvalidAddr);
|
||||
};
|
||||
let Some(type_index) = store.get(&hash)?.map(|cow| cow.into_owned()) else {
|
||||
return Err(Error::UnknownAddr);
|
||||
};
|
||||
let type_index = self.type_index_for(output_type, &hash)?;
|
||||
|
||||
let any_addr_index = computer
|
||||
.distribution
|
||||
@@ -158,9 +150,8 @@ impl Query {
|
||||
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
|
||||
.collect())
|
||||
} else {
|
||||
let prefix = u32::from(type_index).to_be_bytes();
|
||||
Ok(store
|
||||
.prefix(prefix)
|
||||
.prefix(type_index)
|
||||
.rev()
|
||||
.take(limit)
|
||||
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
|
||||
@@ -180,10 +171,8 @@ impl Query {
|
||||
.get(output_type)
|
||||
.data()?;
|
||||
|
||||
let prefix = u32::from(type_index).to_be_bytes();
|
||||
|
||||
let outpoints: Vec<(TxIndex, Vout)> = store
|
||||
.prefix(prefix)
|
||||
.prefix(type_index)
|
||||
.map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout()))
|
||||
.take(max_utxos + 1)
|
||||
.collect();
|
||||
@@ -268,9 +257,8 @@ impl Query {
|
||||
.addr_type_to_addr_index_and_tx_index
|
||||
.get(output_type)
|
||||
.data()?;
|
||||
let prefix = u32::from(type_index).to_be_bytes();
|
||||
let last_tx_index = store
|
||||
.prefix(prefix)
|
||||
.prefix(type_index)
|
||||
.next_back()
|
||||
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
|
||||
.ok_or(Error::UnknownAddr)?;
|
||||
@@ -278,22 +266,23 @@ impl Query {
|
||||
}
|
||||
|
||||
fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> {
|
||||
let stores = &self.indexer().stores;
|
||||
|
||||
let bytes = AddrBytes::from_str(addr)?;
|
||||
let output_type = OutputType::from(&bytes);
|
||||
let hash = AddrHash::from(&bytes);
|
||||
let type_index = self.type_index_for(output_type, &hash)?;
|
||||
Ok((output_type, type_index))
|
||||
}
|
||||
|
||||
let Some(type_index) = stores
|
||||
/// Lookup the per-type index of an address by `(output_type, hash)`.
|
||||
/// Returns `UnknownAddr` if the hash is absent from the type's index.
|
||||
fn type_index_for(&self, output_type: OutputType, hash: &AddrHash) -> Result<TypeIndex> {
|
||||
self.indexer()
|
||||
.stores
|
||||
.addr_type_to_addr_hash_to_addr_index
|
||||
.get(output_type)
|
||||
.data()?
|
||||
.get(&hash)?
|
||||
.get(hash)?
|
||||
.map(|cow| cow.into_owned())
|
||||
else {
|
||||
return Err(Error::UnknownAddr);
|
||||
};
|
||||
|
||||
Ok((output_type, type_index))
|
||||
.ok_or(Error::UnknownAddr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,29 @@ use crate::Query;
|
||||
|
||||
const HEADER_SIZE: usize = 80;
|
||||
|
||||
/// Decoded coinbase fields consumed by `blocks_v1_range`.
|
||||
///
|
||||
/// Returned by `Query::parse_coinbase_from_read`. On decode failure the
|
||||
/// caller hard-fails on header reads but accepts a `Coinbase::default()`
|
||||
/// here (manifests as missing `extras` rather than a 5xx).
|
||||
#[derive(Default)]
|
||||
struct Coinbase {
|
||||
/// Hex-encoded scriptsig bytes.
|
||||
raw_hex: String,
|
||||
/// Primary payout address (first non-duplicate output address).
|
||||
primary_address: Option<String>,
|
||||
/// Deduped payout address list (consecutive duplicates collapsed).
|
||||
addresses: Vec<String>,
|
||||
/// Payout-output `asm` (first non-OP_RETURN output, or first output).
|
||||
payout_asm: String,
|
||||
/// Scriptsig rendered as ASCII chars (one byte per char).
|
||||
scriptsig_ascii: String,
|
||||
/// Raw scriptsig bytes (used for Datum miner-name parsing).
|
||||
scriptsig_bytes: Vec<u8>,
|
||||
/// On-disk total size of the coinbase tx.
|
||||
total_size: usize,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
/// Block by hash. Unknown hash → 404 via `height_by_hash`.
|
||||
pub fn block(&self, hash: &BlockHash) -> Result<BlockInfo> {
|
||||
@@ -65,14 +88,14 @@ impl Query {
|
||||
/// Most recent `count` blocks ending at `start_height` (default tip),
|
||||
/// returned in descending-height order.
|
||||
pub fn blocks(&self, start_height: Option<Height>, count: u32) -> Result<Vec<BlockInfo>> {
|
||||
let (begin, end) = self.resolve_block_range(start_height, count);
|
||||
let (begin, end) = self.resolve_block_range(start_height, count, self.tip_height());
|
||||
self.blocks_range(begin, end)
|
||||
}
|
||||
|
||||
/// V1 most recent `count` blocks with extras ending at `start_height`
|
||||
/// (default tip), returned in descending-height order.
|
||||
pub fn blocks_v1(&self, start_height: Option<Height>, count: u32) -> Result<Vec<BlockInfoV1>> {
|
||||
let (begin, end) = self.resolve_block_range(start_height, count);
|
||||
let (begin, end) = self.resolve_block_range(start_height, count, self.height());
|
||||
self.blocks_v1_range(begin, end)
|
||||
}
|
||||
|
||||
@@ -152,7 +175,7 @@ impl Query {
|
||||
Self::compute_median_time(&median_timestamps, begin + i, median_start);
|
||||
|
||||
blocks.push(BlockInfo {
|
||||
id: blockhashes[i].clone(),
|
||||
id: blockhashes[i],
|
||||
height: Height::from(begin + i),
|
||||
version: header.version,
|
||||
timestamp: timestamps[i],
|
||||
@@ -171,9 +194,12 @@ impl Query {
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// Build `BlockInfoV1` rows for `[begin, end)` in descending-height order.
|
||||
/// Caller must bounds-check `end <= min(indexed, computed) + 1`. Returns
|
||||
/// `Internal` on bulk-read short returns or per-block header read failures.
|
||||
pub(crate) fn blocks_v1_range(&self, begin: usize, end: usize) -> Result<Vec<BlockInfoV1>> {
|
||||
if begin >= end {
|
||||
return Ok(vec![]);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let count = end - begin;
|
||||
@@ -289,6 +315,50 @@ impl Query {
|
||||
.timestamp
|
||||
.collect_range_at(median_start, end);
|
||||
|
||||
let per_block_lens = [
|
||||
blockhashes.len(),
|
||||
difficulties.len(),
|
||||
timestamps.len(),
|
||||
sizes.len(),
|
||||
weights.len(),
|
||||
positions.len(),
|
||||
pool_slugs.len(),
|
||||
segwit_txs.len(),
|
||||
segwit_sizes.len(),
|
||||
segwit_weights.len(),
|
||||
fee_sats.len(),
|
||||
subsidy_sats.len(),
|
||||
input_counts.len(),
|
||||
output_counts.len(),
|
||||
utxo_set_sizes.len(),
|
||||
input_volumes.len(),
|
||||
prices.len(),
|
||||
output_volumes.len(),
|
||||
fr_min.len(),
|
||||
fr_pct10.len(),
|
||||
fr_pct25.len(),
|
||||
fr_median.len(),
|
||||
fr_pct75.len(),
|
||||
fr_pct90.len(),
|
||||
fr_max.len(),
|
||||
fa_min.len(),
|
||||
fa_pct10.len(),
|
||||
fa_pct25.len(),
|
||||
fa_median.len(),
|
||||
fa_pct75.len(),
|
||||
fa_pct90.len(),
|
||||
fa_max.len(),
|
||||
];
|
||||
if per_block_lens.iter().any(|&l| l != count) {
|
||||
return Err(Error::Internal("blocks_v1_range: short read on per-block vecs"));
|
||||
}
|
||||
if first_tx_indexes.len() < count {
|
||||
return Err(Error::Internal("blocks_v1_range: short read on first_tx_index"));
|
||||
}
|
||||
if median_timestamps.len() != end - median_start {
|
||||
return Err(Error::Internal("blocks_v1_range: short read on median window"));
|
||||
}
|
||||
|
||||
let mut blocks = Vec::with_capacity(count);
|
||||
|
||||
for i in (0..count).rev() {
|
||||
@@ -298,53 +368,26 @@ impl Query {
|
||||
(total_txs - first_tx_indexes[i].to_usize()) as u32
|
||||
};
|
||||
|
||||
// Single reader for header + coinbase (adjacent in blk file)
|
||||
// Single reader for header + coinbase (adjacent in blk file).
|
||||
// Header read errors hard-fail; coinbase parsing silent-degrades.
|
||||
let varint_len = Self::compact_size_len(tx_count) as usize;
|
||||
let (
|
||||
raw_header,
|
||||
coinbase_raw,
|
||||
coinbase_address,
|
||||
coinbase_addresses,
|
||||
coinbase_signature,
|
||||
coinbase_signature_ascii,
|
||||
let mut blk = reader
|
||||
.reader_at(positions[i])
|
||||
.map_err(|_| Error::Internal("blocks_v1_range: failed to open block reader"))?;
|
||||
let mut raw_header = [0u8; HEADER_SIZE];
|
||||
blk.read_exact(&mut raw_header)
|
||||
.map_err(|_| Error::Internal("blocks_v1_range: failed to read block header"))?;
|
||||
let mut skip = [0u8; 5];
|
||||
let _ = blk.read_exact(&mut skip[..varint_len]);
|
||||
let Coinbase {
|
||||
raw_hex: coinbase_raw,
|
||||
primary_address: coinbase_address,
|
||||
addresses: coinbase_addresses,
|
||||
payout_asm: coinbase_signature,
|
||||
scriptsig_ascii: coinbase_signature_ascii,
|
||||
scriptsig_bytes,
|
||||
coinbase_total_size,
|
||||
) = match reader.reader_at(positions[i]) {
|
||||
Ok(mut blk) => {
|
||||
let mut header_buf = [0u8; HEADER_SIZE];
|
||||
if blk.read_exact(&mut header_buf).is_err() {
|
||||
(
|
||||
[0u8; HEADER_SIZE],
|
||||
String::new(),
|
||||
None,
|
||||
vec![],
|
||||
String::new(),
|
||||
String::new(),
|
||||
vec![],
|
||||
0,
|
||||
)
|
||||
} else {
|
||||
// Skip tx count varint
|
||||
let mut skip = [0u8; 5];
|
||||
let _ = blk.read_exact(&mut skip[..varint_len]);
|
||||
let coinbase = Self::parse_coinbase_from_read(blk);
|
||||
(
|
||||
header_buf, coinbase.0, coinbase.1, coinbase.2, coinbase.3, coinbase.4,
|
||||
coinbase.5, coinbase.6,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(_) => (
|
||||
[0u8; HEADER_SIZE],
|
||||
String::new(),
|
||||
None,
|
||||
vec![],
|
||||
String::new(),
|
||||
String::new(),
|
||||
vec![],
|
||||
0,
|
||||
),
|
||||
};
|
||||
total_size: coinbase_total_size,
|
||||
} = Self::parse_coinbase_from_read(blk);
|
||||
let header = Self::decode_header(&raw_header)?;
|
||||
|
||||
let weight = weights[i];
|
||||
@@ -370,7 +413,7 @@ impl Query {
|
||||
Self::compute_median_time(&median_timestamps, begin + i, median_start);
|
||||
|
||||
let info = BlockInfo {
|
||||
id: blockhashes[i].clone(),
|
||||
id: blockhashes[i],
|
||||
height: Height::from(begin + i),
|
||||
version: header.version,
|
||||
timestamp: timestamps[i],
|
||||
@@ -464,17 +507,29 @@ impl Query {
|
||||
Height::from(self.indexer().vecs.blocks.blockhash.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Hash to height. The prefix store keys on the first 8 bytes of
|
||||
/// the hash, so the resolved height is verified against the full
|
||||
/// `blockhash[height]` before being returned. Prefix collisions
|
||||
/// (or unknown hashes) surface as `NotFound`.
|
||||
pub fn height_by_hash(&self, hash: &BlockHash) -> Result<Height> {
|
||||
let indexer = self.indexer();
|
||||
let prefix = BlockHashPrefix::from(hash);
|
||||
indexer
|
||||
let height = indexer
|
||||
.stores
|
||||
.blockhash_prefix_to_height
|
||||
.get(&prefix)?
|
||||
.map(|h| *h)
|
||||
.ok_or(Error::NotFound("Block not found".into()))
|
||||
.ok_or(Error::NotFound("Block not found".into()))?;
|
||||
match indexer.vecs.blocks.blockhash.get(height) {
|
||||
Some(stored) if &stored == hash => Ok(height),
|
||||
_ => Err(Error::NotFound("Block not found".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the on-disk 80-byte header at `height` and decode it.
|
||||
/// Caller must bounds-check `height` (no `OutOfRange` mapping here).
|
||||
/// Returns `bitcoin::block::Header` because callers feed it into
|
||||
/// upstream consensus-encoding APIs (`serialize_hex`, `MerkleBlock`).
|
||||
pub fn read_block_header(&self, height: Height) -> Result<bitcoin::block::Header> {
|
||||
let position = self
|
||||
.indexer()
|
||||
@@ -488,9 +543,21 @@ impl Query {
|
||||
.map_err(|_| Error::Internal("Failed to decode block header"))
|
||||
}
|
||||
|
||||
fn resolve_block_range(&self, start_height: Option<Height>, count: u32) -> (usize, usize) {
|
||||
let max_height = self.height();
|
||||
let start = start_height.unwrap_or(max_height).min(max_height);
|
||||
/// `(begin, end)` half-open window of up to `count` blocks ending
|
||||
/// at `start_height` (default `cap`), clamped to `[0, cap]`. Caller
|
||||
/// supplies `cap`: `tip_height()` when reading indexer-only series,
|
||||
/// `height() = min(indexed, computed)` when reading computer-stamped
|
||||
/// series too.
|
||||
fn resolve_block_range(
|
||||
&self,
|
||||
start_height: Option<Height>,
|
||||
count: u32,
|
||||
cap: Height,
|
||||
) -> (usize, usize) {
|
||||
let start = match start_height {
|
||||
Some(h) => h.min(cap),
|
||||
None => cap,
|
||||
};
|
||||
let start_u32: u32 = start.into();
|
||||
let count = count.min(start_u32 + 1) as usize;
|
||||
let end = start_u32 as usize + 1;
|
||||
@@ -498,12 +565,23 @@ impl Query {
|
||||
(begin, end)
|
||||
}
|
||||
|
||||
/// Consensus-decodes 80 raw header bytes into the crate's `BlockHeader`.
|
||||
/// Failure means on-disk corruption (the bytes already passed indexer
|
||||
/// validation), so it surfaces as `Error::Internal`, not `OutOfRange`.
|
||||
fn decode_header(bytes: &[u8]) -> Result<BlockHeader> {
|
||||
let raw = bitcoin::block::Header::consensus_decode(&mut &bytes[..])
|
||||
.map_err(|_| Error::Internal("Failed to decode block header"))?;
|
||||
Ok(BlockHeader::from(raw))
|
||||
}
|
||||
|
||||
/// BIP113 Median Time Past for `height`: median of timestamps over
|
||||
/// `[height-10, height]` (11 blocks). For `height < 10` the window is
|
||||
/// shorter and the median is the upper-middle of available data, matching
|
||||
/// Bitcoin Core's behavior.
|
||||
///
|
||||
/// `all_timestamps` is the contiguous slab covering `[window_start, ..)`
|
||||
/// pre-fetched by the caller, so this helper only translates absolute
|
||||
/// heights into relative slice indices.
|
||||
fn compute_median_time(
|
||||
all_timestamps: &[Timestamp],
|
||||
height: usize,
|
||||
@@ -511,14 +589,15 @@ impl Query {
|
||||
) -> Timestamp {
|
||||
let rel_start = height.saturating_sub(10) - window_start;
|
||||
let rel_end = height + 1 - window_start;
|
||||
let mut sorted: Vec<usize> = all_timestamps[rel_start..rel_end]
|
||||
.iter()
|
||||
.map(|t| usize::from(*t))
|
||||
.collect();
|
||||
let mut sorted = all_timestamps[rel_start..rel_end].to_vec();
|
||||
sorted.sort_unstable();
|
||||
Timestamp::from(sorted[sorted.len() / 2])
|
||||
sorted[sorted.len() / 2]
|
||||
}
|
||||
|
||||
/// Byte length of Bitcoin's CompactSize varint for a tx count.
|
||||
/// `1` for `<= 0xFC`, `3` for the `0xFD`-prefixed u16 form, `5` for
|
||||
/// the `0xFE`-prefixed u32 form. The 9-byte `0xFF`-prefixed u64 form
|
||||
/// is unreachable here because the input is `u32`.
|
||||
fn compact_size_len(tx_count: u32) -> u32 {
|
||||
if tx_count <= 0xFC {
|
||||
1
|
||||
@@ -529,8 +608,18 @@ impl Query {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OCEAN DATUM protocol miner names from coinbase scriptsig.
|
||||
/// Skips BIP34 height push, reads tag payload, splits on 0x0F delimiter.
|
||||
/// Parse OCEAN DATUM protocol miner names from a coinbase scriptsig.
|
||||
///
|
||||
/// Layout: `[height_len][height_bytes][tags_push][tags_bytes...]`.
|
||||
/// `tags_push` is either a direct push length (`<= 0x4b`) or
|
||||
/// `OP_PUSHDATA1 (0x4c)` followed by a length byte. `tags_bytes` is
|
||||
/// split on `0x0F` and each segment is sanitized to ASCII alphanumeric
|
||||
/// plus space.
|
||||
///
|
||||
/// Any structural mismatch (truncation, missing fields) returns `None`.
|
||||
/// `OP_PUSHDATA2`/`OP_PUSHDATA4` are not handled: today's payloads are
|
||||
/// well under 255 bytes, so this only matters if OCEAN ever publishes
|
||||
/// a longer tag list.
|
||||
fn parse_datum_miner_names(scriptsig: &[u8]) -> Option<Vec<String>> {
|
||||
if scriptsig.is_empty() {
|
||||
return None;
|
||||
@@ -558,19 +647,13 @@ impl Query {
|
||||
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<String> = tag_string
|
||||
.split('\x0f')
|
||||
.map(|s| {
|
||||
s.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == ' ')
|
||||
let names: Vec<String> = tag_bytes
|
||||
.split(|&b| b == 0x0f)
|
||||
.map(|seg| {
|
||||
seg.iter()
|
||||
.filter(|&&b| b.is_ascii_alphanumeric() || b == b' ')
|
||||
.map(|&b| b as char)
|
||||
.collect::<String>()
|
||||
})
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
@@ -579,34 +662,18 @@ impl Query {
|
||||
if names.is_empty() { None } else { Some(names) }
|
||||
}
|
||||
|
||||
fn parse_coinbase_from_read(
|
||||
reader: impl Read,
|
||||
) -> (
|
||||
String,
|
||||
Option<String>,
|
||||
Vec<String>,
|
||||
String,
|
||||
String,
|
||||
Vec<u8>,
|
||||
usize,
|
||||
) {
|
||||
let empty = (
|
||||
String::new(),
|
||||
None,
|
||||
vec![],
|
||||
String::new(),
|
||||
String::new(),
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
/// Decode a coinbase transaction off the block reader into a
|
||||
/// `Coinbase` struct. Decode failure is silent: returns
|
||||
/// `Coinbase::default()`. The caller hard-fails on header-read errors
|
||||
/// but accepts coinbase parse failures (they manifest as missing
|
||||
/// `extras` rather than a 5xx).
|
||||
fn parse_coinbase_from_read(reader: impl Read) -> Coinbase {
|
||||
let tx = match bitcoin::Transaction::consensus_decode(&mut bitcoin::io::FromStd::new(reader)) {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => return Coinbase::default(),
|
||||
};
|
||||
|
||||
let tx =
|
||||
match bitcoin::Transaction::consensus_decode(&mut bitcoin::io::FromStd::new(reader)) {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => return empty,
|
||||
};
|
||||
|
||||
let coinbase_total_size = tx.total_size();
|
||||
let total_size = tx.total_size();
|
||||
|
||||
let scriptsig_bytes: Vec<u8> = tx
|
||||
.input
|
||||
@@ -614,11 +681,11 @@ impl Query {
|
||||
.map(|input| input.script_sig.as_bytes().to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
let coinbase_raw = scriptsig_bytes.to_lower_hex_string();
|
||||
let raw_hex = scriptsig_bytes.to_lower_hex_string();
|
||||
|
||||
let coinbase_signature_ascii: String = scriptsig_bytes.iter().map(|&b| b as char).collect();
|
||||
let scriptsig_ascii: String = scriptsig_bytes.iter().map(|&b| b as char).collect();
|
||||
|
||||
let mut coinbase_addresses: Vec<String> = tx
|
||||
let mut addresses: Vec<String> = tx
|
||||
.output
|
||||
.iter()
|
||||
.filter_map(|output| {
|
||||
@@ -627,10 +694,12 @@ impl Query {
|
||||
.map(|a| a.to_string())
|
||||
})
|
||||
.collect();
|
||||
coinbase_addresses.dedup();
|
||||
let coinbase_address = coinbase_addresses.first().cloned();
|
||||
// Collapse consecutive duplicates only: padding outputs to the same
|
||||
// payout get merged, multi-payout pools keep distinct order.
|
||||
addresses.dedup();
|
||||
let primary_address = addresses.first().cloned();
|
||||
|
||||
let coinbase_signature = tx
|
||||
let payout_asm = tx
|
||||
.output
|
||||
.iter()
|
||||
.find(|output| !output.script_pubkey.is_op_return())
|
||||
@@ -638,14 +707,14 @@ impl Query {
|
||||
.map(|output| output.script_pubkey.to_asm_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
(
|
||||
coinbase_raw,
|
||||
coinbase_address,
|
||||
coinbase_addresses,
|
||||
coinbase_signature,
|
||||
coinbase_signature_ascii,
|
||||
Coinbase {
|
||||
raw_hex,
|
||||
primary_address,
|
||||
addresses,
|
||||
payout_asm,
|
||||
scriptsig_ascii,
|
||||
scriptsig_bytes,
|
||||
coinbase_total_size,
|
||||
)
|
||||
total_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,3 @@ mod raw;
|
||||
mod status;
|
||||
mod timestamp;
|
||||
mod txs;
|
||||
|
||||
pub const BLOCK_TXS_PAGE_SIZE: usize = 25;
|
||||
|
||||
@@ -13,9 +13,9 @@ impl Query {
|
||||
fn block_raw_by_height(&self, height: Height) -> Result<Vec<u8>> {
|
||||
let max_height = self.tip_height();
|
||||
if height > max_height {
|
||||
return Err(Error::OutOfRange(format!(
|
||||
"Block height {height} out of range (tip {max_height})"
|
||||
)));
|
||||
return Err(Error::OutOfRange(
|
||||
format!("Block height {height} out of range (tip {max_height})").into(),
|
||||
));
|
||||
}
|
||||
|
||||
let indexer = self.indexer();
|
||||
|
||||
@@ -3,22 +3,34 @@ use std::io::Cursor;
|
||||
use bitcoin::consensus::Decodable;
|
||||
use brk_error::{Error, OptionData, Result};
|
||||
use brk_types::{
|
||||
BlkPosition, BlockHash, Height, OutPoint, OutputType, RawLockTime, Sats, StoredU32,
|
||||
Transaction, TxIn, TxInIndex, TxIndex, TxOut, TxStatus, Txid, TypeIndex, Vout, Weight,
|
||||
BlkPosition, BlockHash, BlockTxIndex, Height, OutPoint, OutputType, RawLockTime, Sats, SigOps,
|
||||
StoredU32, Transaction, TxIn, TxInIndex, TxIndex, TxOut, TxStatus, Txid, TypeIndex, Vout,
|
||||
Weight,
|
||||
};
|
||||
use rustc_hash::FxHashMap;
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use super::BLOCK_TXS_PAGE_SIZE;
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// All txids in the block, canonical order (coinbase first).
|
||||
/// `NotFound` if the hash is unknown (or only collides on the 8-byte
|
||||
/// prefix), `OutOfRange` if the resolved height is past the indexed tip.
|
||||
/// Unpaginated by design.
|
||||
pub fn block_txids(&self, hash: &BlockHash) -> Result<Vec<Txid>> {
|
||||
let height = self.height_by_hash(hash)?;
|
||||
self.block_txids_by_height(height)
|
||||
}
|
||||
|
||||
pub fn block_txs(&self, hash: &BlockHash, start_index: TxIndex) -> Result<Vec<Transaction>> {
|
||||
/// Up to `count` transactions from the block, starting at the in-block
|
||||
/// offset `start_index` (0 = coinbase). `OutOfRange` when `start_index`
|
||||
/// is past the last tx in the block. Caller (route layer) sets `count`.
|
||||
pub fn block_txs(
|
||||
&self,
|
||||
hash: &BlockHash,
|
||||
start_index: BlockTxIndex,
|
||||
count: u32,
|
||||
) -> Result<Vec<Transaction>> {
|
||||
let height = self.height_by_hash(hash)?;
|
||||
let (first, tx_count) = self.block_tx_range(height)?;
|
||||
let start: usize = start_index.into();
|
||||
@@ -27,51 +39,77 @@ impl Query {
|
||||
"start index past last transaction in block".into(),
|
||||
));
|
||||
}
|
||||
let count = BLOCK_TXS_PAGE_SIZE.min(tx_count - start);
|
||||
let count = (count as usize).min(tx_count - start);
|
||||
let indices: Vec<TxIndex> = (first + start..first + start + count)
|
||||
.map(TxIndex::from)
|
||||
.collect();
|
||||
self.transactions_by_indices(&indices)
|
||||
}
|
||||
|
||||
pub fn block_txid_at_index(&self, hash: &BlockHash, index: TxIndex) -> Result<Txid> {
|
||||
/// Txid at an in-block offset (`index` is the position within the block,
|
||||
/// 0 = coinbase). `NotFound` if the hash is unknown or only collides on
|
||||
/// the 8-byte prefix; `OutOfRange` if `index` is past the last tx in
|
||||
/// the block.
|
||||
pub fn block_txid_at_index(&self, hash: &BlockHash, index: BlockTxIndex) -> Result<Txid> {
|
||||
let height = self.height_by_hash(hash)?;
|
||||
self.block_txid_at_index_by_height(height, index.into())
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
/// All txids in the block at `height`, canonical order. `OutOfRange`
|
||||
/// when `height` is past the indexed tip; `Internal` if any read hits
|
||||
/// the stamp-before-data race or short-returns. Used by both the
|
||||
/// hash-keyed and height-keyed entry points so they share bounds
|
||||
/// semantics.
|
||||
pub(crate) fn block_txids_by_height(&self, height: Height) -> Result<Vec<Txid>> {
|
||||
let (first, tx_count) = self.block_tx_range(height)?;
|
||||
Ok(self
|
||||
let txids = self
|
||||
.indexer()
|
||||
.vecs
|
||||
.transactions
|
||||
.txid
|
||||
.collect_range_at(first, first + tx_count))
|
||||
.collect_range_at(first, first + tx_count);
|
||||
if txids.len() != tx_count {
|
||||
return Err(Error::Internal(
|
||||
"block_txids_by_height: short txid read",
|
||||
));
|
||||
}
|
||||
Ok(txids)
|
||||
}
|
||||
|
||||
/// Single txid at an in-block offset. `OutOfRange` when `index` is past
|
||||
/// the last tx in the block. `Internal` if the underlying read finds
|
||||
/// the stamp-before-data race (`first_tx_index` flushed ahead of `txid`).
|
||||
fn block_txid_at_index_by_height(&self, height: Height, index: usize) -> Result<Txid> {
|
||||
let (first, tx_count) = self.block_tx_range(height)?;
|
||||
if index >= tx_count {
|
||||
return Err(Error::OutOfRange("Transaction index out of range".into()));
|
||||
}
|
||||
Ok(self
|
||||
.indexer()
|
||||
self.indexer()
|
||||
.vecs
|
||||
.transactions
|
||||
.txid
|
||||
.reader()
|
||||
.get(first + index))
|
||||
.try_get(first + index)
|
||||
.ok_or(Error::Internal(
|
||||
"block_txid_at_index_by_height: txid index past data",
|
||||
))
|
||||
}
|
||||
|
||||
/// Batch-read transactions at arbitrary indices.
|
||||
/// Reads in ascending index order for I/O locality, returns in caller's order.
|
||||
///
|
||||
/// Three-phase approach for optimal I/O:
|
||||
/// Phase 1 — Decode transactions & collect outpoints (sorted by tx_index)
|
||||
/// Phase 2 — Batch-read all prevout data (sorted by prev_tx_index, then txout_index)
|
||||
/// Phase 3 — Assemble Transaction objects from pre-fetched data
|
||||
/// Three-phase approach for sequential cursor I/O:
|
||||
/// Phase 1: decode transactions, collect outpoints + per-input prevout
|
||||
/// metadata (sorted by tx_index).
|
||||
/// Phase 2: resolve each prevout's script_pubkey (sorted by
|
||||
/// output_type, then type_index, for sequential addr-vec reads).
|
||||
/// Phase 3: assemble `Transaction` objects, compute sigops + fees.
|
||||
///
|
||||
/// The final `unwrap` is provably safe: `order` is a permutation of
|
||||
/// `0..len`, Phase 1 produces exactly one `DecodedTx` per position, and
|
||||
/// Phase 3 assigns each `txs[pos]` once before the collect.
|
||||
pub fn transactions_by_indices(&self, indices: &[TxIndex]) -> Result<Vec<Transaction>> {
|
||||
if indices.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -84,6 +122,7 @@ impl Query {
|
||||
order.sort_unstable_by_key(|&i| indices[i]);
|
||||
|
||||
let indexer = self.indexer();
|
||||
// BLK file reader, distinct from the vec cursors below.
|
||||
let reader = self.reader();
|
||||
|
||||
// ── Phase 1: Decode all transactions, collect outpoints ─────────
|
||||
@@ -147,8 +186,8 @@ impl Query {
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 1b: Batch-read outpoints + prevout data via cursors (PcoVec —
|
||||
// sequential cursor avoids re-decompressing the same pages).
|
||||
// Phase 1b: Batch-read outpoints + prevout data via cursors. PcoVec
|
||||
// sequential cursors avoid re-decompressing the same pages.
|
||||
// Reading output_type/type_index/value HERE from inputs vecs (sequential)
|
||||
// avoids random-reading them from outputs vecs in Phase 2.
|
||||
let mut outpoint_cursor = indexer.vecs.inputs.outpoint.cursor();
|
||||
@@ -247,7 +286,7 @@ impl Query {
|
||||
.map(|(j, txin)| (txin.previous_output, j))
|
||||
.collect();
|
||||
|
||||
let total_sigop_cost = dtx.decoded.total_sigop_cost(|outpoint| {
|
||||
let total_sigop_cost = SigOps::of_bitcoin_tx(&dtx.decoded, |outpoint| {
|
||||
outpoint_to_idx
|
||||
.get(outpoint)
|
||||
.and_then(|&j| input[j].prevout.as_ref())
|
||||
@@ -280,7 +319,15 @@ impl Query {
|
||||
Ok(txs.into_iter().map(Option::unwrap).collect())
|
||||
}
|
||||
|
||||
/// Returns (first_tx_raw_index, tx_count) for a block at `height`.
|
||||
/// Half-open `[first, first + tx_count)` window into the flat tx vecs
|
||||
/// for the block at `height`. Single source of truth for the four
|
||||
/// `block_*` callers in this file.
|
||||
///
|
||||
/// `OutOfRange` when `height` is past the indexed-tip stamp.
|
||||
/// `Internal` when `first_tx_index[height]` is missing under the
|
||||
/// stamp-before-data race. For the tip block (where
|
||||
/// `first_tx_index[height+1]` is not yet written), `next` falls back
|
||||
/// to `txid.len()`.
|
||||
fn block_tx_range(&self, height: Height) -> Result<(usize, usize)> {
|
||||
let indexer = self.indexer();
|
||||
if height > self.indexed_height() {
|
||||
|
||||
346
crates/brk_query/src/impl/cpfp.rs
Normal file
346
crates/brk_query/src/impl/cpfp.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
//! CPFP queries: dispatches between the live mempool path (handled by
|
||||
//! `brk_mempool`) and the confirmed-tx path built here from indexer
|
||||
//! and computer vecs.
|
||||
//!
|
||||
//! Confirmed clusters are built on demand by walking the same-block
|
||||
//! parent/child edges in `TxIndex` space (no `Transaction`
|
||||
//! reconstruction, no `txid → tx_index` lookup), then handing the
|
||||
//! resulting `brk_mempool::cluster::Cluster` to `Cluster::to_cpfp_info`
|
||||
//! — the same wire converter the mempool path uses, so both produce
|
||||
//! identical `CpfpInfo` shapes.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use bitcoin::consensus::Decodable;
|
||||
use brk_error::{Error, OptionData, Result};
|
||||
use brk_mempool::cluster::{Cluster, ClusterNode, LocalIdx};
|
||||
use brk_types::{
|
||||
CpfpInfo, FeeRate, Height, OutPoint, OutputType, Sats, SigOps, TxIndex, TxInIndex, TypeIndex,
|
||||
Txid, TxidPrefix, VSize, Weight,
|
||||
};
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap};
|
||||
use smallvec::SmallVec;
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use crate::Query;
|
||||
|
||||
/// Cap matches Bitcoin Core's default mempool ancestor/descendant
|
||||
/// chain limits and mempool.space's truncation.
|
||||
const MAX: usize = 25;
|
||||
|
||||
struct WalkResult {
|
||||
/// Cluster members in build order (`[seed, ancestors..., descendants...]`),
|
||||
/// each paired with its in-cluster parent edges already resolved to
|
||||
/// `LocalIdx`. Vec position equals the node's `LocalIdx`.
|
||||
nodes: Vec<(TxIndex, SmallVec<[LocalIdx; 2]>)>,
|
||||
/// Pre-permutation `LocalIdx` of the seed. Equals `ancestor_count`
|
||||
/// because all of seed's in-cluster ancestors topo-sort before it
|
||||
/// and only ancestors do, so after `Cluster::new` permutes nodes
|
||||
/// into topological order seed lands at this exact position.
|
||||
seed_local: LocalIdx,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
/// CPFP cluster for `txid`. Returns the mempool cluster when the
|
||||
/// txid is unconfirmed; otherwise reconstructs the confirmed
|
||||
/// same-block cluster from indexer state. Works even when the
|
||||
/// mempool feature is off.
|
||||
pub fn cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
if let Some(info) = self.mempool().and_then(|m| m.cpfp_info(&prefix)) {
|
||||
return Ok(info);
|
||||
}
|
||||
self.confirmed_cpfp(txid)
|
||||
}
|
||||
|
||||
/// Effective fee rate for `txid` using the same SFL chunk-rate
|
||||
/// semantics across paths:
|
||||
///
|
||||
/// - Live mempool: snapshot `cluster_of` lookup → seed's chunk rate.
|
||||
/// If the tx is in the pool but not in the latest snapshot (e.g.
|
||||
/// just added), falls back to the entry's simple `fee/vsize`.
|
||||
/// - Confirmed: precomputed `effective_fee_rate.tx_index` (the same
|
||||
/// SFL chunk rate, computed at index time).
|
||||
/// - Graveyard-only RBF predecessor: simple `fee/vsize` snapshotted
|
||||
/// at burial.
|
||||
///
|
||||
/// Returns `Error::UnknownTxid` for txids not seen in any of those.
|
||||
pub fn effective_fee_rate(&self, txid: &Txid) -> Result<FeeRate> {
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
|
||||
if let Some(mempool) = self.mempool() {
|
||||
let entries = mempool.entries();
|
||||
if let Some(seed_idx) = entries.idx_of(&prefix)
|
||||
&& let Some(rate) = mempool.snapshot().chunk_rate_of(seed_idx)
|
||||
{
|
||||
return Ok(rate);
|
||||
}
|
||||
if let Some(entry) = entries.get(&prefix) {
|
||||
return Ok(entry.fee_rate());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(idx) = self.resolve_tx_index(txid)
|
||||
&& let Some(rate) = self
|
||||
.computer()
|
||||
.transactions
|
||||
.fees
|
||||
.effective_fee_rate
|
||||
.tx_index
|
||||
.collect_one(idx)
|
||||
{
|
||||
return Ok(rate);
|
||||
}
|
||||
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tomb) = mempool.graveyard().get(txid)
|
||||
{
|
||||
return Ok(tomb.entry.fee_rate());
|
||||
}
|
||||
|
||||
Err(Error::UnknownTxid)
|
||||
}
|
||||
|
||||
/// CPFP cluster for a confirmed tx: the connected component of
|
||||
/// same-block parent/child edges, walked on demand. SFL runs on
|
||||
/// the result so `effectiveFeePerVsize` matches the live path's
|
||||
/// chunk-rate semantics.
|
||||
fn confirmed_cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
|
||||
let seed = self.resolve_tx_index(txid)?;
|
||||
let height = self.confirmed_status_height(seed)?;
|
||||
let (cluster, seed_local) = self.build_confirmed_cluster(seed, height)?;
|
||||
let sigops = self.seed_sigop_cost(seed)?;
|
||||
Ok(cluster.to_cpfp_info(seed_local, sigops))
|
||||
}
|
||||
|
||||
/// BIP-141 sigop cost for a single confirmed tx, computed on demand:
|
||||
/// re-decode the raw tx, rebuild its prevout map from `inputs.*` +
|
||||
/// addr vecs, then defer the actual count to `SigOps::of_bitcoin_tx`.
|
||||
/// Cost is one BLK read plus `n_inputs` cursor hops, so a few hundred
|
||||
/// microseconds per CPFP request.
|
||||
fn seed_sigop_cost(&self, tx_index: TxIndex) -> Result<SigOps> {
|
||||
let indexer = self.indexer();
|
||||
let total_size = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.total_size
|
||||
.collect_one(tx_index)
|
||||
.data()?;
|
||||
let position = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.position
|
||||
.collect_one(tx_index)
|
||||
.data()?;
|
||||
let buffer = self.reader().read_raw_bytes(position, *total_size as usize)?;
|
||||
let decoded = bitcoin::Transaction::consensus_decode(&mut Cursor::new(buffer))
|
||||
.map_err(|_| Error::Parse("Failed to decode transaction".into()))?;
|
||||
|
||||
let first_txin = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txin_index
|
||||
.collect_one(tx_index)
|
||||
.data()?;
|
||||
let start = usize::from(first_txin);
|
||||
let count = decoded.input.len();
|
||||
|
||||
let mut outpoint_cursor = indexer.vecs.inputs.outpoint.cursor();
|
||||
let mut output_type_cursor = indexer.vecs.inputs.output_type.cursor();
|
||||
let mut type_index_cursor = indexer.vecs.inputs.type_index.cursor();
|
||||
let mut value_cursor = self.computer().inputs.spent.value.cursor();
|
||||
|
||||
let addr_readers = indexer.vecs.addrs.addr_readers();
|
||||
|
||||
let mut prevout_map: FxHashMap<bitcoin::OutPoint, bitcoin::TxOut> =
|
||||
FxHashMap::with_capacity_and_hasher(count, FxBuildHasher);
|
||||
|
||||
for (j, txin) in decoded.input.iter().enumerate() {
|
||||
let op: OutPoint = outpoint_cursor.get(start + j).data()?;
|
||||
if op.is_coinbase() {
|
||||
continue;
|
||||
}
|
||||
let ot: OutputType = output_type_cursor.get(start + j).data()?;
|
||||
let ti: TypeIndex = type_index_cursor.get(start + j).data()?;
|
||||
let val: Sats = value_cursor.get(start + j).data()?;
|
||||
let script_pubkey = addr_readers.script_pubkey(ot, ti);
|
||||
prevout_map.insert(
|
||||
txin.previous_output,
|
||||
bitcoin::TxOut {
|
||||
value: bitcoin::Amount::from_sat(u64::from(val)),
|
||||
script_pubkey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SigOps::of_bitcoin_tx(&decoded, |outpoint| {
|
||||
prevout_map.get(outpoint).cloned()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Walk the seed's same-block parent/child edges, materialize each
|
||||
/// member's `(txid, weight, fee)` from indexer/computer cursors,
|
||||
/// and build a `Cluster<TxIndex>`. The seed's `LocalIdx` comes
|
||||
/// straight from the walk (`ancestor_count`), since `Cluster::new`
|
||||
/// preserves the "ancestors before seed before descendants" ordering
|
||||
/// that defines that index.
|
||||
fn build_confirmed_cluster(
|
||||
&self,
|
||||
seed: TxIndex,
|
||||
height: Height,
|
||||
) -> Result<(Cluster<TxIndex>, LocalIdx)> {
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let block_first = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_one(height)
|
||||
.data()?;
|
||||
let block_end = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_one(height.incremented())
|
||||
.unwrap_or_else(|| TxIndex::from(indexer.vecs.transactions.txid.len()));
|
||||
let same_block = |idx: TxIndex| idx >= block_first && idx < block_end;
|
||||
|
||||
let WalkResult { nodes, seed_local } = self.walk_same_block_edges(seed, same_block);
|
||||
|
||||
let mut base_size = indexer.vecs.transactions.base_size.cursor();
|
||||
let mut total_size = indexer.vecs.transactions.total_size.cursor();
|
||||
let mut fee_cursor = computer.transactions.fees.fee.tx_index.cursor();
|
||||
let txid_reader = indexer.vecs.transactions.txid.reader();
|
||||
|
||||
let cluster_nodes: Vec<ClusterNode<TxIndex>> = nodes
|
||||
.into_iter()
|
||||
.map(|(tx_index, parents)| {
|
||||
let i = tx_index.to_usize();
|
||||
let weight = Weight::from_sizes(*base_size.get(i).data()?, *total_size.get(i).data()?);
|
||||
Ok(ClusterNode {
|
||||
id: tx_index,
|
||||
txid: txid_reader.get(i),
|
||||
fee: fee_cursor.get(i).data()?,
|
||||
vsize: VSize::from(weight),
|
||||
weight,
|
||||
parents,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
Ok((Cluster::new(cluster_nodes), seed_local))
|
||||
}
|
||||
|
||||
/// BFS the seed's same-block ancestors (via `outpoint`) and
|
||||
/// descendants (via `spent.txin_index` → `spending_tx`), capped
|
||||
/// at `MAX` each side to match Core/mempool.space. Each node is
|
||||
/// pushed in build order with its full parent-outpoint list, then
|
||||
/// at end of walk those lists are filtered against the membership
|
||||
/// map to keep only in-cluster parents (resolved to `LocalIdx`).
|
||||
fn walk_same_block_edges(
|
||||
&self,
|
||||
seed: TxIndex,
|
||||
same_block: impl Fn(TxIndex) -> bool,
|
||||
) -> WalkResult {
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let mut first_txin = indexer.vecs.transactions.first_txin_index.cursor();
|
||||
let mut first_txout = indexer.vecs.transactions.first_txout_index.cursor();
|
||||
let mut outpoint = indexer.vecs.inputs.outpoint.cursor();
|
||||
let mut spent = computer.outputs.spent.txin_index.cursor();
|
||||
let mut spending_tx = indexer.vecs.inputs.tx_index.cursor();
|
||||
|
||||
let mut walk_inputs = |tx: TxIndex| -> SmallVec<[TxIndex; 2]> {
|
||||
let mut out: SmallVec<[TxIndex; 2]> = SmallVec::new();
|
||||
let Ok(start) = first_txin.get(tx.to_usize()).data() else { return out };
|
||||
let Ok(end) = first_txin.get(tx.to_usize() + 1).data() else { return out };
|
||||
for i in usize::from(start)..usize::from(end) {
|
||||
let Ok(op) = outpoint.get(i).data() else { continue };
|
||||
if op.is_coinbase() {
|
||||
continue;
|
||||
}
|
||||
out.push(op.tx_index());
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
let mut raw: Vec<(TxIndex, SmallVec<[TxIndex; 2]>)> = Vec::with_capacity(2 * MAX + 1);
|
||||
let mut local_of: FxHashMap<TxIndex, LocalIdx> =
|
||||
FxHashMap::with_capacity_and_hasher(2 * MAX + 1, FxBuildHasher);
|
||||
raw.push((seed, walk_inputs(seed)));
|
||||
local_of.insert(seed, LocalIdx::ZERO);
|
||||
|
||||
// Ancestor BFS. Stack holds indices into `raw`; each pop reads
|
||||
// that node's already-recorded parents and explores any same-block
|
||||
// ones we haven't visited yet. `walk_inputs` runs at push time so
|
||||
// parents are ready for the post-walk filter.
|
||||
let mut stack: Vec<usize> = vec![0];
|
||||
let mut ancestor_count: usize = 0;
|
||||
'a: while let Some(idx) = stack.pop() {
|
||||
let parents = raw[idx].1.clone();
|
||||
for parent in parents {
|
||||
if ancestor_count >= MAX {
|
||||
break 'a;
|
||||
}
|
||||
if local_of.contains_key(&parent) || !same_block(parent) {
|
||||
continue;
|
||||
}
|
||||
let new_idx = raw.len();
|
||||
raw.push((parent, walk_inputs(parent)));
|
||||
local_of.insert(parent, LocalIdx::from(new_idx));
|
||||
stack.push(new_idx);
|
||||
ancestor_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Descendant BFS. Stack holds tx_indices since we look up each
|
||||
// tx's txouts via `first_txout`/`spent`/`spending_tx`. `local_of`
|
||||
// already contains the seed and every ancestor, so they're
|
||||
// skipped by the membership check.
|
||||
let mut stack: Vec<TxIndex> = vec![seed];
|
||||
let mut descendant_count = 0;
|
||||
'd: while let Some(cur) = stack.pop() {
|
||||
let Ok(start) = first_txout.get(cur.to_usize()).data() else { continue };
|
||||
let Ok(end) = first_txout.get(cur.to_usize() + 1).data() else { continue };
|
||||
for i in usize::from(start)..usize::from(end) {
|
||||
let Ok(txin_idx) = spent.get(i).data() else { continue };
|
||||
if txin_idx == TxInIndex::UNSPENT {
|
||||
continue;
|
||||
}
|
||||
let Ok(child) = spending_tx.get(usize::from(txin_idx)).data() else { continue };
|
||||
if local_of.contains_key(&child) || !same_block(child) {
|
||||
continue;
|
||||
}
|
||||
let new_idx = raw.len();
|
||||
raw.push((child, walk_inputs(child)));
|
||||
local_of.insert(child, LocalIdx::from(new_idx));
|
||||
stack.push(child);
|
||||
descendant_count += 1;
|
||||
if descendant_count >= MAX {
|
||||
break 'd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter each node's full input list against `local_of` to keep
|
||||
// only in-cluster parents, resolved to their `LocalIdx`.
|
||||
let nodes: Vec<(TxIndex, SmallVec<[LocalIdx; 2]>)> = raw
|
||||
.into_iter()
|
||||
.map(|(tx_index, full_inputs)| {
|
||||
let parents: SmallVec<[LocalIdx; 2]> = full_inputs
|
||||
.iter()
|
||||
.filter_map(|p| local_of.get(p).copied())
|
||||
.collect();
|
||||
(tx_index, parents)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Seed's pre-permutation index is 0; after `Cluster::new` topo-sorts
|
||||
// it lands at `ancestor_count` (all in-cluster ancestors come first,
|
||||
// and only ancestors do).
|
||||
WalkResult {
|
||||
nodes,
|
||||
seed_local: LocalIdx::from(ancestor_count),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use brk_error::{Error, OptionData, Result};
|
||||
use brk_error::{Error, Result};
|
||||
use brk_mempool::{EntryPool, Mempool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone};
|
||||
use brk_types::{
|
||||
CheckedSub, CpfpEntry, CpfpInfo, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx,
|
||||
OutputType, RbfResponse, RbfTx, RecommendedFees, ReplacementNode, Sats, Timestamp, Transaction,
|
||||
TxIndex, TxInIndex, TxOut, TxOutIndex, Txid, TxidPrefix, TypeIndex, VSize, Weight,
|
||||
CheckedSub, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx,
|
||||
RecommendedFees, ReplacementNode, Sats, Timestamp, Transaction, TxOut, TxOutIndex, Txid,
|
||||
TxidPrefix, TypeIndex,
|
||||
};
|
||||
use rustc_hash::FxHashSet;
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
use vecdb::VecIndex;
|
||||
|
||||
use crate::Query;
|
||||
|
||||
@@ -93,197 +93,6 @@ impl Query {
|
||||
Ok(self.require_mempool()?.txs().recent().to_vec())
|
||||
}
|
||||
|
||||
/// CPFP cluster for `txid`. Returns the mempool cluster when the txid is
|
||||
/// unconfirmed; otherwise reconstructs the confirmed same-block cluster
|
||||
/// from indexer state. Works even when the mempool feature is off.
|
||||
pub fn cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
let mempool_cluster = self.mempool().and_then(|m| m.cpfp_info(&prefix));
|
||||
Ok(mempool_cluster.unwrap_or_else(|| self.confirmed_cpfp(txid)))
|
||||
}
|
||||
|
||||
/// CPFP cluster for a confirmed tx: the connected component of
|
||||
/// same-block parent/child edges, reconstructed by a depth-first
|
||||
/// walk on demand. Walks entirely in `TxIndex` space using direct
|
||||
/// vec reads (height, weight, fee) - skips full `Transaction`
|
||||
/// reconstruction and avoids `txid -> tx_index` lookups by reading
|
||||
/// `OutPoint`'s packed `tx_index` directly. Capped at 25 each side
|
||||
/// to match Bitcoin Core's default mempool chain limits and
|
||||
/// mempool.space's own truncation. `effectiveFeePerVsize` is the
|
||||
/// simple package rate; mempool's `calculateGoodBlockCpfp`
|
||||
/// chunk-rate algorithm is not ported.
|
||||
fn confirmed_cpfp(&self, txid: &Txid) -> CpfpInfo {
|
||||
const MAX: usize = 25;
|
||||
let Ok(seed_idx) = self.resolve_tx_index(txid) else {
|
||||
return CpfpInfo::default();
|
||||
};
|
||||
let Ok(seed_height) = self.confirmed_status_height(seed_idx) else {
|
||||
return CpfpInfo::default();
|
||||
};
|
||||
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
// Block's tx_index range. Reduces the per-neighbor height check to a
|
||||
// pair of integer compares (vs `tx_heights.get_shared` which acquires
|
||||
// a read lock and walks a `RangeMap`).
|
||||
let Ok(block_first) = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_one(seed_height)
|
||||
.data()
|
||||
else {
|
||||
return CpfpInfo::default();
|
||||
};
|
||||
let block_end = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_one(seed_height.incremented())
|
||||
.unwrap_or_else(|| TxIndex::from(indexer.vecs.transactions.txid.len()));
|
||||
let same_block = |idx: TxIndex| idx >= block_first && idx < block_end;
|
||||
|
||||
let mut first_txin = indexer.vecs.transactions.first_txin_index.cursor();
|
||||
let mut first_txout = indexer.vecs.transactions.first_txout_index.cursor();
|
||||
let mut outpoint = indexer.vecs.inputs.outpoint.cursor();
|
||||
let mut spent = computer.outputs.spent.txin_index.cursor();
|
||||
let mut spending_tx = indexer.vecs.inputs.tx_index.cursor();
|
||||
|
||||
let mut visited: FxHashSet<TxIndex> = FxHashSet::with_capacity_and_hasher(
|
||||
2 * MAX + 1,
|
||||
Default::default(),
|
||||
);
|
||||
visited.insert(seed_idx);
|
||||
|
||||
let mut ancestor_idxs: Vec<TxIndex> = Vec::with_capacity(MAX);
|
||||
let mut queue: Vec<TxIndex> = vec![seed_idx];
|
||||
'a: while let Some(cur) = queue.pop() {
|
||||
let Ok(start) = first_txin.get(cur.to_usize()).data() else { continue };
|
||||
let Ok(end) = first_txin.get(cur.to_usize() + 1).data() else { continue };
|
||||
for i in usize::from(start)..usize::from(end) {
|
||||
let Ok(op) = outpoint.get(i).data() else { continue };
|
||||
if op.is_coinbase() {
|
||||
continue;
|
||||
}
|
||||
let parent = op.tx_index();
|
||||
if !visited.insert(parent) || !same_block(parent) {
|
||||
continue;
|
||||
}
|
||||
ancestor_idxs.push(parent);
|
||||
queue.push(parent);
|
||||
if ancestor_idxs.len() >= MAX {
|
||||
break 'a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut descendant_idxs: Vec<TxIndex> = Vec::with_capacity(MAX);
|
||||
let mut queue: Vec<TxIndex> = vec![seed_idx];
|
||||
'd: while let Some(cur) = queue.pop() {
|
||||
let Ok(start) = first_txout.get(cur.to_usize()).data() else { continue };
|
||||
let Ok(end) = first_txout.get(cur.to_usize() + 1).data() else { continue };
|
||||
for i in usize::from(start)..usize::from(end) {
|
||||
let Ok(txin_idx) = spent.get(i).data() else { continue };
|
||||
if txin_idx == TxInIndex::UNSPENT {
|
||||
continue;
|
||||
}
|
||||
let Ok(child) = spending_tx.get(usize::from(txin_idx)).data() else { continue };
|
||||
if !visited.insert(child) || !same_block(child) {
|
||||
continue;
|
||||
}
|
||||
descendant_idxs.push(child);
|
||||
queue.push(child);
|
||||
if descendant_idxs.len() >= MAX {
|
||||
break 'd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: bulk-fetch (weight, fee) for seed + cluster, cursors opened
|
||||
// once and reads issued in tx_index order for sequential page locality.
|
||||
let mut all = Vec::with_capacity(1 + ancestor_idxs.len() + descendant_idxs.len());
|
||||
all.push(seed_idx);
|
||||
all.extend(&ancestor_idxs);
|
||||
all.extend(&descendant_idxs);
|
||||
let Ok(weights_fees) = self.txs_weight_fee(&all) else {
|
||||
return CpfpInfo::default();
|
||||
};
|
||||
|
||||
let txid_reader = indexer.vecs.transactions.txid.reader();
|
||||
let entry_at = |i: usize, idx: TxIndex| {
|
||||
let (weight, fee) = weights_fees[i];
|
||||
CpfpEntry {
|
||||
txid: txid_reader.get(idx.to_usize()),
|
||||
weight,
|
||||
fee,
|
||||
}
|
||||
};
|
||||
let (seed_weight, seed_fee) = weights_fees[0];
|
||||
let seed_vsize = VSize::from(seed_weight);
|
||||
let ancestors: Vec<CpfpEntry> = ancestor_idxs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, &idx)| entry_at(1 + k, idx))
|
||||
.collect();
|
||||
let descendants: Vec<CpfpEntry> = descendant_idxs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, &idx)| entry_at(1 + ancestor_idxs.len() + k, idx))
|
||||
.collect();
|
||||
|
||||
let (sum_fee, sum_vsize) = ancestors
|
||||
.iter()
|
||||
.chain(descendants.iter())
|
||||
.fold((u64::from(seed_fee), u64::from(seed_vsize)), |(f, v), e| {
|
||||
(f + u64::from(e.fee), v + u64::from(VSize::from(e.weight)))
|
||||
});
|
||||
let package_rate = FeeRate::from((Sats::from(sum_fee), VSize::from(sum_vsize)));
|
||||
let effective = FeeRate::from((seed_fee, seed_vsize)).max(package_rate);
|
||||
|
||||
let best_descendant = descendants
|
||||
.iter()
|
||||
.max_by_key(|e| FeeRate::from((e.fee, e.weight)))
|
||||
.cloned();
|
||||
|
||||
CpfpInfo {
|
||||
ancestors,
|
||||
best_descendant,
|
||||
descendants,
|
||||
effective_fee_per_vsize: Some(effective),
|
||||
sigops: None,
|
||||
fee: Some(seed_fee),
|
||||
adjusted_vsize: Some(seed_vsize),
|
||||
cluster: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk read `(weight, fee)` for many tx_indexes. Cursors opened once;
|
||||
/// reads issued in ascending `tx_index` order for sequential I/O,
|
||||
/// results returned in the caller's order.
|
||||
fn txs_weight_fee(&self, idxs: &[TxIndex]) -> Result<Vec<(Weight, Sats)>> {
|
||||
if idxs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let mut base_size = indexer.vecs.transactions.base_size.cursor();
|
||||
let mut total_size = indexer.vecs.transactions.total_size.cursor();
|
||||
let mut fee_cursor = computer.transactions.fees.fee.tx_index.cursor();
|
||||
|
||||
let mut order: Vec<usize> = (0..idxs.len()).collect();
|
||||
order.sort_unstable_by_key(|&i| idxs[i]);
|
||||
|
||||
let mut out = vec![(Weight::default(), Sats::ZERO); idxs.len()];
|
||||
for &pos in &order {
|
||||
let i = idxs[pos].to_usize();
|
||||
let bs = base_size.get(i).data()?;
|
||||
let ts = total_size.get(i).data()?;
|
||||
let f = fee_cursor.get(i).data()?;
|
||||
out[pos] = (Weight::from_sizes(*bs, *ts), f);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// RBF history for a tx, matching mempool.space's
|
||||
/// `GET /api/v1/tx/:txid/rbf`. Walks forward through the graveyard
|
||||
/// to find the latest known replacer (tree root), then recursively
|
||||
@@ -295,21 +104,15 @@ impl Query {
|
||||
let entries = mempool.entries();
|
||||
let graveyard = mempool.graveyard();
|
||||
|
||||
let mut root_txid = txid.clone();
|
||||
while let Some(TxRemoval::Replaced { by }) =
|
||||
graveyard.get(&root_txid).map(TxTombstone::reason)
|
||||
{
|
||||
root_txid = by.clone();
|
||||
}
|
||||
let root_txid = Self::walk_to_replacement_root(&graveyard, *txid);
|
||||
|
||||
let replaces_vec: Vec<Txid> = graveyard
|
||||
.predecessors_of(txid)
|
||||
.map(|(p, _)| p.clone())
|
||||
.map(|(p, _)| *p)
|
||||
.collect();
|
||||
let replaces = (!replaces_vec.is_empty()).then_some(replaces_vec);
|
||||
|
||||
let replacements =
|
||||
self.build_rbf_node(&root_txid, None, mempool, &txs, &entries, &graveyard);
|
||||
let replacements = self.build_rbf_node(&root_txid, None, &txs, &entries, &graveyard);
|
||||
|
||||
Ok(RbfResponse {
|
||||
replacements,
|
||||
@@ -317,6 +120,18 @@ impl Query {
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk forward through `Replaced { by }` links to the terminal
|
||||
/// replacer of an RBF chain. Returns `txid` itself if it's already
|
||||
/// the root.
|
||||
fn walk_to_replacement_root(graveyard: &TxGraveyard, mut root: Txid) -> Txid {
|
||||
while let Some(TxRemoval::Replaced { by }) =
|
||||
graveyard.get(&root).map(TxTombstone::reason)
|
||||
{
|
||||
root = *by;
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Resolve a txid to the data we need for an `RbfTx`. The live
|
||||
/// pool takes priority; the graveyard is the fallback. Returns
|
||||
/// `None` if the tx has no known data in either.
|
||||
@@ -337,16 +152,13 @@ impl Query {
|
||||
/// `Removal::Replaced` lives), so the recursion only needs the
|
||||
/// graveyard; the live pool is consulted for the root.
|
||||
///
|
||||
/// `rate` matches mempool.space's `tx.effectiveFeePerVsize`: live
|
||||
/// txs get the live CPFP-cluster effective rate; mined txs get the
|
||||
/// computer's stored same-block-cluster effective rate; never-mined
|
||||
/// replaced predecessors have no recorded effective rate, so we
|
||||
/// fall back to the simple `fee/vsize` snapshotted at burial.
|
||||
/// `rate` matches mempool.space's `tx.effectiveFeePerVsize` via
|
||||
/// `Query::effective_fee_rate`, with a fall-back to the entry's
|
||||
/// simple `fee/vsize` when the rate lookup fails.
|
||||
fn build_rbf_node(
|
||||
&self,
|
||||
txid: &Txid,
|
||||
successor_time: Option<Timestamp>,
|
||||
mempool: &Mempool,
|
||||
txs: &TxStore,
|
||||
entries: &EntryPool,
|
||||
graveyard: &TxGraveyard,
|
||||
@@ -356,14 +168,7 @@ impl Query {
|
||||
let replaces: Vec<ReplacementNode> = graveyard
|
||||
.predecessors_of(txid)
|
||||
.filter_map(|(pred_txid, _)| {
|
||||
self.build_rbf_node(
|
||||
pred_txid,
|
||||
Some(entry.first_seen),
|
||||
mempool,
|
||||
txs,
|
||||
entries,
|
||||
graveyard,
|
||||
)
|
||||
self.build_rbf_node(pred_txid, Some(entry.first_seen), txs, entries, graveyard)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -371,31 +176,17 @@ impl Query {
|
||||
|
||||
let interval = successor_time
|
||||
.and_then(|st| st.checked_sub(entry.first_seen))
|
||||
.map(|d| usize::from(d) as u32);
|
||||
.map(|d| *d);
|
||||
|
||||
let value = Sats::from(tx.output.iter().map(|o| u64::from(o.value)).sum::<u64>());
|
||||
let tx_index = self.resolve_tx_index(txid).ok();
|
||||
let mined = tx_index.map(|_| true);
|
||||
let rate = if txs.contains(txid) {
|
||||
mempool
|
||||
.cpfp_info(&TxidPrefix::from(txid))
|
||||
.and_then(|info| info.effective_fee_per_vsize)
|
||||
.unwrap_or_else(|| entry.fee_rate())
|
||||
} else if let Some(idx) = tx_index {
|
||||
self.computer()
|
||||
.transactions
|
||||
.fees
|
||||
.effective_fee_rate
|
||||
.tx_index
|
||||
.collect_one(idx)
|
||||
.unwrap_or_else(|| entry.fee_rate())
|
||||
} else {
|
||||
entry.fee_rate()
|
||||
};
|
||||
let value: Sats = tx.output.iter().map(|o| o.value).sum();
|
||||
let mined = self.resolve_tx_index(txid).is_ok().then_some(true);
|
||||
let rate = self
|
||||
.effective_fee_rate(txid)
|
||||
.unwrap_or_else(|_| entry.fee_rate());
|
||||
|
||||
Some(ReplacementNode {
|
||||
tx: RbfTx {
|
||||
txid: txid.clone(),
|
||||
txid: *txid,
|
||||
fee: entry.fee,
|
||||
vsize: entry.vsize,
|
||||
value,
|
||||
@@ -435,17 +226,10 @@ impl Query {
|
||||
Ok(graveyard
|
||||
.replaced_iter_recent_first()
|
||||
.filter_map(|(_, by)| {
|
||||
let mut root = by.clone();
|
||||
while let Some(TxRemoval::Replaced { by: next }) =
|
||||
graveyard.get(&root).map(TxTombstone::reason)
|
||||
{
|
||||
root = next.clone();
|
||||
}
|
||||
seen.insert(root.clone()).then_some(root)
|
||||
})
|
||||
.filter_map(|root| {
|
||||
self.build_rbf_node(&root, None, mempool, &txs, &entries, &graveyard)
|
||||
let root = Self::walk_to_replacement_root(&graveyard, *by);
|
||||
seen.insert(root).then_some(root)
|
||||
})
|
||||
.filter_map(|root| self.build_rbf_node(&root, None, &txs, &entries, &graveyard))
|
||||
.filter(|node| !full_rbf_only || node.full_rbf)
|
||||
.take(RECENT_REPLACEMENTS_LIMIT)
|
||||
.collect())
|
||||
@@ -461,8 +245,7 @@ impl Query {
|
||||
.map(|txid| {
|
||||
entries
|
||||
.get(&TxidPrefix::from(txid))
|
||||
.map(|e| u64::from(e.first_seen))
|
||||
.unwrap_or(0)
|
||||
.map_or(0, |e| u64::from(e.first_seen))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ use super::block_window::BlockWindow;
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// Time-bucketed fee-rate percentiles over `time_period`. One entry per
|
||||
/// bucket, ordered chronologically. Each entry carries the bucket's
|
||||
/// average height/timestamp and the seven percentile means
|
||||
/// (`min, pct10, pct25, median, pct75, pct90, max`).
|
||||
pub fn block_fee_rates(&self, time_period: TimePeriod) -> Result<Vec<BlockFeeRatesEntry>> {
|
||||
let bw = BlockWindow::new(self, time_period);
|
||||
let bw = BlockWindow::new(self, time_period)?;
|
||||
let frd = &self
|
||||
.computer()
|
||||
.transactions
|
||||
@@ -15,13 +19,13 @@ impl Query {
|
||||
.distribution
|
||||
.block;
|
||||
|
||||
let min = bw.read(&frd.min.height);
|
||||
let pct10 = bw.read(&frd.pct10.height);
|
||||
let pct25 = bw.read(&frd.pct25.height);
|
||||
let median = bw.read(&frd.median.height);
|
||||
let pct75 = bw.read(&frd.pct75.height);
|
||||
let pct90 = bw.read(&frd.pct90.height);
|
||||
let max = bw.read(&frd.max.height);
|
||||
let min = bw.read(&frd.min.height)?;
|
||||
let pct10 = bw.read(&frd.pct10.height)?;
|
||||
let pct25 = bw.read(&frd.pct25.height)?;
|
||||
let median = bw.read(&frd.median.height)?;
|
||||
let pct75 = bw.read(&frd.pct75.height)?;
|
||||
let pct90 = bw.read(&frd.pct90.height)?;
|
||||
let max = bw.read(&frd.max.height)?;
|
||||
|
||||
Ok(bw
|
||||
.buckets
|
||||
|
||||
@@ -5,10 +5,15 @@ use super::block_window::BlockWindow;
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// Time-bucketed average block fees over `time_period`. One entry per
|
||||
/// bucket, ordered chronologically. Each entry carries the bucket's
|
||||
/// average height/timestamp, the round-half-up mean of block fees in
|
||||
/// sats, and the bucket-mean USD spot price (the spot price, not
|
||||
/// fees-in-USD: clients multiply).
|
||||
pub fn block_fees(&self, time_period: TimePeriod) -> Result<Vec<BlockFeesEntry>> {
|
||||
let bw = BlockWindow::new(self, time_period);
|
||||
let fees: Vec<Sats> = bw.read(&self.computer().mining.rewards.fees.block.sats);
|
||||
let prices: Vec<Cents> = bw.read(&self.computer().prices.spot.cents.height);
|
||||
let bw = BlockWindow::new(self, time_period)?;
|
||||
let fees: Vec<Sats> = bw.read(&self.computer().mining.rewards.fees.block.sats)?;
|
||||
let prices: Vec<Cents> = bw.read(&self.computer().prices.spot.cents.height)?;
|
||||
|
||||
Ok(bw
|
||||
.buckets
|
||||
|
||||
@@ -5,10 +5,15 @@ use super::block_window::BlockWindow;
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// Time-bucketed average block rewards (subsidy + fees) over
|
||||
/// `time_period`. One entry per bucket, ordered chronologically. Each
|
||||
/// entry carries the bucket's average height/timestamp, the round-half-up
|
||||
/// mean of coinbase rewards in sats, and the bucket-mean USD spot price
|
||||
/// (the spot price, not rewards-in-USD: clients multiply).
|
||||
pub fn block_rewards(&self, time_period: TimePeriod) -> Result<Vec<BlockRewardsEntry>> {
|
||||
let bw = BlockWindow::new(self, time_period);
|
||||
let rewards: Vec<Sats> = bw.read(&self.computer().mining.rewards.coinbase.block.sats);
|
||||
let prices: Vec<Cents> = bw.read(&self.computer().prices.spot.cents.height);
|
||||
let bw = BlockWindow::new(self, time_period)?;
|
||||
let rewards: Vec<Sats> = bw.read(&self.computer().mining.rewards.coinbase.block.sats)?;
|
||||
let prices: Vec<Cents> = bw.read(&self.computer().prices.spot.cents.height)?;
|
||||
|
||||
Ok(bw
|
||||
.buckets
|
||||
|
||||
@@ -7,12 +7,18 @@ use super::block_window::BlockWindow;
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// Time-bucketed average block size and weight over `time_period`. Returns
|
||||
/// two parallel vecs (one entry per bucket, ordered chronologically): byte
|
||||
/// size in `sizes`, weight units in `weights`. Each entry carries the
|
||||
/// bucket's average height/timestamp and the round-half-up mean of the
|
||||
/// corresponding metric. Single bucket-pass: built via `.map(...).unzip()`
|
||||
/// to avoid re-walking buckets.
|
||||
pub fn block_sizes_weights(&self, time_period: TimePeriod) -> Result<BlockSizesWeights> {
|
||||
let blocks = &self.indexer().vecs.blocks;
|
||||
let bw = BlockWindow::new(self, time_period);
|
||||
let bw = BlockWindow::new(self, time_period)?;
|
||||
|
||||
let block_sizes: Vec<StoredU64> = bw.read(&blocks.total);
|
||||
let block_weights: Vec<Weight> = bw.read(&blocks.weight);
|
||||
let block_sizes: Vec<StoredU64> = bw.read(&blocks.total)?;
|
||||
let block_weights: Vec<Weight> = bw.read(&blocks.weight)?;
|
||||
|
||||
let (sizes, weights) = bw
|
||||
.buckets
|
||||
|
||||
@@ -4,13 +4,15 @@ use std::{
|
||||
ops::{Deref, Div},
|
||||
};
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::{Height, TimePeriod, Timestamp};
|
||||
use vecdb::{ReadableVec, VecValue};
|
||||
|
||||
use crate::Query;
|
||||
|
||||
/// Mempool.space's `GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}` divisor in seconds.
|
||||
/// `div = 1` puts each block in its own bucket.
|
||||
/// Time-bucket divisor in seconds: blocks are grouped by `timestamp / div`.
|
||||
/// `div = 1` puts each block in its own bucket; coarser values down-sample
|
||||
/// long windows so the response stays bounded.
|
||||
fn time_div(period: TimePeriod) -> u32 {
|
||||
match period {
|
||||
TimePeriod::Day | TimePeriod::ThreeDays => 1,
|
||||
@@ -39,7 +41,10 @@ pub struct BlockBucket {
|
||||
|
||||
impl BlockBucket {
|
||||
/// Float arithmetic mean of `values[offset]` across this bucket's blocks.
|
||||
/// Use for float-backed types like `FeeRate`.
|
||||
/// Use for float-backed types like `FeeRate`. Soundness: `offsets.len() >= 1`
|
||||
/// is guaranteed by `BlockWindow::new` (only non-empty groups become buckets),
|
||||
/// and indexing `values[i]` is in range when `values` was obtained via
|
||||
/// `BlockWindow::read` (which validates `values.len() >= window.len`).
|
||||
pub fn mean<T>(&self, values: &[T]) -> T
|
||||
where
|
||||
T: Copy + Sum + Div<usize, Output = T>,
|
||||
@@ -47,8 +52,11 @@ impl BlockBucket {
|
||||
self.offsets.iter().map(|&i| values[i]).sum::<T>() / self.offsets.len()
|
||||
}
|
||||
|
||||
/// Round-half-up arithmetic mean for u64-backed integer types, matching
|
||||
/// mempool.space's `CAST(AVG(...) AS INT)`.
|
||||
/// Round-half-up arithmetic mean for u64-backed integer types: returns
|
||||
/// `T::from((sum + n/2) / n)`. Use when truncating integer division would
|
||||
/// bias rolling averages downward. Soundness: `offsets.len() >= 1` is
|
||||
/// guaranteed by `BlockWindow::new`, and `values[i]` is in range when
|
||||
/// `values` was obtained via `BlockWindow::read`.
|
||||
pub fn mean_rounded<T>(&self, values: &[T]) -> T
|
||||
where
|
||||
T: Copy + Deref<Target = u64> + From<u64>,
|
||||
@@ -65,11 +73,22 @@ pub struct BlockWindow {
|
||||
pub start: Height,
|
||||
pub end: Height,
|
||||
pub buckets: Vec<BlockBucket>,
|
||||
/// Number of blocks observed in `[start, end)` at construction. Equals
|
||||
/// `timestamps.len()` after the prefetch; may be less than `end - start`
|
||||
/// when the timestamp vec lags under per-vec stamp race. Every value vec
|
||||
/// passed to `read` must yield at least this many elements.
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
impl BlockWindow {
|
||||
pub fn new(query: &Query, period: TimePeriod) -> Self {
|
||||
let start = query.start_height(period);
|
||||
/// Build a time-bucketed window over `[start_height(period), tip + 1)`.
|
||||
/// Prefetches `blocks.timestamp` once, groups block indices by
|
||||
/// `ts / div(period)` (chronological), and stores per-bucket offsets
|
||||
/// into the prefetched slice. Downstream metric reads (`BlockWindow::read`)
|
||||
/// reuse the same `[start, end)` so each bucket's offsets index directly
|
||||
/// into the value vec without a second walk.
|
||||
pub fn new(query: &Query, period: TimePeriod) -> Result<Self> {
|
||||
let start = query.start_height(period)?;
|
||||
let end = query.height() + 1usize;
|
||||
let div = time_div(period);
|
||||
|
||||
@@ -85,6 +104,8 @@ impl BlockWindow {
|
||||
groups.entry(**ts / div).or_default().push(i);
|
||||
}
|
||||
|
||||
let len = timestamps.len();
|
||||
|
||||
let buckets = groups
|
||||
.into_values()
|
||||
.map(|offsets| {
|
||||
@@ -99,19 +120,29 @@ impl BlockWindow {
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
start,
|
||||
end,
|
||||
buckets,
|
||||
}
|
||||
len,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a height-keyed vec over this window's `[start, end)` range.
|
||||
pub fn read<V, T>(&self, vec: &V) -> Vec<T>
|
||||
/// Errors if the vec returns fewer elements than the window observed at
|
||||
/// construction (per-vec stamp lag): bucket offsets reach up to `len - 1`
|
||||
/// and would otherwise panic in `BlockBucket::mean(&values)`.
|
||||
pub fn read<V, T>(&self, vec: &V) -> Result<Vec<T>>
|
||||
where
|
||||
V: ReadableVec<Height, T>,
|
||||
T: VecValue,
|
||||
{
|
||||
vec.collect_range(self.start, self.end)
|
||||
let values = vec.collect_range(self.start, self.end);
|
||||
if values.len() < self.len {
|
||||
return Err(Error::Internal(
|
||||
"BlockWindow::read: value vec shorter than window (per-vec stamp lag)",
|
||||
));
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,18 @@ const BLOCKS_PER_EPOCH: u32 = 2016;
|
||||
const TARGET_BLOCK_TIME: u64 = 600;
|
||||
|
||||
impl Query {
|
||||
/// Live difficulty-adjustment snapshot for the current epoch. Bundles
|
||||
/// progress through the 2016-block window, the projected next-retarget
|
||||
/// percentage from observed pace, an estimated wall-clock retarget time,
|
||||
/// remaining blocks/time, the previous retarget percentage (current epoch
|
||||
/// vs previous epoch first-block difficulty), and the time offset from a
|
||||
/// 600s/block schedule. Output time fields are in milliseconds.
|
||||
pub fn difficulty_adjustment(&self) -> Result<DifficultyAdjustment> {
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let current_height = self.height();
|
||||
let current_height_u32: u32 = current_height.into();
|
||||
|
||||
// Get current epoch
|
||||
let current_epoch = computer
|
||||
.indexes
|
||||
.height
|
||||
@@ -28,7 +33,6 @@ impl Query {
|
||||
.data()?;
|
||||
let current_epoch_usize: usize = current_epoch.into();
|
||||
|
||||
// Get epoch start height
|
||||
let epoch_start_height = computer
|
||||
.indexes
|
||||
.epoch
|
||||
@@ -37,13 +41,11 @@ impl Query {
|
||||
.data()?;
|
||||
let epoch_start_u32: u32 = epoch_start_height.into();
|
||||
|
||||
// Calculate epoch progress
|
||||
let next_retarget_height = epoch_start_u32 + BLOCKS_PER_EPOCH;
|
||||
let blocks_into_epoch = current_height_u32 - epoch_start_u32;
|
||||
let remaining_blocks = next_retarget_height - current_height_u32;
|
||||
let progress_percent = (blocks_into_epoch as f64 / BLOCKS_PER_EPOCH as f64) * 100.0;
|
||||
|
||||
// Get timestamps using difficulty_to_timestamp for epoch start
|
||||
let epoch_start_timestamp = computer
|
||||
.indexes
|
||||
.timestamp
|
||||
@@ -57,8 +59,11 @@ impl Query {
|
||||
.collect_one(current_height)
|
||||
.data()?;
|
||||
|
||||
// Calculate average block time in current epoch
|
||||
let elapsed_time = (*current_timestamp - *epoch_start_timestamp) as u64;
|
||||
// Bitcoin block timestamps can step backward within MTP rules, so
|
||||
// saturate the subtraction to avoid u32 underflow on a backwards-going
|
||||
// first block of an epoch.
|
||||
let elapsed_time =
|
||||
u64::from((*current_timestamp).saturating_sub(*epoch_start_timestamp));
|
||||
let time_avg = if blocks_into_epoch > 0 {
|
||||
elapsed_time / blocks_into_epoch as u64
|
||||
} else {
|
||||
@@ -66,7 +71,8 @@ impl Query {
|
||||
};
|
||||
|
||||
// Per-block time needed over remaining blocks to land the epoch at
|
||||
// 2016 * TARGET_BLOCK_TIME. Matches mempool.space's adjustedTimeAvg.
|
||||
// BLOCKS_PER_EPOCH * TARGET_BLOCK_TIME (the convergence path that
|
||||
// client UIs render as adjustedTimeAvg).
|
||||
let target_total = BLOCKS_PER_EPOCH as u64 * TARGET_BLOCK_TIME;
|
||||
let adjusted_time_avg = if remaining_blocks > 0 {
|
||||
target_total.saturating_sub(elapsed_time) / remaining_blocks as u64
|
||||
@@ -74,15 +80,13 @@ impl Query {
|
||||
TARGET_BLOCK_TIME
|
||||
};
|
||||
|
||||
// Estimate remaining time and retarget date
|
||||
let remaining_time = remaining_blocks as u64 * adjusted_time_avg;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(*current_timestamp as u64);
|
||||
.unwrap_or(u64::from(*current_timestamp));
|
||||
let estimated_retarget_date = now + remaining_time;
|
||||
|
||||
// Calculate expected vs actual time for difficulty change estimate
|
||||
let expected_time = blocks_into_epoch as u64 * TARGET_BLOCK_TIME;
|
||||
let difficulty_change = if elapsed_time > 0 && blocks_into_epoch > 0 {
|
||||
((expected_time as f64 / elapsed_time as f64) - 1.0) * 100.0
|
||||
@@ -90,10 +94,8 @@ impl Query {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Time offset from expected schedule
|
||||
let time_offset = expected_time as i64 - elapsed_time as i64;
|
||||
|
||||
// Calculate previous retarget using stored difficulty values
|
||||
let (previous_retarget, previous_time) = if current_epoch_usize > 0 {
|
||||
let prev_epoch = Epoch::from(current_epoch_usize - 1);
|
||||
let prev_epoch_start = computer
|
||||
@@ -127,7 +129,6 @@ impl Query {
|
||||
(0.0, epoch_start_timestamp)
|
||||
};
|
||||
|
||||
// Expected blocks based on wall clock time since epoch start
|
||||
let expected_blocks = elapsed_time as f64 / TARGET_BLOCK_TIME as f64;
|
||||
|
||||
Ok(DifficultyAdjustment {
|
||||
|
||||
@@ -6,21 +6,23 @@ use super::epochs::iter_difficulty_epochs;
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// All difficulty adjustments (one entry per retarget) whose first block
|
||||
/// lies within `time_period`, in reverse chronological order (newest
|
||||
/// first). `None` walks every epoch from genesis. The window cutoff is
|
||||
/// wall-clock (via `start_height`) rather than block-count, so the
|
||||
/// returned set is "epochs whose first block lies within the period",
|
||||
/// not "the last N epochs".
|
||||
pub fn difficulty_adjustments(
|
||||
&self,
|
||||
time_period: Option<TimePeriod>,
|
||||
) -> Result<Vec<DifficultyAdjustmentEntry>> {
|
||||
let end = self.height().to_usize();
|
||||
// Match mempool.space's wall-clock `time > NOW() - INTERVAL ${period}` cutoff
|
||||
// by walking back through real block timestamps, not estimating via block count.
|
||||
let start = match time_period {
|
||||
Some(tp) => self.start_height(tp).to_usize(),
|
||||
Some(tp) => self.start_height(tp)?.to_usize(),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let mut entries = iter_difficulty_epochs(self.computer(), start, end);
|
||||
|
||||
// Return in reverse chronological order (newest first)
|
||||
let mut entries = iter_difficulty_epochs(self.computer(), start, end)?;
|
||||
entries.reverse();
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
use brk_computer::Computer;
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::{DifficultyAdjustmentEntry, Height};
|
||||
use vecdb::{ReadableVec, Ro, VecIndex};
|
||||
|
||||
/// Iterate over difficulty epochs within a height range.
|
||||
pub fn iter_difficulty_epochs(
|
||||
/// Walk every difficulty epoch overlapping `[start_height, end_height]` and
|
||||
/// return one `DifficultyAdjustmentEntry` per retarget whose first block
|
||||
/// lies inside the window. Each entry carries the epoch's first-block
|
||||
/// timestamp/height, the epoch's difficulty, and the new/previous difficulty
|
||||
/// ratio (e.g. 1.068 = +6.8%, matching the field's contract). Epochs whose
|
||||
/// first block falls before `start_height` are skipped but their difficulty
|
||||
/// is still read so the next in-window entry can compute its ratio. Returns
|
||||
/// `Error::Internal` on any missing cursor read so corrupt zero-valued
|
||||
/// entries cannot slip into the output under per-vec stamp lag.
|
||||
pub(super) fn iter_difficulty_epochs(
|
||||
computer: &Computer<Ro>,
|
||||
start_height: usize,
|
||||
end_height: usize,
|
||||
) -> Vec<DifficultyAdjustmentEntry> {
|
||||
) -> Result<Vec<DifficultyAdjustmentEntry>> {
|
||||
let start_epoch = computer
|
||||
.indexes
|
||||
.height
|
||||
.epoch
|
||||
.collect_one(Height::from(start_height))
|
||||
.unwrap_or_default();
|
||||
.ok_or(Error::Internal(
|
||||
"iter_difficulty_epochs: start_height not in epoch index",
|
||||
))?;
|
||||
let end_epoch = computer
|
||||
.indexes
|
||||
.height
|
||||
.epoch
|
||||
.collect_one(Height::from(end_height))
|
||||
.unwrap_or_default();
|
||||
.ok_or(Error::Internal(
|
||||
"iter_difficulty_epochs: end_height not in epoch index",
|
||||
))?;
|
||||
|
||||
let mut height_cursor = computer.indexes.epoch.first_height.cursor();
|
||||
let mut timestamp_cursor = computer.indexes.timestamp.epoch.cursor();
|
||||
@@ -29,16 +42,25 @@ pub fn iter_difficulty_epochs(
|
||||
let mut prev_difficulty: Option<f64> = None;
|
||||
|
||||
for epoch_usize in start_epoch.to_usize()..=end_epoch.to_usize() {
|
||||
let epoch_height = height_cursor.get(epoch_usize).unwrap_or_default();
|
||||
let epoch_height = height_cursor.get(epoch_usize).ok_or(Error::Internal(
|
||||
"iter_difficulty_epochs: missing epoch first_height",
|
||||
))?;
|
||||
|
||||
// Skip epochs before our start height but track difficulty
|
||||
// Epochs that start before the window are skipped; we still record
|
||||
// their difficulty so the next in-window entry can compute its ratio.
|
||||
if epoch_height.to_usize() < start_height {
|
||||
prev_difficulty = difficulty_cursor.get(epoch_usize).map(|d| *d);
|
||||
prev_difficulty = Some(*difficulty_cursor.get(epoch_usize).ok_or(
|
||||
Error::Internal("iter_difficulty_epochs: missing pre-window epoch difficulty"),
|
||||
)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
let epoch_timestamp = timestamp_cursor.get(epoch_usize).unwrap_or_default();
|
||||
let epoch_difficulty = *difficulty_cursor.get(epoch_usize).unwrap_or_default();
|
||||
let epoch_timestamp = timestamp_cursor.get(epoch_usize).ok_or(Error::Internal(
|
||||
"iter_difficulty_epochs: missing epoch timestamp",
|
||||
))?;
|
||||
let epoch_difficulty = *difficulty_cursor.get(epoch_usize).ok_or(Error::Internal(
|
||||
"iter_difficulty_epochs: missing epoch difficulty",
|
||||
))?;
|
||||
|
||||
let change_percent = match prev_difficulty {
|
||||
Some(prev) if prev > 0.0 => epoch_difficulty / prev,
|
||||
@@ -55,5 +77,5 @@ pub fn iter_difficulty_epochs(
|
||||
prev_difficulty = Some(epoch_difficulty);
|
||||
}
|
||||
|
||||
results
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,39 @@ use super::epochs::iter_difficulty_epochs;
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
pub fn hashrate(&self, time_period: Option<TimePeriod>) -> Result<HashrateSummary> {
|
||||
/// Network 1-day hashrate at the day containing `height`. Errors on
|
||||
/// stamp lag in the day1 index or in the daily-hashrate vec, so a
|
||||
/// transient dropout surfaces instead of silently reporting zero.
|
||||
pub(super) fn hashrate_at(&self, height: Height) -> Result<u128> {
|
||||
let computer = self.computer();
|
||||
let day = computer.indexes.height.day1.collect_one(height).data()?;
|
||||
Ok(*computer
|
||||
.mining
|
||||
.hashrate
|
||||
.rate
|
||||
.base
|
||||
.day1
|
||||
.collect_one_flat(day)
|
||||
.data()? as u128)
|
||||
}
|
||||
|
||||
/// Network hashrate summary for `time_period` (`None` walks the full
|
||||
/// chain). Bundles a downsampled daily hashrate series (at most
|
||||
/// `max_points` samples; sampling step is `total_days / max_points`,
|
||||
/// floored at 1), every difficulty retarget within the window, the
|
||||
/// current 1-day hashrate, and the current block's difficulty. The
|
||||
/// window cutoff is wall-clock (via `start_height`), matching
|
||||
/// `difficulty_adjustments` so the two endpoints agree on the same
|
||||
/// `time_period`.
|
||||
pub fn hashrate(
|
||||
&self,
|
||||
time_period: Option<TimePeriod>,
|
||||
max_points: usize,
|
||||
) -> Result<HashrateSummary> {
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let current_height = self.height();
|
||||
|
||||
// Get current difficulty
|
||||
let current_difficulty = *indexer
|
||||
.vecs
|
||||
.blocks
|
||||
@@ -19,7 +46,7 @@ impl Query {
|
||||
.collect_one(current_height)
|
||||
.data()?;
|
||||
|
||||
// Get current hashrate
|
||||
let current_hashrate = self.hashrate_at(current_height)?;
|
||||
let current_day1 = computer
|
||||
.indexes
|
||||
.height
|
||||
@@ -27,23 +54,12 @@ impl Query {
|
||||
.collect_one(current_height)
|
||||
.data()?;
|
||||
|
||||
let current_hashrate = *computer
|
||||
.mining
|
||||
.hashrate
|
||||
.rate
|
||||
.base
|
||||
.day1
|
||||
.collect_one_flat(current_day1)
|
||||
.unwrap_or_default() as u128;
|
||||
|
||||
// Calculate start height based on time period
|
||||
let end = current_height.to_usize();
|
||||
let start = match time_period {
|
||||
Some(tp) => end.saturating_sub(tp.block_count()),
|
||||
Some(tp) => self.start_height(tp)?.to_usize(),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
// Get hashrate entries using iterators for efficiency
|
||||
let start_day1 = computer
|
||||
.indexes
|
||||
.height
|
||||
@@ -52,9 +68,10 @@ impl Query {
|
||||
.data()?;
|
||||
let end_day1 = current_day1;
|
||||
|
||||
// Sample at regular intervals to avoid too many data points
|
||||
// Sample at regular intervals so the chart payload stays bounded
|
||||
// regardless of window size.
|
||||
let total_days = end_day1.to_usize().saturating_sub(start_day1.to_usize()) + 1;
|
||||
let step = (total_days / 200).max(1); // Max ~200 data points
|
||||
let step = (total_days / max_points.max(1)).max(1);
|
||||
|
||||
let mut hr_cursor = computer.mining.hashrate.rate.base.day1.cursor();
|
||||
let mut ts_cursor = computer.indexes.timestamp.day1.cursor();
|
||||
@@ -71,8 +88,7 @@ impl Query {
|
||||
di += step;
|
||||
}
|
||||
|
||||
// Get difficulty adjustments within the period
|
||||
let difficulty: Vec<DifficultyEntry> = iter_difficulty_epochs(computer, start, end)
|
||||
let difficulty: Vec<DifficultyEntry> = iter_difficulty_epochs(computer, start, end)?
|
||||
.into_iter()
|
||||
.map(|e| DifficultyEntry {
|
||||
time: e.timestamp,
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
use brk_error::{OptionData, Result};
|
||||
use brk_types::{Height, TimePeriod};
|
||||
use vecdb::ReadableVec;
|
||||
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// First block height inside `period` looking back from the tip; genesis (0) for `All`.
|
||||
pub(super) fn start_height(&self, period: TimePeriod) -> Height {
|
||||
self.computer()
|
||||
.blocks
|
||||
.lookback
|
||||
.start_height(period, self.height())
|
||||
.unwrap_or_default()
|
||||
/// First block height inside `period` looking back from the tip;
|
||||
/// genesis (`Height(0)`) for `All`. Errors `Internal` if the chosen
|
||||
/// lookback vec is stamped short of the tip - separating the
|
||||
/// "all-time" case from a transient stamp-lag dropout that would
|
||||
/// otherwise silently widen a windowed query to the full chain.
|
||||
pub(super) fn start_height(&self, period: TimePeriod) -> Result<Height> {
|
||||
let lookback = &self.computer().blocks.lookback;
|
||||
let tip = self.height();
|
||||
Ok(match period {
|
||||
TimePeriod::Day => lookback._24h.collect_one(tip).data()?,
|
||||
TimePeriod::ThreeDays => lookback._3d.collect_one(tip).data()?,
|
||||
TimePeriod::Week => lookback._1w.collect_one(tip).data()?,
|
||||
TimePeriod::Month => lookback._1m.collect_one(tip).data()?,
|
||||
TimePeriod::ThreeMonths => lookback._3m.collect_one(tip).data()?,
|
||||
TimePeriod::SixMonths => lookback._6m.collect_one(tip).data()?,
|
||||
TimePeriod::Year => lookback._1y.collect_one(tip).data()?,
|
||||
TimePeriod::TwoYears => lookback._2y.collect_one(tip).data()?,
|
||||
TimePeriod::ThreeYears => lookback._3y.collect_one(tip).data()?,
|
||||
TimePeriod::All => Height::from(0_usize),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::{borrow::Cow, cmp::Reverse};
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
use brk_error::{Error, OptionData, Result};
|
||||
use brk_types::{
|
||||
BlockInfoV1, Day1, Height, Pool, PoolBlockCounts, PoolBlockShares, PoolDetail, PoolDetailInfo,
|
||||
PoolHashrateEntry, PoolInfo, PoolSlug, PoolStats, PoolsSummary, StoredF64, StoredU64,
|
||||
@@ -10,9 +10,9 @@ use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use crate::Query;
|
||||
|
||||
/// 7-day lookback for share computation (matching mempool.space)
|
||||
/// 7-day lookback for share computation.
|
||||
const LOOKBACK_DAYS: usize = 7;
|
||||
/// Weekly sample interval (matching mempool.space's 604800s interval)
|
||||
/// Weekly sample interval (~604800s).
|
||||
const SAMPLE_WEEKLY: usize = 7;
|
||||
|
||||
/// Pre-read shared data for hashrate computation.
|
||||
@@ -24,11 +24,18 @@ struct HashrateSharedData {
|
||||
}
|
||||
|
||||
impl Query {
|
||||
/// Mining-pool leaderboard for `time_period`. For each pool, computes
|
||||
/// block count over the window via `cumulative(end) - cumulative(start - 1)`
|
||||
/// (tip-cumulative minus pre-window-cumulative), sorts pools by count
|
||||
/// descending, assigns ranks, and emits the per-pool share. Also bundles
|
||||
/// current / 3d / 1w network hashrate snapshots. Returns zeros early
|
||||
/// when no blocks have been indexed. The window start uses the
|
||||
/// timestamp-based lookback vecs (`_24h`, `_3d`, ...) rather than
|
||||
/// block-count math; `TimePeriod::All` walks from genesis.
|
||||
pub fn mining_pools(&self, time_period: TimePeriod) -> Result<PoolsSummary> {
|
||||
let computer = self.computer();
|
||||
let current_height = self.height();
|
||||
|
||||
// No blocks indexed yet
|
||||
if computer.pools.pool.len() == 0 {
|
||||
return Ok(PoolsSummary {
|
||||
pools: vec![],
|
||||
@@ -39,27 +46,13 @@ impl Query {
|
||||
});
|
||||
}
|
||||
|
||||
// Use timestamp-based lookback for accurate time boundaries
|
||||
let start = self.start_height(time_period)?.to_usize();
|
||||
let lookback = &computer.blocks.lookback;
|
||||
let start = match time_period {
|
||||
TimePeriod::Day => lookback._24h.collect_one(current_height),
|
||||
TimePeriod::ThreeDays => lookback._3d.collect_one(current_height),
|
||||
TimePeriod::Week => lookback._1w.collect_one(current_height),
|
||||
TimePeriod::Month => lookback._1m.collect_one(current_height),
|
||||
TimePeriod::ThreeMonths => lookback._3m.collect_one(current_height),
|
||||
TimePeriod::SixMonths => lookback._6m.collect_one(current_height),
|
||||
TimePeriod::Year => lookback._1y.collect_one(current_height),
|
||||
TimePeriod::TwoYears => lookback._2y.collect_one(current_height),
|
||||
TimePeriod::ThreeYears => lookback._3y.collect_one(current_height),
|
||||
TimePeriod::All => None,
|
||||
}
|
||||
.unwrap_or_default()
|
||||
.to_usize();
|
||||
|
||||
let pools = pools();
|
||||
let mut pool_data: Vec<(&'static Pool, u64)> = Vec::new();
|
||||
|
||||
// For each pool, get cumulative count at end and start, subtract to get range count
|
||||
// Range count = cumulative(end) - cumulative(start - 1).
|
||||
for (pool_id, cumulative) in computer
|
||||
.pools
|
||||
.major
|
||||
@@ -73,14 +66,12 @@ impl Query {
|
||||
.map(|(id, v)| (id, &v.blocks_mined.cumulative.height)),
|
||||
)
|
||||
{
|
||||
let count_at_end: u64 = *cumulative.collect_one(current_height).unwrap_or_default();
|
||||
let count_at_end: u64 = *cumulative.collect_one(current_height).data()?;
|
||||
|
||||
let count_at_start: u64 = if start == 0 {
|
||||
0
|
||||
} else {
|
||||
*cumulative
|
||||
.collect_one(Height::from(start - 1))
|
||||
.unwrap_or_default()
|
||||
*cumulative.collect_one(Height::from(start - 1)).data()?
|
||||
};
|
||||
|
||||
let block_count = count_at_end.saturating_sub(count_at_start);
|
||||
@@ -90,12 +81,10 @@ impl Query {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by block count descending
|
||||
pool_data.sort_by_key(|p| Reverse(p.1));
|
||||
|
||||
let total_blocks: u64 = pool_data.iter().map(|(_, count)| count).sum();
|
||||
|
||||
// Build stats with ranks
|
||||
let pool_stats: Vec<PoolStats> = pool_data
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -109,31 +98,11 @@ impl Query {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let hashrate_at = |height: Height| -> u128 {
|
||||
let day = computer
|
||||
.indexes
|
||||
.height
|
||||
.day1
|
||||
.collect_one(height)
|
||||
.unwrap_or_default();
|
||||
computer
|
||||
.mining
|
||||
.hashrate
|
||||
.rate
|
||||
.base
|
||||
.day1
|
||||
.collect_one(day)
|
||||
.flatten()
|
||||
.map(|v| *v as u128)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let lookback = &computer.blocks.lookback;
|
||||
let last_estimated_hashrate = hashrate_at(current_height);
|
||||
let last_estimated_hashrate = self.hashrate_at(current_height)?;
|
||||
let last_estimated_hashrate3d =
|
||||
hashrate_at(lookback._3d.collect_one(current_height).unwrap_or_default());
|
||||
self.hashrate_at(lookback._3d.collect_one(current_height).data()?)?;
|
||||
let last_estimated_hashrate1w =
|
||||
hashrate_at(lookback._1w.collect_one(current_height).unwrap_or_default());
|
||||
self.hashrate_at(lookback._1w.collect_one(current_height).data()?)?;
|
||||
|
||||
Ok(PoolsSummary {
|
||||
pools: pool_stats,
|
||||
@@ -144,10 +113,18 @@ impl Query {
|
||||
})
|
||||
}
|
||||
|
||||
/// All supported pools as `PoolInfo`. Static list, no indexer reads, can't fail.
|
||||
pub fn all_pools(&self) -> Vec<PoolInfo> {
|
||||
pools().iter().map(PoolInfo::from).collect()
|
||||
}
|
||||
|
||||
/// Per-pool detail: lifetime block count plus 24h and 1w windowed counts,
|
||||
/// each as a share of network blocks in the same window. The 24h share is
|
||||
/// also used to weight the current 1-day network hashrate into a per-pool
|
||||
/// `estimated_hashrate`. `total_reward` is `Some` only for major pools
|
||||
/// (minor pools don't track per-pool reward sums); under stamp lag on a
|
||||
/// major pool's reward vec this errors rather than silently reporting
|
||||
/// `None`.
|
||||
pub fn pool_detail(&self, slug: PoolSlug) -> Result<PoolDetail> {
|
||||
let computer = self.computer();
|
||||
let current_height = self.height();
|
||||
@@ -156,7 +133,6 @@ impl Query {
|
||||
let pools_list = pools();
|
||||
let pool = pools_list.get(slug);
|
||||
|
||||
// Get cumulative blocks for this pool (works for both major and minor)
|
||||
let cumulative = computer
|
||||
.pools
|
||||
.major
|
||||
@@ -169,42 +145,31 @@ impl Query {
|
||||
.get(&slug)
|
||||
.map(|v| &v.blocks_mined.cumulative.height)
|
||||
})
|
||||
.ok_or_else(|| Error::NotFound("Pool data not found".into()))?;
|
||||
.ok_or_else(|| {
|
||||
Error::Internal(
|
||||
"pool slug present in static list but missing from major/minor maps",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get total blocks (all time)
|
||||
let total_all: u64 = *cumulative.collect_one(current_height).unwrap_or_default();
|
||||
let total_all: u64 = *cumulative.collect_one(current_height).data()?;
|
||||
|
||||
// Use timestamp-based lookback for accurate time boundaries
|
||||
let lookback = &computer.blocks.lookback;
|
||||
let start_24h = lookback
|
||||
._24h
|
||||
.collect_one(current_height)
|
||||
.unwrap_or_default()
|
||||
.to_usize();
|
||||
let start_24h = lookback._24h.collect_one(current_height).data()?.to_usize();
|
||||
let count_before_24h: u64 = if start_24h == 0 {
|
||||
0
|
||||
} else {
|
||||
*cumulative
|
||||
.collect_one(Height::from(start_24h - 1))
|
||||
.unwrap_or_default()
|
||||
*cumulative.collect_one(Height::from(start_24h - 1)).data()?
|
||||
};
|
||||
let total_24h = total_all.saturating_sub(count_before_24h);
|
||||
|
||||
let start_1w = lookback
|
||||
._1w
|
||||
.collect_one(current_height)
|
||||
.unwrap_or_default()
|
||||
.to_usize();
|
||||
let start_1w = lookback._1w.collect_one(current_height).data()?.to_usize();
|
||||
let count_before_1w: u64 = if start_1w == 0 {
|
||||
0
|
||||
} else {
|
||||
*cumulative
|
||||
.collect_one(Height::from(start_1w - 1))
|
||||
.unwrap_or_default()
|
||||
*cumulative.collect_one(Height::from(start_1w - 1)).data()?
|
||||
};
|
||||
let total_1w = total_all.saturating_sub(count_before_1w);
|
||||
|
||||
// Calculate total network blocks for share calculation
|
||||
let network_blocks_all = (end + 1) as u64;
|
||||
let network_blocks_24h = (end - start_24h + 1) as u64;
|
||||
let network_blocks_1w = (end - start_1w + 1) as u64;
|
||||
@@ -225,6 +190,15 @@ impl Query {
|
||||
0.0
|
||||
};
|
||||
|
||||
let network_hr = self.hashrate_at(current_height)?;
|
||||
let estimated_hashrate = (share_24h * network_hr as f64) as u128;
|
||||
|
||||
let total_reward = if let Some(major) = computer.pools.major.get(&slug) {
|
||||
Some(major.rewards.cumulative.sats.height.collect_one(current_height).data()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(PoolDetail {
|
||||
pool: PoolDetailInfo::from(pool),
|
||||
block_count: PoolBlockCounts {
|
||||
@@ -237,45 +211,28 @@ impl Query {
|
||||
day: share_24h,
|
||||
week: share_1w,
|
||||
},
|
||||
estimated_hashrate: {
|
||||
let day = computer
|
||||
.indexes
|
||||
.height
|
||||
.day1
|
||||
.collect_one(current_height)
|
||||
.unwrap_or_default();
|
||||
let network_hr = computer
|
||||
.mining
|
||||
.hashrate
|
||||
.rate
|
||||
.base
|
||||
.day1
|
||||
.collect_one(day)
|
||||
.flatten()
|
||||
.map(|v| *v as u128)
|
||||
.unwrap_or(0);
|
||||
(share_24h * network_hr as f64) as u128
|
||||
},
|
||||
estimated_hashrate,
|
||||
reported_hashrate: None,
|
||||
total_reward: computer
|
||||
.pools
|
||||
.major
|
||||
.get(&slug)
|
||||
.and_then(|v| v.rewards.cumulative.sats.height.collect_one(current_height)),
|
||||
total_reward,
|
||||
})
|
||||
}
|
||||
|
||||
/// Page of blocks mined by `slug`, in descending height order, capped at
|
||||
/// `limit`. `before_height` is the inclusive upper bound to paginate from
|
||||
/// (defaults to tip). Returns an empty `Vec` if the pool has no recorded
|
||||
/// blocks. Heights come from a sorted-ascending per-pool index, so the
|
||||
/// page is computed via `partition_point` then reversed; consecutive
|
||||
/// runs are merged into a single bulk read of `blocks_v1_range`.
|
||||
pub fn pool_blocks(
|
||||
&self,
|
||||
slug: PoolSlug,
|
||||
start_height: Option<Height>,
|
||||
before_height: Option<Height>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<BlockInfoV1>> {
|
||||
let computer = self.computer();
|
||||
let max_height = self.height().to_usize();
|
||||
let start = start_height.map(|h| h.to_usize()).unwrap_or(max_height);
|
||||
let end = start.min(computer.pools.pool.len().saturating_sub(1));
|
||||
|
||||
const POOL_BLOCKS_LIMIT: usize = 100;
|
||||
let tip = self.height().to_usize();
|
||||
let upper = before_height.map(|h| h.to_usize()).unwrap_or(tip);
|
||||
let end = upper.min(computer.pools.pool.len().saturating_sub(1));
|
||||
|
||||
let heights: Vec<usize> = computer
|
||||
.pools
|
||||
@@ -284,7 +241,7 @@ impl Query {
|
||||
.get(&slug)
|
||||
.map(|pool_heights| {
|
||||
let pos = pool_heights.partition_point(|h| h.to_usize() <= end);
|
||||
let start = pos.saturating_sub(POOL_BLOCKS_LIMIT);
|
||||
let start = pos.saturating_sub(limit);
|
||||
pool_heights[start..pos]
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -293,7 +250,7 @@ impl Query {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Group consecutive descending heights into ranges for batch reads
|
||||
// Group consecutive descending heights into ranges for batch reads.
|
||||
let mut blocks = Vec::with_capacity(heights.len());
|
||||
let mut i = 0;
|
||||
while i < heights.len() {
|
||||
@@ -301,50 +258,42 @@ impl Query {
|
||||
while i + 1 < heights.len() && heights[i + 1] + 1 == heights[i] {
|
||||
i += 1;
|
||||
}
|
||||
if let Ok(mut v) = self.blocks_v1_range(heights[i], hi + 1) {
|
||||
blocks.append(&mut v);
|
||||
}
|
||||
let mut v = self.blocks_v1_range(heights[i], hi + 1)?;
|
||||
blocks.append(&mut v);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// Weekly-sampled hashrate series for a single pool over the full chain.
|
||||
/// Each point's hashrate is `network_hashrate(day) * pool_share_over_7d`,
|
||||
/// where the share is the pool's last-7-days block count divided by the
|
||||
/// network's last-7-days block count.
|
||||
pub fn pool_hashrate(&self, slug: PoolSlug) -> Result<Vec<PoolHashrateEntry>> {
|
||||
let pool_name = pools().get(slug).name.to_string();
|
||||
let pool_name = pools().get(slug).name;
|
||||
let shared = self.hashrate_shared_data(0)?;
|
||||
let pool_cum = self.pool_daily_cumulative(slug, shared.start_day, shared.end_day)?;
|
||||
Ok(Self::compute_hashrate_entries(
|
||||
&shared,
|
||||
&pool_cum,
|
||||
&pool_name,
|
||||
pool_name,
|
||||
SAMPLE_WEEKLY,
|
||||
))
|
||||
}
|
||||
|
||||
/// Multi-pool weekly-sampled hashrate series over `time_period`. Walks
|
||||
/// the full chain when `time_period` is `None` or `Some(TimePeriod::All)`.
|
||||
/// For each known pool, emits one entry per weekly sample where the
|
||||
/// hashrate is `network_hashrate(day) * pool_share_over_7d`, tagged with
|
||||
/// `pool_name`. Entries from all pools are concatenated; the chart layer
|
||||
/// groups by pool name.
|
||||
pub fn pools_hashrate(
|
||||
&self,
|
||||
time_period: Option<TimePeriod>,
|
||||
) -> Result<Vec<PoolHashrateEntry>> {
|
||||
let start_height = match time_period {
|
||||
Some(tp) => {
|
||||
let lookback = &self.computer().blocks.lookback;
|
||||
let current_height = self.height();
|
||||
match tp {
|
||||
TimePeriod::Day => lookback._24h.collect_one(current_height),
|
||||
TimePeriod::ThreeDays => lookback._3d.collect_one(current_height),
|
||||
TimePeriod::Week => lookback._1w.collect_one(current_height),
|
||||
TimePeriod::Month => lookback._1m.collect_one(current_height),
|
||||
TimePeriod::ThreeMonths => lookback._3m.collect_one(current_height),
|
||||
TimePeriod::SixMonths => lookback._6m.collect_one(current_height),
|
||||
TimePeriod::Year => lookback._1y.collect_one(current_height),
|
||||
TimePeriod::TwoYears => lookback._2y.collect_one(current_height),
|
||||
TimePeriod::ThreeYears => lookback._3y.collect_one(current_height),
|
||||
TimePeriod::All => None,
|
||||
}
|
||||
.unwrap_or_default()
|
||||
.to_usize()
|
||||
}
|
||||
Some(tp) => self.start_height(tp)?.to_usize(),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
@@ -353,11 +302,8 @@ impl Query {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for pool in pools_list.iter() {
|
||||
let Ok(pool_cum) =
|
||||
self.pool_daily_cumulative(pool.slug, shared.start_day, shared.end_day)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let pool_cum =
|
||||
self.pool_daily_cumulative(pool.slug, shared.start_day, shared.end_day)?;
|
||||
entries.extend(Self::compute_hashrate_entries(
|
||||
&shared,
|
||||
&pool_cum,
|
||||
@@ -369,7 +315,11 @@ impl Query {
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Shared data needed for hashrate computation (read once, reuse across pools).
|
||||
/// Pre-loads the network-wide day1 series (network hashrate, per-day
|
||||
/// first heights) over `[start_day, end_day)`, where `start_day` is the
|
||||
/// day index of `start_height` and `end_day` is the day index of the
|
||||
/// current tip plus one (exclusive). Reused across pools so the network
|
||||
/// series is read only once per request.
|
||||
fn hashrate_shared_data(&self, start_height: usize) -> Result<HashrateSharedData> {
|
||||
let computer = self.computer();
|
||||
let current_height = self.height();
|
||||
@@ -378,14 +328,14 @@ impl Query {
|
||||
.height
|
||||
.day1
|
||||
.collect_one_at(start_height)
|
||||
.unwrap_or_default()
|
||||
.data()?
|
||||
.to_usize();
|
||||
let end_day = computer
|
||||
.indexes
|
||||
.height
|
||||
.day1
|
||||
.collect_one(current_height)
|
||||
.unwrap_or_default()
|
||||
.data()?
|
||||
.to_usize()
|
||||
+ 1;
|
||||
let daily_hashrate = computer
|
||||
@@ -409,7 +359,13 @@ impl Query {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read daily cumulative blocks mined for a pool.
|
||||
/// Reads the pool's daily-cumulative blocks-mined vec over the half-open
|
||||
/// day range `[start_day, end_day)`. Major pools nest under `.base`
|
||||
/// (additional derived computations), minor pools don't, so the slug is
|
||||
/// looked up in both maps. Errors `Internal` if the slug is in neither
|
||||
/// map: this can only fire on a static-pool-list / indexer-map mismatch
|
||||
/// since both callers guarantee the slug is in the static list, so the
|
||||
/// route layer never reaches a user-driven not-found path here.
|
||||
fn pool_daily_cumulative(
|
||||
&self,
|
||||
slug: PoolSlug,
|
||||
@@ -436,18 +392,38 @@ impl Query {
|
||||
.collect_range_at(start_day, end_day)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| Error::NotFound("Pool not found".into()))
|
||||
.ok_or_else(|| {
|
||||
Error::Internal(
|
||||
"pool slug present in static list but missing from major/minor maps",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute hashrate entries from daily cumulative blocks + shared data.
|
||||
/// Uses 7-day windowed share: pool_blocks_in_week / total_blocks_in_week.
|
||||
/// Per-pool hashrate-share entries from pre-loaded daily cumulative blocks
|
||||
/// plus the shared network series. Walks samples from `LOOKBACK_DAYS`
|
||||
/// onward in `sample_days` strides; for each sample emits one entry with
|
||||
/// pool_blocks = pool_cum[i] - pool_cum[i - LOOKBACK_DAYS]
|
||||
/// total_blocks = first_heights[i] - first_heights[i - LOOKBACK_DAYS]
|
||||
/// share = pool_blocks / total_blocks
|
||||
/// avg_hashrate = daily_hashrate[i] * share
|
||||
/// Skips samples where either cumulative value is `None`, where
|
||||
/// `pool_blocks == 0`, where `total_blocks == 0`, or where the network
|
||||
/// hashrate for that day is unavailable. The iteration is bounded by
|
||||
/// the shortest of `pool_cum`, `shared.first_heights`, and
|
||||
/// `shared.daily_hashrate` so per-vec stamp-lag truncation from
|
||||
/// `collect_range_at` degrades the chart's tail rather than panicking
|
||||
/// on out-of-bounds indexing. `LOOKBACK_DAYS` (rolling window) and
|
||||
/// `sample_days` (point spacing) are independent.
|
||||
fn compute_hashrate_entries(
|
||||
shared: &HashrateSharedData,
|
||||
pool_cum: &[Option<StoredU64>],
|
||||
pool_name: &str,
|
||||
pool_name: &'static str,
|
||||
sample_days: usize,
|
||||
) -> Vec<PoolHashrateEntry> {
|
||||
let total = pool_cum.len();
|
||||
let total = pool_cum
|
||||
.len()
|
||||
.min(shared.first_heights.len())
|
||||
.min(shared.daily_hashrate.len());
|
||||
if total <= LOOKBACK_DAYS {
|
||||
return vec![];
|
||||
}
|
||||
@@ -472,7 +448,7 @@ impl Query {
|
||||
timestamp: day.to_timestamp(),
|
||||
avg_hashrate: (network_hr * share) as u128,
|
||||
share,
|
||||
pool_name: pool_name.to_string(),
|
||||
pool_name: Cow::Borrowed(pool_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
use brk_error::Result;
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::{Height, RewardStats, Sats};
|
||||
use vecdb::{ReadableVec, VecIndex};
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
/// Sums coinbase rewards, fees, and tx counts over the last `block_count`
|
||||
/// blocks ending at the current tip. Errors `OutOfRange` if `block_count`
|
||||
/// is zero, and `Internal` if any of the three per-block vecs (coinbase,
|
||||
/// fees, tx count) is stamped short of the tip - silent truncation by
|
||||
/// `fold_range_at` would otherwise produce a quietly low total.
|
||||
pub fn reward_stats(&self, block_count: usize) -> Result<RewardStats> {
|
||||
if block_count == 0 {
|
||||
return Err(Error::OutOfRange("block_count must be >= 1".into()));
|
||||
}
|
||||
|
||||
let computer = self.computer();
|
||||
let current_height = self.height();
|
||||
|
||||
@@ -19,6 +28,12 @@ impl Query {
|
||||
let start = start_block.to_usize();
|
||||
let end = end_block.to_usize() + 1;
|
||||
|
||||
if coinbase_vec.len() < end || fee_vec.len() < end || tx_count_vec.len() < end {
|
||||
return Err(Error::Internal(
|
||||
"reward stats vecs lag the tip; retry once indexing catches up",
|
||||
));
|
||||
}
|
||||
|
||||
let total_reward = coinbase_vec.fold_range_at(start, end, Sats::ZERO, |acc, v| acc + v);
|
||||
let total_fee = fee_vec.fold_range_at(start, end, Sats::ZERO, |acc, v| acc + v);
|
||||
let total_tx = tx_count_vec.fold_range_at(start, end, 0u64, |acc, v| acc + *v);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod addr;
|
||||
mod block;
|
||||
mod cpfp;
|
||||
mod mempool;
|
||||
mod mining;
|
||||
mod price;
|
||||
@@ -7,5 +8,4 @@ mod series;
|
||||
mod tx;
|
||||
mod urpd;
|
||||
|
||||
pub use block::BLOCK_TXS_PAGE_SIZE;
|
||||
pub use series::ResolvedQuery;
|
||||
|
||||
@@ -39,18 +39,19 @@ impl Query {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Error::SeriesUnsupportedIndex {
|
||||
series: series.to_string(),
|
||||
series: brk_error::truncate_series_name(series.to_string()),
|
||||
supported,
|
||||
};
|
||||
}
|
||||
|
||||
let matches = self
|
||||
.vecs()
|
||||
.matches(series, Limit::DEFAULT)
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
Error::SeriesNotFound(brk_error::SeriesNotFound::new(series.to_string(), matches))
|
||||
let matches = self.vecs().matches(series, Limit::DEFAULT);
|
||||
let total_matches = matches.len();
|
||||
let suggestions = matches.into_iter().take(3).collect();
|
||||
Error::SeriesNotFound(brk_error::SeriesNotFound::new(
|
||||
series.to_string(),
|
||||
suggestions,
|
||||
total_matches,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn columns_to_csv(
|
||||
@@ -345,7 +346,7 @@ impl Query {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn indexes(&self) -> &[IndexInfo] {
|
||||
pub fn indexes(&self) -> &'static [IndexInfo] {
|
||||
&self.vecs().indexes
|
||||
}
|
||||
|
||||
@@ -353,7 +354,7 @@ impl Query {
|
||||
self.vecs().series(pagination)
|
||||
}
|
||||
|
||||
pub fn series_catalog(&self) -> &TreeNode {
|
||||
pub fn series_catalog(&self) -> &'static TreeNode {
|
||||
self.vecs().catalog()
|
||||
}
|
||||
|
||||
|
||||
@@ -206,10 +206,10 @@ impl Query {
|
||||
let (block_hash, block_time) = if let Some((h, ref bh, bt)) = cached_status
|
||||
&& h == spending_height
|
||||
{
|
||||
(bh.clone(), bt)
|
||||
(*bh, bt)
|
||||
} else {
|
||||
let (bh, bt) = self.block_hash_and_time(spending_height)?;
|
||||
cached_status = Some((spending_height, bh.clone(), bt));
|
||||
cached_status = Some((spending_height, bh, bt));
|
||||
(bh, bt)
|
||||
};
|
||||
|
||||
@@ -315,10 +315,11 @@ impl Query {
|
||||
let txids = self.block_txids_by_height(height)?;
|
||||
|
||||
let target: bitcoin::Txid = txid.into();
|
||||
let btxids: Vec<bitcoin::Txid> = txids.iter().map(bitcoin::Txid::from).collect();
|
||||
let mb = bitcoin::MerkleBlock::from_header_txids_with_predicate(&header, &btxids, |t| {
|
||||
*t == target
|
||||
});
|
||||
let mb = bitcoin::MerkleBlock::from_header_txids_with_predicate(
|
||||
&header,
|
||||
Txid::as_bitcoin_slice(&txids),
|
||||
|t| *t == target,
|
||||
);
|
||||
Ok(bitcoin::consensus::encode::serialize_hex(&mb))
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ mod r#impl;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use r#async::*;
|
||||
pub use r#impl::{BLOCK_TXS_PAGE_SIZE, ResolvedQuery};
|
||||
pub use r#impl::ResolvedQuery;
|
||||
pub use vecs::Vecs;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -59,12 +59,12 @@ impl Query {
|
||||
|
||||
/// Current indexed height
|
||||
pub fn indexed_height(&self) -> Height {
|
||||
Height::from(self.indexer().vecs.blocks.blockhash.inner.stamp())
|
||||
self.indexer().indexed_height()
|
||||
}
|
||||
|
||||
/// Current computed height (series)
|
||||
pub fn computed_height(&self) -> Height {
|
||||
Height::from(self.computer().distribution.supply_state.stamp())
|
||||
self.computer().computed_height()
|
||||
}
|
||||
|
||||
/// Minimum of indexed and computed heights
|
||||
|
||||
Reference in New Issue
Block a user