crates: snapshot

This commit is contained in:
nym21
2026-05-12 22:33:09 +02:00
parent 8fc2e71492
commit 5cc3fbfa6e
25 changed files with 450 additions and 362 deletions

View File

@@ -9009,7 +9009,7 @@ impl BrkClient {
/// Health check /// Health check
/// ///
/// Returns the health status of the API server, including uptime information. /// Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.
/// ///
/// Endpoint: `GET /health` /// Endpoint: `GET /health`
pub fn get_health(&self) -> Result<Health> { pub fn get_health(&self) -> Result<Health> {
@@ -9294,7 +9294,7 @@ impl BrkClient {
/// Address transactions /// Address transactions
/// ///
/// Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. /// Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`.
/// ///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
/// ///

View File

@@ -1,8 +1,8 @@
use std::ops::Range; use std::ops::Range;
use brk_error::{Error, Result}; use brk_error::Result;
use brk_indexer::{Indexer, Lengths}; use brk_indexer::{Indexer, Lengths};
use brk_oracle::{Config, NUM_BINS, Oracle, START_HEIGHT, bin_to_cents, cents_to_bin}; use brk_oracle::{Config, Histogram, Oracle, START_HEIGHT, bin_to_cents, cents_to_bin};
use brk_types::{Cents, OutputType, Sats, TxIndex, TxOutIndex}; use brk_types::{Cents, OutputType, Sats, TxIndex, TxOutIndex};
use tracing::info; use tracing::info;
use vecdb::{AnyStoredVec, AnyVec, Exit, ReadableVec, StorageMode, VecIndex, WritableVec}; use vecdb::{AnyStoredVec, AnyVec, Exit, ReadableVec, StorageMode, VecIndex, WritableVec};
@@ -112,7 +112,7 @@ impl Vecs {
let seed_bin = cents_to_bin(prev_cents.inner() as f64); let seed_bin = cents_to_bin(prev_cents.inner() as f64);
let warmup = config.window_size.min(committed - START_HEIGHT); let warmup = config.window_size.min(committed - START_HEIGHT);
let mut oracle = Oracle::from_checkpoint(seed_bin, config, |o| { let mut oracle = Oracle::from_checkpoint(seed_bin, config, |o| {
Self::feed_blocks(o, indexer, (committed - warmup)..committed); Self::feed_blocks(o, indexer, (committed - warmup)..committed, None);
}); });
let num_new = total_heights - committed; let num_new = total_heights - committed;
@@ -121,7 +121,8 @@ impl Vecs {
committed, total_heights committed, total_heights
); );
let ref_bins = Self::feed_blocks(&mut oracle, indexer, committed..total_heights); let ref_bins =
Self::feed_blocks(&mut oracle, indexer, committed..total_heights, None);
for (i, ref_bin) in ref_bins.into_iter().enumerate() { for (i, ref_bin) in ref_bins.into_iter().enumerate() {
self.spot self.spot
@@ -150,32 +151,14 @@ impl Vecs {
} }
/// Feed a range of blocks from the indexer into an Oracle (skipping coinbase), /// Feed a range of blocks from the indexer into an Oracle (skipping coinbase),
/// returning per-block ref_bin values. Uncapped: derives boundaries from /// returning per-block ref_bin values.
/// raw indexer vec lengths. Use during compute, when the indexer is ///
/// quiescent and `safe_lengths` is still pinned at the pre-pass value. /// Pass `cap = None` from compute paths, when the indexer is quiescent and
fn feed_blocks<M: StorageMode>( /// raw vec lengths are authoritative. Pass `cap = Some(&safe_lengths)` from
/// reader paths so concurrent writer pushes past the cap are invisible.
pub fn feed_blocks<IM: StorageMode>(
oracle: &mut Oracle, oracle: &mut Oracle,
indexer: &Indexer<M>, indexer: &Indexer<IM>,
range: Range<usize>,
) -> Vec<f64> {
Self::feed_blocks_inner(oracle, indexer, range, None)
}
/// Capped variant: derives boundaries from `cap` instead of raw vec
/// lengths, so concurrent writer pushes past `cap` are invisible.
/// Reader paths (live_oracle) use this with the current `safe_lengths`.
fn feed_blocks_capped<M: StorageMode>(
oracle: &mut Oracle,
indexer: &Indexer<M>,
range: Range<usize>,
cap: &Lengths,
) -> Vec<f64> {
Self::feed_blocks_inner(oracle, indexer, range, Some(cap))
}
fn feed_blocks_inner<M: StorageMode>(
oracle: &mut Oracle,
indexer: &Indexer<M>,
range: Range<usize>, range: Range<usize>,
cap: Option<&Lengths>, cap: Option<&Lengths>,
) -> Vec<f64> { ) -> Vec<f64> {
@@ -208,24 +191,24 @@ impl Vecs {
let mut ref_bins = Vec::with_capacity(range.len()); let mut ref_bins = Vec::with_capacity(range.len());
// Cursor avoids per-block PcoVec page decompression for // Cursor avoids per-block PcoVec page decompression for the
// the tx-indexed first_txout_index lookup. The accessed // tx-indexed first_txout_index lookup. Accessed tx_index values
// tx_index values (first_tx_index + 1) are strictly increasing // (first_tx_index + 1) are strictly increasing across blocks,
// across blocks, so the cursor only advances forward. // so the cursor only advances forward.
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor(); let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
// Reusable buffers avoid per-block allocation // Reusable buffers: avoid per-block allocation
let mut values: Vec<Sats> = Vec::new(); let mut values: Vec<Sats> = Vec::new();
let mut output_types: Vec<OutputType> = Vec::new(); let mut output_types: Vec<OutputType> = Vec::new();
for (idx, _h) in range.enumerate() { for idx in 0..range.len() {
let first_tx_index = first_tx_indexes[idx]; let first_tx_index = first_tx_indexes[idx];
let next_first_tx_index = first_tx_indexes let next_first_tx_index = first_tx_indexes
.get(idx + 1) .get(idx + 1)
.copied() .copied()
.unwrap_or(TxIndex::from(total_txs)); .unwrap_or(TxIndex::from(total_txs));
let next_out_first = out_firsts let out_end = out_firsts
.get(idx + 1) .get(idx + 1)
.copied() .copied()
.unwrap_or(TxOutIndex::from(total_outputs)) .unwrap_or(TxOutIndex::from(total_outputs))
@@ -235,9 +218,8 @@ impl Vecs {
txout_cursor.advance(target - txout_cursor.position()); txout_cursor.advance(target - txout_cursor.position());
txout_cursor.next().unwrap().to_usize() txout_cursor.next().unwrap().to_usize()
} else { } else {
next_out_first out_end
}; };
let out_end = next_out_first;
indexer indexer
.vecs .vecs
@@ -250,10 +232,10 @@ impl Vecs {
&mut output_types, &mut output_types,
); );
let mut hist = [0u32; NUM_BINS]; let mut hist = Histogram::zeros();
for i in 0..values.len() { for (sats, output_type) in values.iter().zip(&output_types) {
if let Some(bin) = oracle.output_to_bin(values[i], output_types[i]) { if let Some(bin) = oracle.output_to_bin(*sats, *output_type) {
hist[bin] += 1; hist.increment(bin);
} }
} }
@@ -263,42 +245,3 @@ impl Vecs {
ref_bins ref_bins
} }
} }
impl<M: StorageMode> Vecs<M> {
/// Returns an Oracle seeded from the last committed price, with the last
/// window_size blocks already processed. Ready for additional blocks (e.g. mempool).
pub fn live_oracle<IM: StorageMode>(&self, indexer: &Indexer<IM>) -> Result<Oracle> {
let config = Config::default();
let safe_lengths = indexer.safe_lengths();
let height = safe_lengths.height.to_usize();
let last_idx = self
.spot
.cents
.height
.len()
.checked_sub(1)
.ok_or(Error::NotFound(
"oracle prices not yet computed".to_string(),
))?;
let last_cents = self
.spot
.cents
.height
.collect_one_at(last_idx)
.ok_or(Error::NotFound(
"oracle prices not yet computed".to_string(),
))?;
let seed_bin = cents_to_bin(last_cents.inner() as f64);
let window_size = config.window_size;
let oracle = Oracle::from_checkpoint(seed_bin, config, |o| {
Vecs::feed_blocks_capped(
o,
indexer,
height.saturating_sub(window_size)..height,
&safe_lengths,
);
});
Ok(oracle)
}
}

View File

@@ -11,6 +11,7 @@ exclude = ["examples/"]
[dependencies] [dependencies]
bitcoin = { workspace = true } bitcoin = { workspace = true }
brk_error = { workspace = true } brk_error = { workspace = true }
brk_oracle = { workspace = true }
brk_rpc = { workspace = true } brk_rpc = { workspace = true }
brk_types = { workspace = true } brk_types = { workspace = true }
derive_more = { workspace = true } derive_more = { workspace = true }

View File

@@ -32,11 +32,12 @@ use std::{
}; };
use brk_error::Result; use brk_error::Result;
use brk_oracle::Histogram;
use brk_rpc::Client; use brk_rpc::Client;
use brk_types::{ use brk_types::{
AddrBytes, AddrMempoolStats, BlockTemplate, BlockTemplateDiff, FeeRate, MempoolBlock, AddrBytes, AddrMempoolStats, BlockTemplate, BlockTemplateDiff, FeeRate, MempoolBlock,
MempoolInfo, MempoolRecentTx, NextBlockHash, OutpointPrefix, OutputType, Sats, Timestamp, MempoolInfo, MempoolRecentTx, NextBlockHash, OutpointPrefix, Timestamp, Transaction, TxOut,
Transaction, TxOut, Txid, TxidPrefix, Vin, Vout, Txid, TxidPrefix, Vin, Vout,
}; };
use parking_lot::{RwLock, RwLockReadGuard}; use parking_lot::{RwLock, RwLockReadGuard};
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
@@ -236,19 +237,20 @@ impl Mempool {
.collect() .collect()
} }
/// Apply `f` to an iterator over `(value, output_type)` for every output /// Histogram of pre-bucketed oracle bins across all live mempool tx
/// of every live mempool tx. The lock is held for the duration of the call. /// outputs. Bins are computed once on insert (see `OutputBins`), so this
pub fn process_live_outputs<R>( /// hot path is `O(eligible outputs)` of integer increments. Used by
&self, /// `live_price` to blend the mempool into the committed oracle without
f: impl FnOnce(&mut dyn Iterator<Item = (Sats, OutputType)>) -> R, /// re-parsing scripts per request.
) -> R { pub fn live_histogram(&self) -> Histogram {
let mut hist = Histogram::zeros();
let state = self.read(); let state = self.read();
let mut iter = state for (_, record) in state.txs.records() {
.txs for bin in record.output_bins.iter() {
.values() hist.increment(bin as usize);
.flat_map(|tx| &tx.output) }
.map(|txout| (txout.value, txout.type_())); }
f(&mut iter) hist
} }
/// Effective fee rate for a live tx: snapshot's linearized chunk /// Effective fee rate for a live tx: snapshot's linearized chunk

View File

@@ -5,10 +5,12 @@
pub(crate) mod addr_tracker; pub(crate) mod addr_tracker;
pub(crate) mod outpoint_spends; pub(crate) mod outpoint_spends;
pub(crate) mod output_bins;
pub(crate) mod tx_graveyard; pub(crate) mod tx_graveyard;
pub(crate) mod tx_store; pub(crate) mod tx_store;
pub(crate) use addr_tracker::AddrTracker; pub(crate) use addr_tracker::AddrTracker;
pub(crate) use outpoint_spends::OutpointSpends; pub(crate) use outpoint_spends::OutpointSpends;
pub(crate) use output_bins::OutputBins;
pub(crate) use tx_graveyard::{TxGraveyard, TxTombstone}; pub(crate) use tx_graveyard::{TxGraveyard, TxTombstone};
pub(crate) use tx_store::TxStore; pub(crate) use tx_store::TxStore;

View File

@@ -0,0 +1,23 @@
use brk_oracle::default_eligible_bin;
use brk_types::Transaction;
use smallvec::SmallVec;
/// Pre-bucketed oracle bins for a tx's eligible outputs. Computed once on
/// insert so `Mempool::live_histogram` can bin all live outputs without
/// re-parsing scripts or recomputing eligibility per request.
pub struct OutputBins(SmallVec<[u16; 4]>);
impl OutputBins {
pub fn from_tx(tx: &Transaction) -> Self {
Self(
tx.output
.iter()
.filter_map(|o| default_eligible_bin(o.value, o.type_()))
.collect(),
)
}
pub fn iter(&self) -> impl Iterator<Item = u16> + '_ {
self.0.iter().copied()
}
}

View File

@@ -1,15 +1,28 @@
use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin}; use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin};
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
use crate::TxEntry; use crate::{TxEntry, stores::OutputBins};
const RECENT_CAP: usize = 10; const RECENT_CAP: usize = 10;
/// Per-tx record: live tx body and its mempool entry, kept under one /// Per-tx record: live tx body, its mempool entry, and the pre-bucketed
/// key so a single map probe returns both. /// oracle bins for its outputs. Kept under one key so a single map probe
/// returns everything readers need.
pub struct TxRecord { pub struct TxRecord {
pub tx: Transaction, pub tx: Transaction,
pub entry: TxEntry, pub entry: TxEntry,
pub output_bins: OutputBins,
}
impl TxRecord {
pub fn new(tx: Transaction, entry: TxEntry) -> Self {
let output_bins = OutputBins::from_tx(&tx);
Self {
tx,
entry,
output_bins,
}
}
} }
/// Live-pool index keyed by `TxidPrefix`. The full `Txid` lives in /// Live-pool index keyed by `TxidPrefix`. The full `Txid` lives in
@@ -63,10 +76,6 @@ impl TxStore {
self.records.values().map(|r| &r.entry.txid) self.records.values().map(|r| &r.entry.txid)
} }
pub fn values(&self) -> impl Iterator<Item = &Transaction> {
self.records.values().map(|r| &r.tx)
}
pub fn insert(&mut self, tx: Transaction, entry: TxEntry) { pub fn insert(&mut self, tx: Transaction, entry: TxEntry) {
let prefix = entry.txid_prefix(); let prefix = entry.txid_prefix();
debug_assert!( debug_assert!(
@@ -77,7 +86,7 @@ impl TxStore {
if tx.input.iter().any(|i| i.prevout.is_none()) { if tx.input.iter().any(|i| i.prevout.is_none()) {
self.unresolved.insert(prefix); self.unresolved.insert(prefix);
} }
self.records.insert(prefix, TxRecord { tx, entry }); self.records.insert(prefix, TxRecord::new(tx, entry));
} }
fn sample_recent(&mut self, txid: &Txid, tx: &Transaction) { fn sample_recent(&mut self, txid: &Txid, tx: &Transaction) {

View File

@@ -6,7 +6,7 @@ use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin}; use brk_oracle::{Config, Histogram, NUM_BINS, Oracle, PRICES, cents_to_bin, sats_to_bin};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex}; use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex};
@@ -159,7 +159,7 @@ fn main() {
let ref_config = Config::default(); let ref_config = Config::default();
let earliest_start = *start_heights.iter().min().unwrap(); let earliest_start = *start_heights.iter().min().unwrap();
for h in START_HEIGHT..total_heights { for h in earliest_start..total_heights {
let ft = first_tx_index[h]; let ft = first_tx_index[h];
let next_ft = first_tx_index let next_ft = first_tx_index
.get(h + 1) .get(h + 1)
@@ -187,10 +187,6 @@ fn main() {
.unwrap_or(TxOutIndex::from(total_outputs)) .unwrap_or(TxOutIndex::from(total_outputs))
.to_usize(); .to_usize();
if h < earliest_start {
continue;
}
let values: Vec<Sats> = indexer let values: Vec<Sats> = indexer
.vecs .vecs
.outputs .outputs
@@ -203,8 +199,8 @@ fn main() {
.collect_range_at(out_start, out_end); .collect_range_at(out_start, out_end);
// Build full histogram and per-digit histograms. // Build full histogram and per-digit histograms.
let mut full_hist = [0u32; NUM_BINS]; let mut full_hist = Histogram::zeros();
let mut digit_hist = [[0u32; NUM_BINS]; 9]; let mut digit_hist: [Histogram; 9] = std::array::from_fn(|_| Histogram::zeros());
for (sats, output_type) in values.into_iter().zip(output_types) { for (sats, output_type) in values.into_iter().zip(output_types) {
if ref_config.excluded_output_types.contains(&output_type) { if ref_config.excluded_output_types.contains(&output_type) {
@@ -214,11 +210,11 @@ fn main() {
continue; continue;
} }
if let Some(bin) = sats_to_bin(sats) { if let Some(bin) = sats_to_bin(sats) {
full_hist[bin] += 1; full_hist.increment(bin);
if is_round(*sats) { if is_round(*sats) {
let d = leading_digit(*sats); let d = leading_digit(*sats);
if (1..=9).contains(&d) { if (1..=9).contains(&d) {
digit_hist[(d - 1) as usize][bin] += 1; digit_hist[(d - 1) as usize].increment(bin);
} }
} }
} }
@@ -227,7 +223,7 @@ fn main() {
// Feed each (mask, start_height) combo. // Feed each (mask, start_height) combo.
for (mi, &(mask, _)) in masks.iter().enumerate() { for (mi, &(mask, _)) in masks.iter().enumerate() {
// Build filtered histogram for this mask. // Build filtered histogram for this mask.
let mut hist = full_hist; let mut hist = full_hist.clone();
(0..9usize).for_each(|d| { (0..9usize).for_each(|d| {
if mask & (1 << d) != 0 { if mask & (1 << d) != 0 {
for bin in 0..NUM_BINS { for bin in 0..NUM_BINS {

View File

@@ -11,7 +11,9 @@
use std::path::PathBuf; use std::path::PathBuf;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin}; use brk_oracle::{
Config, Histogram, Oracle, PRICES, START_HEIGHT, cents_to_bin, default_eligible_bin,
};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex}; use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex};
@@ -52,8 +54,6 @@ fn main() {
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect(); let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect(); let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let ref_config = Config::default();
// Reference oracle at 575k. // Reference oracle at 575k.
let ref_start = START_HEIGHT; let ref_start = START_HEIGHT;
let mut ref_oracle = Oracle::new(seed_bin(ref_start), Config::default()); let mut ref_oracle = Oracle::new(seed_bin(ref_start), Config::default());
@@ -112,18 +112,10 @@ fn main() {
.output_type .output_type
.collect_range_at(out_start, out_end); .collect_range_at(out_start, out_end);
let mut hist = [0u32; NUM_BINS]; let mut hist = Histogram::zeros();
for (sats, output_type) in values.into_iter().zip(output_types) { for (sats, output_type) in values.into_iter().zip(output_types) {
if ref_config.excluded_output_types.contains(&output_type) { if let Some(bin) = default_eligible_bin(sats, output_type) {
continue; hist.increment(bin as usize);
}
if *sats < ref_config.min_sats
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
if let Some(bin) = sats_to_bin(sats) {
hist[bin] += 1;
} }
} }

View File

@@ -6,7 +6,7 @@ use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, cents_to_bin, sats_to_bin}; use brk_oracle::{Config, Histogram, Oracle, PRICES, cents_to_bin, default_eligible_bin};
use brk_types::{Sats, TxIndex, TxOutIndex}; use brk_types::{Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex};
@@ -19,7 +19,7 @@ fn bins_to_pct(bins: f64) -> f64 {
(10.0_f64.powf(bins / BPD) - 1.0) * 100.0 (10.0_f64.powf(bins / BPD) - 1.0) * 100.0
} }
fn price_seed_bin(start_height: usize) -> f64 { fn seed_bin(start_height: usize) -> f64 {
let price: f64 = PRICES let price: f64 = PRICES
.lines() .lines()
.nth(start_height - 1) .nth(start_height - 1)
@@ -30,9 +30,7 @@ fn price_seed_bin(start_height: usize) -> f64 {
} }
/// Clamp the top N bins in `src` down to the (N+1)th highest value, writing into `dst`. /// Clamp the top N bins in `src` down to the (N+1)th highest value, writing into `dst`.
fn clamp_top_n(src: &[u32; NUM_BINS], dst: &mut [u32; NUM_BINS], n: usize) { fn clamp_top_n(src: &Histogram, dst: &mut Histogram, n: usize) {
// Find the (n+1)th largest value.
// Collect non-zero counts, sort descending, take the (n+1)th.
let mut top: Vec<u32> = src.iter().copied().filter(|&v| v > 0).collect(); let mut top: Vec<u32> = src.iter().copied().filter(|&v| v > 0).collect();
top.sort_unstable_by(|a, b| b.cmp(a)); top.sort_unstable_by(|a, b| b.cmp(a));
let clamp_to = if top.len() > n { top[n] } else { 0 }; let clamp_to = if top.len() > n { top[n] } else { 0 };
@@ -102,7 +100,7 @@ fn main() {
let total_blocks = total_heights - lowest; let total_blocks = total_heights - lowest;
struct BlockData { struct BlockData {
hist: Box<[u32; NUM_BINS]>, hist: Histogram,
high_bin: f64, high_bin: f64,
low_bin: f64, low_bin: f64,
} }
@@ -144,21 +142,12 @@ fn main() {
.unwrap_or(TxOutIndex::from(total_outputs)) .unwrap_or(TxOutIndex::from(total_outputs))
.to_usize(); .to_usize();
let mut hist = Box::new([0u32; NUM_BINS]); let mut hist = Histogram::zeros();
for i in out_start..out_end { for i in out_start..out_end {
let sats: Sats = value_reader.get(i); let sats: Sats = value_reader.get(i);
let output_type = output_type_reader.get(i); let output_type = output_type_reader.get(i);
if config.excluded_output_types.contains(&output_type) { if let Some(bin) = default_eligible_bin(sats, output_type) {
continue; hist.increment(bin as usize);
}
if *sats < config.min_sats {
continue;
}
if config.exclude_common_round_values && sats.is_common_round_value() {
continue;
}
if let Some(bin) = sats_to_bin(sats) {
hist[bin] += 1;
} }
} }
@@ -206,7 +195,7 @@ fn main() {
println!("{}", "-".repeat(72)); println!("{}", "-".repeat(72));
for &start_height in &start_heights { for &start_height in &start_heights {
let mut oracle = Oracle::new(price_seed_bin(start_height), config.clone()); let mut oracle = Oracle::new(seed_bin(start_height), config.clone());
let block_offset = start_height - lowest; let block_offset = start_height - lowest;
let mut worst_err: f64 = 0.0; let mut worst_err: f64 = 0.0;
@@ -217,7 +206,7 @@ fn main() {
let mut total_sq_err: f64 = 0.0; let mut total_sq_err: f64 = 0.0;
let mut total_measured: u64 = 0; let mut total_measured: u64 = 0;
let mut clamped_hist = [0u32; NUM_BINS]; let mut clamped_hist = Histogram::zeros();
for (i, bd) in blocks[block_offset..].iter().enumerate() { for (i, bd) in blocks[block_offset..].iter().enumerate() {
if clamp_n > 0 { if clamp_n > 0 {
clamp_top_n(&bd.hist, &mut clamped_hist, clamp_n); clamp_top_n(&bd.hist, &mut clamped_hist, clamp_n);

View File

@@ -6,7 +6,8 @@ use std::path::PathBuf;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_oracle::{ use brk_oracle::{
Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, bin_to_cents, cents_to_bin, sats_to_bin, Config, Histogram, Oracle, PRICES, START_HEIGHT, bin_to_cents, cents_to_bin,
default_eligible_bin,
}; };
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex}; use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex};
@@ -188,8 +189,6 @@ fn main() {
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect(); let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect(); let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let ref_config = Config::default();
let mut year_stats: Vec<YearStats> = Vec::new(); let mut year_stats: Vec<YearStats> = Vec::new();
let mut overall = YearStats::new(0); let mut overall = YearStats::new(0);
let mut worst_blocks: Vec<BlockError> = Vec::new(); let mut worst_blocks: Vec<BlockError> = Vec::new();
@@ -238,18 +237,10 @@ fn main() {
.output_type .output_type
.collect_range_at(out_start, out_end); .collect_range_at(out_start, out_end);
let mut hist = [0u32; NUM_BINS]; let mut hist = Histogram::zeros();
for (sats, output_type) in values.into_iter().zip(output_types) { for (sats, output_type) in values.into_iter().zip(output_types) {
if ref_config.excluded_output_types.contains(&output_type) { if let Some(bin) = default_eligible_bin(sats, output_type) {
continue; hist.increment(bin as usize);
}
if *sats < ref_config.min_sats
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
if let Some(bin) = sats_to_bin(sats) {
hist[bin] += 1;
} }
} }

View File

@@ -12,7 +12,7 @@ use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin}; use brk_oracle::{Config, Histogram, Oracle, PRICES, cents_to_bin, sats_to_bin};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex}; use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex};
@@ -117,7 +117,7 @@ impl Stats {
} }
struct BlockData { struct BlockData {
full_hist: Box<[u32; NUM_BINS]>, full_hist: Histogram,
/// (bin_index, leading_digit) for outputs that are round values. /// (bin_index, leading_digit) for outputs that are round values.
round_outputs: Vec<(u16, u8)>, round_outputs: Vec<(u16, u8)>,
high_bin: f64, high_bin: f64,
@@ -173,7 +173,7 @@ fn main() {
let total_blocks = total_heights - sweep_start; let total_blocks = total_heights - sweep_start;
let mut blocks: Vec<BlockData> = Vec::with_capacity(total_blocks); let mut blocks: Vec<BlockData> = Vec::with_capacity(total_blocks);
for h in START_HEIGHT..total_heights { for h in sweep_start..total_heights {
let ft = first_tx_index[h]; let ft = first_tx_index[h];
let next_ft = first_tx_index let next_ft = first_tx_index
.get(h + 1) .get(h + 1)
@@ -201,10 +201,6 @@ fn main() {
.unwrap_or(TxOutIndex::from(total_outputs)) .unwrap_or(TxOutIndex::from(total_outputs))
.to_usize(); .to_usize();
if h < sweep_start {
continue;
}
let values: Vec<Sats> = indexer let values: Vec<Sats> = indexer
.vecs .vecs
.outputs .outputs
@@ -216,7 +212,7 @@ fn main() {
.output_type .output_type
.collect_range_at(out_start, out_end); .collect_range_at(out_start, out_end);
let mut full_hist = Box::new([0u32; NUM_BINS]); let mut full_hist = Histogram::zeros();
let mut round_outputs = Vec::new(); let mut round_outputs = Vec::new();
for (sats, output_type) in values.into_iter().zip(output_types) { for (sats, output_type) in values.into_iter().zip(output_types) {
@@ -227,7 +223,7 @@ fn main() {
continue; continue;
} }
if let Some(bin) = sats_to_bin(sats) { if let Some(bin) = sats_to_bin(sats) {
full_hist[bin] += 1; full_hist.increment(bin);
if is_round(*sats) { if is_round(*sats) {
let d = leading_digit(*sats); let d = leading_digit(*sats);
if (1..=9).contains(&d) { if (1..=9).contains(&d) {
@@ -260,7 +256,7 @@ fn main() {
} }
} }
let mem_hists = blocks.len() * std::mem::size_of::<[u32; NUM_BINS]>(); let mem_hists = blocks.len() * std::mem::size_of::<Histogram>();
let mem_rounds: usize = blocks.iter().map(|b| b.round_outputs.len() * 3).sum(); let mem_rounds: usize = blocks.iter().map(|b| b.round_outputs.len() * 3).sum();
eprintln!( eprintln!(
"\r {} blocks precomputed ({:.1} GB hists + {:.0} MB rounds) in {:.1}s", "\r {} blocks precomputed ({:.1} GB hists + {:.0} MB rounds) in {:.1}s",
@@ -308,7 +304,7 @@ fn main() {
let mut stats = Stats::new(); let mut stats = Stats::new();
for bd in blocks.iter() { for bd in blocks.iter() {
let mut hist = *bd.full_hist; let mut hist = bd.full_hist.clone();
for &(bin, digit) in &bd.round_outputs { for &(bin, digit) in &bd.round_outputs {
if mask & (1 << (digit - 1)) != 0 { if mask & (1 << (digit - 1)) != 0 {
hist[bin as usize] -= 1; hist[bin as usize] -= 1;

View File

@@ -12,7 +12,7 @@ use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin}; use brk_oracle::{Config, Histogram, Oracle, PRICES, cents_to_bin, sats_to_bin};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex}; use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex};
@@ -114,7 +114,7 @@ struct RoundOutput {
} }
struct BlockData { struct BlockData {
full_hist: Box<[u32; NUM_BINS]>, full_hist: Histogram,
round_outputs: Vec<RoundOutput>, round_outputs: Vec<RoundOutput>,
high_bin: f64, high_bin: f64,
low_bin: f64, low_bin: f64,
@@ -175,7 +175,7 @@ fn main() {
// Outputs beyond 5% relative error will never be filtered at any tolerance. // Outputs beyond 5% relative error will never be filtered at any tolerance.
let max_tolerance: f64 = 0.05; let max_tolerance: f64 = 0.05;
for h in START_HEIGHT..total_heights { for h in sweep_start..total_heights {
let ft = first_tx_index[h]; let ft = first_tx_index[h];
let next_ft = first_tx_index let next_ft = first_tx_index
.get(h + 1) .get(h + 1)
@@ -203,10 +203,6 @@ fn main() {
.unwrap_or(TxOutIndex::from(total_outputs)) .unwrap_or(TxOutIndex::from(total_outputs))
.to_usize(); .to_usize();
if h < sweep_start {
continue;
}
let values: Vec<Sats> = indexer let values: Vec<Sats> = indexer
.vecs .vecs
.outputs .outputs
@@ -218,7 +214,7 @@ fn main() {
.output_type .output_type
.collect_range_at(out_start, out_end); .collect_range_at(out_start, out_end);
let mut full_hist = Box::new([0u32; NUM_BINS]); let mut full_hist = Histogram::zeros();
let mut round_outputs = Vec::new(); let mut round_outputs = Vec::new();
for (sats, output_type) in values.into_iter().zip(output_types) { for (sats, output_type) in values.into_iter().zip(output_types) {
@@ -229,7 +225,7 @@ fn main() {
continue; continue;
} }
if let Some(bin) = sats_to_bin(sats) { if let Some(bin) = sats_to_bin(sats) {
full_hist[bin] += 1; full_hist.increment(bin);
let d = leading_digit(*sats); let d = leading_digit(*sats);
if (1..=9).contains(&d) { if (1..=9).contains(&d) {
let rel_err = relative_roundness(*sats); let rel_err = relative_roundness(*sats);
@@ -267,7 +263,7 @@ fn main() {
} }
} }
let mem_hists = blocks.len() * std::mem::size_of::<[u32; NUM_BINS]>(); let mem_hists = blocks.len() * std::mem::size_of::<Histogram>();
let mem_rounds: usize = blocks let mem_rounds: usize = blocks
.iter() .iter()
.map(|b| b.round_outputs.len() * std::mem::size_of::<RoundOutput>()) .map(|b| b.round_outputs.len() * std::mem::size_of::<RoundOutput>())
@@ -350,7 +346,7 @@ fn main() {
let mut stats = Stats::new(); let mut stats = Stats::new();
for bd in blocks.iter() { for bd in blocks.iter() {
let mut hist = *bd.full_hist; let mut hist = bd.full_hist.clone();
// Remove outputs matching this tolerance + mask. // Remove outputs matching this tolerance + mask.
let tol_f32 = tolerance as f32; let tol_f32 = tolerance as f32;

View File

@@ -9,7 +9,9 @@
use std::path::PathBuf; use std::path::PathBuf;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin}; use brk_oracle::{
Config, Histogram, Oracle, PRICES, START_HEIGHT, cents_to_bin, default_eligible_bin,
};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex}; use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex};
@@ -155,8 +157,6 @@ fn main() {
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect(); let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect(); let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let ref_config = Config::default();
for h in START_HEIGHT..total_heights { for h in START_HEIGHT..total_heights {
let ft = first_tx_index[h]; let ft = first_tx_index[h];
let next_ft = first_tx_index let next_ft = first_tx_index
@@ -197,18 +197,10 @@ fn main() {
.output_type .output_type
.collect_range_at(out_start, out_end); .collect_range_at(out_start, out_end);
let mut hist = [0u32; NUM_BINS]; let mut hist = Histogram::zeros();
for (sats, output_type) in values.into_iter().zip(output_types) { for (sats, output_type) in values.into_iter().zip(output_types) {
if ref_config.excluded_output_types.contains(&output_type) { if let Some(bin) = default_eligible_bin(sats, output_type) {
continue; hist.increment(bin as usize);
}
if *sats < ref_config.min_sats
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
if let Some(bin) = sats_to_bin(sats) {
hist[bin] += 1;
} }
} }

View File

@@ -0,0 +1,40 @@
use brk_types::OutputType;
/// Dust floor used by `Config::default()` and `default_eligible_bin`.
pub(crate) const DEFAULT_MIN_SATS: u64 = 1000;
/// Output types skipped by `Config::default()` (noisy) and the source of
/// truth for `default_eligible_bin`'s precomputed exclusion mask.
pub(crate) const DEFAULT_EXCLUDED_OUTPUT_TYPES: &[OutputType] =
&[OutputType::P2TR, OutputType::P2WSH];
#[derive(Clone)]
pub struct Config {
/// EMA decay: 2/(N+1) where N is span in blocks. 2/7 = 6-block span.
pub alpha: f64,
/// Ring buffer depth. 12 blocks for deterministic convergence at any start height.
pub window_size: usize,
/// Search window bins below/above previous estimate. Asymmetric for log-scale.
pub search_below: usize,
pub search_above: usize,
/// Minimum output value in sats (dust filter).
pub min_sats: u64,
/// Exclude round BTC amounts that create false stencil matches.
pub exclude_common_round_values: bool,
/// Output types to ignore (e.g. P2TR, P2WSH are noisy).
pub excluded_output_types: Vec<OutputType>,
}
impl Default for Config {
fn default() -> Self {
Self {
alpha: 2.0 / 7.0,
window_size: 12,
search_below: 9,
search_above: 11,
min_sats: DEFAULT_MIN_SATS,
exclude_common_round_values: true,
excluded_output_types: DEFAULT_EXCLUDED_OUTPUT_TYPES.to_vec(),
}
}
}

View File

@@ -0,0 +1,41 @@
use crate::NUM_BINS;
/// Per-block oracle histogram: count of eligible outputs per bin. Wraps
/// the raw `[u32; NUM_BINS]` so callers can't pass arbitrary bin-indexed
/// arrays to `Oracle::process_histogram`. Deref to the underlying array
/// gives indexing for read paths.
#[derive(Clone)]
pub struct Histogram([u32; NUM_BINS]);
impl Histogram {
#[inline]
pub fn zeros() -> Self {
Self([0; NUM_BINS])
}
#[inline]
pub fn increment(&mut self, bin: usize) {
self.0[bin] += 1;
}
}
impl Default for Histogram {
fn default() -> Self {
Self::zeros()
}
}
impl std::ops::Deref for Histogram {
type Target = [u32; NUM_BINS];
#[inline]
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for Histogram {
#[inline]
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

View File

@@ -3,7 +3,14 @@
//! Detects round-dollar transaction patterns ($1, $5, $10, ... $10,000) in Bitcoin //! Detects round-dollar transaction patterns ($1, $5, $10, ... $10,000) in Bitcoin
//! block outputs to derive the current price without any exchange data. //! block outputs to derive the current price without any exchange data.
use brk_types::{Block, Cents, Dollars, OutputType, Sats}; use brk_types::{Cents, Dollars, OutputType, Sats};
mod config;
mod histogram;
use config::{DEFAULT_EXCLUDED_OUTPUT_TYPES, DEFAULT_MIN_SATS};
pub use config::Config;
pub use histogram::Histogram;
/// Pre-oracle dollar prices, one per line, heights 0..630_000. /// Pre-oracle dollar prices, one per line, heights 0..630_000.
pub const PRICES: &str = include_str!("prices.txt"); pub const PRICES: &str = include_str!("prices.txt");
@@ -55,6 +62,33 @@ pub fn sats_to_bin(sats: Sats) -> Option<usize> {
} }
} }
/// Bitmask form of `DEFAULT_EXCLUDED_OUTPUT_TYPES`, evaluated at compile
/// time so `default_eligible_bin` checks membership with a single AND.
const DEFAULT_EXCLUDED_MASK: u16 = {
let mut mask = 0u16;
let mut i = 0;
while i < DEFAULT_EXCLUDED_OUTPUT_TYPES.len() {
mask |= 1u16 << DEFAULT_EXCLUDED_OUTPUT_TYPES[i] as u8;
i += 1;
}
mask
};
/// Bin index for `(sats, output_type)` under `Config::default()` rules.
/// Returns `None` for excluded types (P2TR/P2WSH), dust, round-BTC values,
/// or out-of-range bins. Mirror of `Oracle::output_to_bin` for callers that
/// can pre-bin outputs at write time and don't have an `Oracle` handle.
#[inline(always)]
pub fn default_eligible_bin(sats: Sats, output_type: OutputType) -> Option<u16> {
if DEFAULT_EXCLUDED_MASK & (1u16 << output_type as u8) != 0 {
return None;
}
if *sats < DEFAULT_MIN_SATS || sats.is_common_round_value() {
return None;
}
sats_to_bin(sats).map(|b| b as u16)
}
/// Converts a fractional bin to a USD price in cents. /// Converts a fractional bin to a USD price in cents.
/// For a $D output at price P: sats = D * 1e8 / P, so P = 10^(10 - bin/200) dollars, /// For a $D output at price P: sats = D * 1e8 / P, so P = 10^(10 - bin/200) dollars,
/// where 10 = log10($100 reference * 1e8 sats/BTC). /// where 10 = log10($100 reference * 1e8 sats/BTC).
@@ -140,40 +174,9 @@ fn find_best_bin(
best_bin as f64 + sub_bin best_bin as f64 + sub_bin
} }
#[derive(Clone)]
pub struct Config {
/// EMA decay: 2/(N+1) where N is span in blocks. 2/7 = 6-block span.
pub alpha: f64,
/// Ring buffer depth. 12 blocks for deterministic convergence at any start height.
pub window_size: usize,
/// Search window bins below/above previous estimate. Asymmetric for log-scale.
pub search_below: usize,
pub search_above: usize,
/// Minimum output value in sats (dust filter).
pub min_sats: u64,
/// Exclude round BTC amounts that create false stencil matches.
pub exclude_common_round_values: bool,
/// Output types to ignore (e.g. P2TR, P2WSH are noisy).
pub excluded_output_types: Vec<OutputType>,
}
impl Default for Config {
fn default() -> Self {
Self {
alpha: 2.0 / 7.0,
window_size: 12,
search_below: 9,
search_above: 11,
min_sats: 1000,
exclude_common_round_values: true,
excluded_output_types: vec![OutputType::P2TR, OutputType::P2WSH],
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct Oracle { pub struct Oracle {
histograms: Vec<[u32; NUM_BINS]>, histograms: Vec<Histogram>,
ema: Box<[f64; NUM_BINS]>, ema: Box<[f64; NUM_BINS]>,
cursor: usize, cursor: usize,
filled: usize, filled: usize,
@@ -196,7 +199,7 @@ impl Oracle {
.iter() .iter()
.fold(0u16, |mask, ot| mask | (1 << *ot as u8)); .fold(0u16, |mask, ot| mask | (1 << *ot as u8));
Self { Self {
histograms: vec![[0u32; NUM_BINS]; window_size], histograms: vec![Histogram::zeros(); window_size],
ema: Box::new([0.0; NUM_BINS]), ema: Box::new([0.0; NUM_BINS]),
cursor: 0, cursor: 0,
filled: 0, filled: 0,
@@ -208,81 +211,21 @@ impl Oracle {
} }
} }
pub fn process_block(&mut self, block: &Block) -> f64 { /// Create an oracle restored from a known price. `fill` should call
self.process_outputs( /// `process_histogram` for the warmup blocks; during warmup the ring
block /// fills without recomputing EMA or searching, then we recompute once
.txdata /// at the end so the first non-warmup call has a primed EMA.
.iter()
.skip(1) // skip coinbase
.flat_map(|tx| &tx.output)
.map(|txout| {
(
Sats::from(txout.value),
OutputType::from(&txout.script_pubkey),
)
}),
)
}
pub fn process_outputs(&mut self, outputs: impl Iterator<Item = (Sats, OutputType)>) -> f64 {
let mut hist = [0u32; NUM_BINS];
for (sats, output_type) in outputs {
if let Some(bin) = self.eligible_bin(sats, output_type) {
hist[bin] += 1;
}
}
self.ingest(&hist)
}
/// Create an oracle restored from a known price.
/// `fill` should feed warmup blocks to populate the ring buffer.
/// ref_bin is anchored to the checkpoint regardless of warmup drift.
pub fn from_checkpoint(ref_bin: f64, config: Config, fill: impl FnOnce(&mut Self)) -> Self { pub fn from_checkpoint(ref_bin: f64, config: Config, fill: impl FnOnce(&mut Self)) -> Self {
let mut oracle = Self::new(ref_bin, config); let mut oracle = Self::new(ref_bin, config);
oracle.warmup = true; oracle.warmup = true;
fill(&mut oracle); fill(&mut oracle);
oracle.warmup = false; oracle.warmup = false;
oracle.recompute_ema(); oracle.recompute_ema();
oracle.ref_bin = ref_bin;
oracle oracle
} }
pub fn process_histogram(&mut self, hist: &[u32; NUM_BINS]) -> f64 { pub fn process_histogram(&mut self, hist: &Histogram) -> f64 {
self.ingest(hist) self.histograms[self.cursor] = hist.clone();
}
pub fn ref_bin(&self) -> f64 {
self.ref_bin
}
pub fn price_cents(&self) -> Cents {
bin_to_cents(self.ref_bin).into()
}
pub fn price_dollars(&self) -> Dollars {
self.price_cents().into()
}
#[inline(always)]
pub fn output_to_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
self.eligible_bin(sats, output_type)
}
#[inline(always)]
fn eligible_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
if self.excluded_mask & (1 << output_type as u8) != 0 {
return None;
}
if *sats < self.config.min_sats
|| (self.config.exclude_common_round_values && sats.is_common_round_value())
{
return None;
}
sats_to_bin(sats)
}
fn ingest(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
self.histograms[self.cursor] = *hist;
self.cursor = (self.cursor + 1) % self.config.window_size; self.cursor = (self.cursor + 1) % self.config.window_size;
if self.filled < self.config.window_size { if self.filled < self.config.window_size {
self.filled += 1; self.filled += 1;
@@ -301,6 +244,35 @@ impl Oracle {
self.ref_bin self.ref_bin
} }
pub fn ref_bin(&self) -> f64 {
self.ref_bin
}
pub fn price_cents(&self) -> Cents {
bin_to_cents(self.ref_bin).into()
}
pub fn price_dollars(&self) -> Dollars {
self.price_cents().into()
}
/// Config-aware bin index for `(sats, output_type)`. Returns `None`
/// for excluded types, dust, round-BTC values, or out-of-range bins.
/// Callers under `Config::default()` should use `default_eligible_bin`
/// (free function) to skip the `&self` indirection.
#[inline(always)]
pub fn output_to_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
if self.excluded_mask & (1 << output_type as u8) != 0 {
return None;
}
if *sats < self.config.min_sats
|| (self.config.exclude_common_round_values && sats.is_common_round_value())
{
return None;
}
sats_to_bin(sats)
}
fn recompute_ema(&mut self) { fn recompute_ema(&mut self) {
self.ema.fill(0.0); self.ema.fill(0.0);
for age in 0..self.filled { for age in 0..self.filled {

View File

@@ -17,6 +17,7 @@ brk_computer = { workspace = true }
brk_error = { workspace = true, features = ["jiff", "vecdb"] } brk_error = { workspace = true, features = ["jiff", "vecdb"] }
brk_indexer = { workspace = true } brk_indexer = { workspace = true }
brk_mempool = { workspace = true } brk_mempool = { workspace = true }
brk_oracle = { workspace = true }
brk_reader = { workspace = true } brk_reader = { workspace = true }
brk_rpc = { workspace = true } brk_rpc = { workspace = true }
brk_traversable = { workspace = true } brk_traversable = { workspace = true }

View File

@@ -85,25 +85,6 @@ impl Query {
}) })
} }
/// Esplora `/address/:address/txs` first page: up to `mempool_limit`
/// mempool entries (newest first), then chain entries fill the response
/// up to `total_limit`. Pagination is path-style via `/txs/chain/:after_txid`.
pub fn addr_txs(
&self,
addr: Addr,
total_limit: usize,
mempool_limit: usize,
) -> Result<Vec<Transaction>> {
let mut out = if self.mempool().is_some() {
self.addr_mempool_txs(&addr, mempool_limit)?
} else {
Vec::new()
};
let chain_limit = total_limit.saturating_sub(out.len());
out.extend(self.addr_txs_chain(&addr, None, chain_limit)?);
Ok(out)
}
pub fn addr_txs_chain( pub fn addr_txs_chain(
&self, &self,
addr: &Addr, addr: &Addr,
@@ -236,8 +217,15 @@ impl Query {
Ok(mempool.addr_txs(&bytes, limit)) Ok(mempool.addr_txs(&bytes, limit))
} }
/// Height of the last on-chain activity for an address (last tx_index height). /// Height of the last on-chain activity for an address (last tx_index to height).
pub fn addr_last_activity_height(&self, addr: &Addr) -> Result<Height> { /// With `before_txid`, returns the newest activity strictly older than that
/// cursor. Used by paginated chain etags so a new tx above the cursor
/// doesn't invalidate deeper pages.
pub fn addr_last_activity_height(
&self,
addr: &Addr,
before_txid: Option<&Txid>,
) -> Result<Height> {
let (output_type, type_index) = self.resolve_addr(addr)?; let (output_type, type_index) = self.resolve_addr(addr)?;
let store = self let store = self
.indexer() .indexer()
@@ -246,12 +234,25 @@ impl Query {
.get(output_type) .get(output_type)
.data()?; .data()?;
let tx_index_len = self.safe_lengths().tx_index; let tx_index_len = self.safe_lengths().tx_index;
let last_tx_index = store let last_tx_index = match before_txid {
.prefix(type_index) Some(txid) => {
.rev() let before_tx_index = self.resolve_tx_index(txid)?;
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) let min = AddrIndexTxIndex::min_for_addr(type_index);
.find(|tx_index| *tx_index < tx_index_len) let cursor = AddrIndexTxIndex::from((type_index, before_tx_index));
.ok_or(Error::UnknownAddr)?; store
.range(min..cursor)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.find(|tx_index| *tx_index < tx_index_len)
.ok_or(Error::UnknownAddr)?
}
None => store
.prefix(type_index)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.find(|tx_index| *tx_index < tx_index_len)
.ok_or(Error::UnknownAddr)?,
};
self.confirmed_status_height(last_tx_index) self.confirmed_status_height(last_tx_index)
} }

View File

@@ -1,20 +1,68 @@
use brk_error::Result; use std::sync::Arc;
use brk_computer::prices::Vecs as PricesVecs;
use brk_error::{Error, Result};
use brk_oracle::{Config, Oracle, cents_to_bin};
use brk_types::{ use brk_types::{
Dollars, ExchangeRates, HistoricalPrice, HistoricalPriceEntry, Hour4, INDEX_EPOCH, Timestamp, Dollars, ExchangeRates, HistoricalPrice, HistoricalPriceEntry, Hour4, INDEX_EPOCH, Timestamp,
}; };
use vecdb::ReadableVec; use vecdb::{AnyVec, ReadableVec, VecIndex};
use crate::Query; use crate::Query;
impl Query { impl Query {
pub fn live_price(&self) -> Result<Dollars> { pub fn live_price(&self) -> Result<Dollars> {
let mut oracle = self.computer().prices.live_oracle(self.indexer())?; let base = self.cached_oracle()?;
Ok(match self.mempool() {
Some(mempool) => {
let mut oracle = (*base).clone();
oracle.process_histogram(&mempool.live_histogram());
oracle.price_dollars()
}
None => base.price_dollars(),
})
}
if let Some(mempool) = self.mempool() { /// Oracle warmed by the last `window_size` committed blocks, seeded from
mempool.process_live_outputs(|iter| oracle.process_outputs(iter)); /// the last committed price. Cached per tip height; rebuilt on advance or
/// reorg. Reads are capped at `safe_lengths` so concurrent indexer writes
/// stay invisible.
fn cached_oracle(&self) -> Result<Arc<Oracle>> {
let safe_lengths = self.safe_lengths();
let height = safe_lengths.height;
if let Some(oracle) = self
.0
.live_oracle
.read()
.unwrap()
.as_ref()
.filter(|(h, _)| *h == height)
.map(|(_, o)| o.clone())
{
return Ok(oracle);
} }
Ok(oracle.price_dollars()) let cents_height = &self.computer().prices.spot.cents.height;
let last_cents = cents_height
.len()
.checked_sub(1)
.and_then(|i| cents_height.collect_one_at(i))
.ok_or_else(|| Error::NotFound("oracle prices not yet computed".to_string()))?;
let config = Config::default();
let seed_bin = cents_to_bin(last_cents.inner() as f64);
let tip = height.to_usize();
let warmup_range = tip.saturating_sub(config.window_size)..tip;
let oracle = Arc::new(Oracle::from_checkpoint(seed_bin, config, |o| {
PricesVecs::feed_blocks(o, self.indexer(), warmup_range, Some(&safe_lengths));
}));
let mut cache = self.0.live_oracle.write().unwrap();
if cache.as_ref().is_none_or(|(h, _)| *h != height) {
*cache = Some((height, oracle.clone()));
}
Ok(oracle)
} }
pub fn historical_price(&self, timestamp: Option<Timestamp>) -> Result<HistoricalPrice> { pub fn historical_price(&self, timestamp: Option<Timestamp>) -> Result<HistoricalPrice> {

View File

@@ -1,12 +1,16 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
#![allow(clippy::module_inception)] #![allow(clippy::module_inception)]
use std::{path::Path, sync::Arc}; use std::{
path::Path,
sync::{Arc, RwLock},
};
use brk_computer::Computer; use brk_computer::Computer;
use brk_error::{OptionData, Result}; use brk_error::{OptionData, Result};
use brk_indexer::{Indexer, Lengths}; use brk_indexer::{Indexer, Lengths};
use brk_mempool::Mempool; use brk_mempool::Mempool;
use brk_oracle::Oracle;
use brk_reader::Reader; use brk_reader::Reader;
use brk_rpc::Client; use brk_rpc::Client;
use brk_types::{BlockHash, BlockHashPrefix, Height, SyncStatus}; use brk_types::{BlockHash, BlockHashPrefix, Height, SyncStatus};
@@ -32,6 +36,7 @@ struct QueryInner<'a> {
indexer: &'a Indexer<Ro>, indexer: &'a Indexer<Ro>,
computer: &'a Computer<Ro>, computer: &'a Computer<Ro>,
mempool: Option<Mempool>, mempool: Option<Mempool>,
live_oracle: RwLock<Option<(Height, Arc<Oracle>)>>,
} }
impl Query { impl Query {
@@ -54,6 +59,7 @@ impl Query {
indexer, indexer,
computer, computer,
mempool, mempool,
live_oracle: RwLock::new(None),
})) }))
} }

View File

@@ -11,6 +11,14 @@ use crate::{
params::{AddrAfterTxidParam, AddrParam, Empty, ValidateAddrParam}, params::{AddrAfterTxidParam, AddrParam, Empty, ValidateAddrParam},
}; };
/// Esplora `/txs` and `/txs/chain` page sizes. Wire-protocol constants from
/// mempool.space/esplora, not deployment policy. `/txs` returns up to
/// `MEMPOOL_PAGE` mempool entries plus a chain page sized to reach
/// `TXS_TOTAL_TARGET` total, floored at `CHAIN_PAGE`.
const MEMPOOL_PAGE: usize = 50;
const CHAIN_PAGE: usize = 25;
const TXS_TOTAL_TARGET: usize = 50;
pub trait AddrRoutes { pub trait AddrRoutes {
fn add_addr_routes(self) -> Self; fn add_addr_routes(self) -> Self;
} }
@@ -26,7 +34,7 @@ impl AddrRoutes for ApiRouter<AppState> {
_: Empty, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, false); let strategy = state.addr_strategy(Version::ONE, &path.addr, false, None);
state.respond_json(&headers, strategy, &uri, move |q| q.addr(path.addr)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr(path.addr)).await
}, |op| op }, |op| op
.id("get_address") .id("get_address")
@@ -49,13 +57,24 @@ impl AddrRoutes for ApiRouter<AppState> {
_: Empty, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, false); let strategy = state.addr_strategy(Version::ONE, &path.addr, false, None);
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, 50, 50)).await state.respond_json(&headers, strategy, &uri, move |q| {
let mempool_txs = if q.mempool().is_some() {
q.addr_mempool_txs(&path.addr, MEMPOOL_PAGE)?
} else {
Vec::new()
};
let chain_limit = TXS_TOTAL_TARGET.saturating_sub(mempool_txs.len()).max(CHAIN_PAGE);
let chain_txs = q.addr_txs_chain(&path.addr, None, chain_limit)?;
let mut out = mempool_txs;
out.extend(chain_txs);
Ok(out)
}).await
}, |op| op }, |op| op
.id("get_address_txs") .id("get_address_txs")
.addrs_tag() .addrs_tag()
.summary("Address transactions") .summary("Address transactions")
.description("Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*") .description("Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
.json_response::<Vec<Transaction>>() .json_response::<Vec<Transaction>>()
.not_modified() .not_modified()
.bad_request() .bad_request()
@@ -72,8 +91,8 @@ impl AddrRoutes for ApiRouter<AppState> {
_: Empty, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, true); let strategy = state.addr_strategy(Version::ONE, &path.addr, true, None);
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, None, 25)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, None, CHAIN_PAGE)).await
}, |op| op }, |op| op
.id("get_address_confirmed_txs") .id("get_address_confirmed_txs")
.addrs_tag() .addrs_tag()
@@ -95,8 +114,8 @@ impl AddrRoutes for ApiRouter<AppState> {
_: Empty, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, true); let strategy = state.addr_strategy(Version::ONE, &path.addr, true, Some(&path.after_txid));
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, Some(path.after_txid), 25)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, Some(path.after_txid), CHAIN_PAGE)).await
}, |op| op }, |op| op
.id("get_address_confirmed_txs_after") .id("get_address_confirmed_txs_after")
.addrs_tag() .addrs_tag()
@@ -119,7 +138,7 @@ impl AddrRoutes for ApiRouter<AppState> {
State(state): State<AppState> State(state): State<AppState>
| { | {
let hash = state.sync(|q| q.addr_mempool_hash(&path.addr)).unwrap_or(0); let hash = state.sync(|q| q.addr_mempool_hash(&path.addr)).unwrap_or(0);
state.respond_json(&headers, CacheStrategy::MempoolHash(hash), &uri, move |q| q.addr_mempool_txs(&path.addr, 50)).await state.respond_json(&headers, CacheStrategy::MempoolHash(hash), &uri, move |q| q.addr_mempool_txs(&path.addr, MEMPOOL_PAGE)).await
}, |op| op }, |op| op
.id("get_address_mempool_txs") .id("get_address_mempool_txs")
.addrs_tag() .addrs_tag()
@@ -141,7 +160,7 @@ impl AddrRoutes for ApiRouter<AppState> {
_: Empty, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, false); let strategy = state.addr_strategy(Version::ONE, &path.addr, false, None);
let max_utxos = state.max_utxos; let max_utxos = state.max_utxos;
state.respond_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr, max_utxos)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr, max_utxos)).await
}, |op| op }, |op| op

View File

@@ -135,7 +135,8 @@ impl MiningRoutes for ApiRouter<AppState> {
"/api/v1/mining/pool/{slug}/blocks", "/api/v1/mining/pool/{slug}/blocks",
get_with( get_with(
async |uri: Uri, headers: HeaderMap, Path(path): Path<PoolSlugParam>, _: Empty, State(state): State<AppState>| { async |uri: Uri, headers: HeaderMap, Path(path): Path<PoolSlugParam>, _: Empty, State(state): State<AppState>| {
state.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.pool_blocks(path.slug, None, POOL_BLOCKS_LIMIT)).await let strategy = state.pool_blocks_strategy(Version::ONE, path.slug);
state.respond_json(&headers, strategy, &uri, move |q| q.pool_blocks(path.slug, None, POOL_BLOCKS_LIMIT)).await
}, },
|op| { |op| {
op.id("get_pool_blocks") op.id("get_pool_blocks")

View File

@@ -29,13 +29,7 @@ impl ServerRoutes for ApiRouter<AppState> {
let uptime = state.started_instant.elapsed(); let uptime = state.started_instant.elapsed();
let started_at = state.started_at.to_string(); let started_at = state.started_at.to_string();
let sync = state let sync = state
.run(move |q| { .run(move |q| q.sync_status(q.height()))
let tip_height = q
.client()
.get_last_height()
.unwrap_or(q.height());
q.sync_status(tip_height)
})
.await .await
.expect("health sync task panicked"); .expect("health sync task panicked");
let mut response = axum::Json(Health { let mut response = axum::Json(Health {
@@ -57,7 +51,7 @@ impl ServerRoutes for ApiRouter<AppState> {
op.id("get_health") op.id("get_health")
.server_tag() .server_tag()
.summary("Health check") .summary("Health check")
.description("Returns the health status of the API server, including uptime information.") .description("Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.")
.json_response::<Health>() .json_response::<Health>()
}, },
), ),

View File

@@ -6,13 +6,13 @@ use axum::{
}; };
use brk_query::AsyncQuery; use brk_query::AsyncQuery;
use brk_types::{ use brk_types::{
Addr, BlockHash, BlockHashPrefix, Date, Height, ONE_HOUR_IN_SEC, Timestamp as BrkTimestamp, Addr, BlockHash, BlockHashPrefix, Date, Height, ONE_HOUR_IN_SEC, PoolSlug,
Txid, Version, Timestamp as BrkTimestamp, Txid, Version,
}; };
use derive_more::Deref; use derive_more::Deref;
use jiff::Timestamp; use jiff::Timestamp;
use serde::Serialize; use serde::Serialize;
use vecdb::ReadableVec; use vecdb::{ReadableVec, VecIndex};
use crate::{CacheParams, CacheStrategy, Error, Website, extended::ResponseExtended}; use crate::{CacheParams, CacheStrategy, Error, Website, extended::ResponseExtended};
@@ -70,16 +70,26 @@ impl AppState {
}) })
} }
/// Smart address caching: checks mempool activity first (unless `chain_only`), then on-chain. /// Smart address caching. Checks mempool activity first (unless `chain_only`), then on-chain.
/// - Address has mempool txs `MempoolHash(addr_specific_hash)` /// - Address has mempool txs: `MempoolHash(addr_specific_hash)`
/// - No mempool, has on-chain activity `BlockBound(last_activity_block)` /// - No mempool, has on-chain activity: `BlockBound(last_activity_block)`
/// - Unknown address `Tip` /// - Unknown address: `Tip`
pub fn addr_strategy(&self, version: Version, addr: &Addr, chain_only: bool) -> CacheStrategy { ///
/// `before_txid` narrows the on-chain branch to the newest activity strictly
/// older than the cursor, so paginated chain pages stay cacheable when newer
/// activity arrives above the cursor.
pub fn addr_strategy(
&self,
version: Version,
addr: &Addr,
chain_only: bool,
before_txid: Option<&Txid>,
) -> CacheStrategy {
self.sync(|q| { self.sync(|q| {
if !chain_only && let Some(mempool_hash) = q.addr_mempool_hash(addr) { if !chain_only && let Some(mempool_hash) = q.addr_mempool_hash(addr) {
return CacheStrategy::MempoolHash(mempool_hash); return CacheStrategy::MempoolHash(mempool_hash);
} }
q.addr_last_activity_height(addr) q.addr_last_activity_height(addr, before_txid)
.and_then(|h| { .and_then(|h| {
let block_hash = q.block_hash_by_height(h)?; let block_hash = q.block_hash_by_height(h)?;
Ok(CacheStrategy::BlockBound( Ok(CacheStrategy::BlockBound(
@@ -135,6 +145,29 @@ impl AppState {
}) })
} }
/// `BlockBound` on the pool's last-mined block hash, `Tip` if the pool has
/// never mined. Lets the no-cursor pool-blocks page stay cached when *other*
/// pools mine; only invalidates when this pool itself mines.
pub fn pool_blocks_strategy(&self, version: Version, slug: PoolSlug) -> CacheStrategy {
self.sync(|q| {
let tip = q.height().to_usize();
let last = q
.computer()
.pools
.pool_heights
.read()
.get(&slug)
.and_then(|heights| {
let pos = heights.partition_point(|h| h.to_usize() <= tip);
pos.checked_sub(1).map(|i| heights[i])
});
match last.and_then(|h| q.block_hash_by_height(h).ok()) {
Some(hash) => CacheStrategy::BlockBound(version, BlockHashPrefix::from(&hash)),
None => CacheStrategy::Tip,
}
})
}
pub fn mempool_strategy(&self) -> CacheStrategy { pub fn mempool_strategy(&self) -> CacheStrategy {
let hash = self.sync(|q| q.mempool().map(|m| m.next_block_hash().into()).unwrap_or(0)); let hash = self.sync(|q| q.mempool().map(|m| m.next_block_hash().into()).unwrap_or(0));
CacheStrategy::MempoolHash(hash) CacheStrategy::MempoolHash(hash)