mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 06:14:47 -07:00
crates: snapshot
This commit is contained in:
@@ -9009,7 +9009,7 @@ impl BrkClient {
|
||||
|
||||
/// 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`
|
||||
pub fn get_health(&self) -> Result<Health> {
|
||||
@@ -9294,7 +9294,7 @@ impl BrkClient {
|
||||
|
||||
/// 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)*
|
||||
///
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
use brk_error::Result;
|
||||
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 tracing::info;
|
||||
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 warmup = config.window_size.min(committed - START_HEIGHT);
|
||||
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;
|
||||
@@ -121,7 +121,8 @@ impl Vecs {
|
||||
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() {
|
||||
self.spot
|
||||
@@ -150,32 +151,14 @@ impl Vecs {
|
||||
}
|
||||
|
||||
/// Feed a range of blocks from the indexer into an Oracle (skipping coinbase),
|
||||
/// returning per-block ref_bin values. Uncapped: derives boundaries from
|
||||
/// raw indexer vec lengths. Use during compute, when the indexer is
|
||||
/// quiescent and `safe_lengths` is still pinned at the pre-pass value.
|
||||
fn feed_blocks<M: StorageMode>(
|
||||
/// returning per-block ref_bin values.
|
||||
///
|
||||
/// Pass `cap = None` from compute paths, when the indexer is quiescent and
|
||||
/// 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,
|
||||
indexer: &Indexer<M>,
|
||||
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>,
|
||||
indexer: &Indexer<IM>,
|
||||
range: Range<usize>,
|
||||
cap: Option<&Lengths>,
|
||||
) -> Vec<f64> {
|
||||
@@ -208,24 +191,24 @@ impl Vecs {
|
||||
|
||||
let mut ref_bins = Vec::with_capacity(range.len());
|
||||
|
||||
// Cursor avoids per-block PcoVec page decompression for
|
||||
// the tx-indexed first_txout_index lookup. The accessed
|
||||
// tx_index values (first_tx_index + 1) are strictly increasing
|
||||
// across blocks, so the cursor only advances forward.
|
||||
// Cursor avoids per-block PcoVec page decompression for the
|
||||
// tx-indexed first_txout_index lookup. Accessed tx_index values
|
||||
// (first_tx_index + 1) are strictly increasing across blocks,
|
||||
// so the cursor only advances forward.
|
||||
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 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 next_first_tx_index = first_tx_indexes
|
||||
.get(idx + 1)
|
||||
.copied()
|
||||
.unwrap_or(TxIndex::from(total_txs));
|
||||
|
||||
let next_out_first = out_firsts
|
||||
let out_end = out_firsts
|
||||
.get(idx + 1)
|
||||
.copied()
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
@@ -235,9 +218,8 @@ impl Vecs {
|
||||
txout_cursor.advance(target - txout_cursor.position());
|
||||
txout_cursor.next().unwrap().to_usize()
|
||||
} else {
|
||||
next_out_first
|
||||
out_end
|
||||
};
|
||||
let out_end = next_out_first;
|
||||
|
||||
indexer
|
||||
.vecs
|
||||
@@ -250,10 +232,10 @@ impl Vecs {
|
||||
&mut output_types,
|
||||
);
|
||||
|
||||
let mut hist = [0u32; NUM_BINS];
|
||||
for i in 0..values.len() {
|
||||
if let Some(bin) = oracle.output_to_bin(values[i], output_types[i]) {
|
||||
hist[bin] += 1;
|
||||
let mut hist = Histogram::zeros();
|
||||
for (sats, output_type) in values.iter().zip(&output_types) {
|
||||
if let Some(bin) = oracle.output_to_bin(*sats, *output_type) {
|
||||
hist.increment(bin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,42 +245,3 @@ impl Vecs {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ exclude = ["examples/"]
|
||||
[dependencies]
|
||||
bitcoin = { workspace = true }
|
||||
brk_error = { workspace = true }
|
||||
brk_oracle = { workspace = true }
|
||||
brk_rpc = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
|
||||
@@ -32,11 +32,12 @@ use std::{
|
||||
};
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_oracle::Histogram;
|
||||
use brk_rpc::Client;
|
||||
use brk_types::{
|
||||
AddrBytes, AddrMempoolStats, BlockTemplate, BlockTemplateDiff, FeeRate, MempoolBlock,
|
||||
MempoolInfo, MempoolRecentTx, NextBlockHash, OutpointPrefix, OutputType, Sats, Timestamp,
|
||||
Transaction, TxOut, Txid, TxidPrefix, Vin, Vout,
|
||||
MempoolInfo, MempoolRecentTx, NextBlockHash, OutpointPrefix, Timestamp, Transaction, TxOut,
|
||||
Txid, TxidPrefix, Vin, Vout,
|
||||
};
|
||||
use parking_lot::{RwLock, RwLockReadGuard};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
@@ -236,19 +237,20 @@ impl Mempool {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Apply `f` to an iterator over `(value, output_type)` for every output
|
||||
/// of every live mempool tx. The lock is held for the duration of the call.
|
||||
pub fn process_live_outputs<R>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut dyn Iterator<Item = (Sats, OutputType)>) -> R,
|
||||
) -> R {
|
||||
/// Histogram of pre-bucketed oracle bins across all live mempool tx
|
||||
/// outputs. Bins are computed once on insert (see `OutputBins`), so this
|
||||
/// hot path is `O(eligible outputs)` of integer increments. Used by
|
||||
/// `live_price` to blend the mempool into the committed oracle without
|
||||
/// re-parsing scripts per request.
|
||||
pub fn live_histogram(&self) -> Histogram {
|
||||
let mut hist = Histogram::zeros();
|
||||
let state = self.read();
|
||||
let mut iter = state
|
||||
.txs
|
||||
.values()
|
||||
.flat_map(|tx| &tx.output)
|
||||
.map(|txout| (txout.value, txout.type_()));
|
||||
f(&mut iter)
|
||||
for (_, record) in state.txs.records() {
|
||||
for bin in record.output_bins.iter() {
|
||||
hist.increment(bin as usize);
|
||||
}
|
||||
}
|
||||
hist
|
||||
}
|
||||
|
||||
/// Effective fee rate for a live tx: snapshot's linearized chunk
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
pub(crate) mod addr_tracker;
|
||||
pub(crate) mod outpoint_spends;
|
||||
pub(crate) mod output_bins;
|
||||
pub(crate) mod tx_graveyard;
|
||||
pub(crate) mod tx_store;
|
||||
|
||||
pub(crate) use addr_tracker::AddrTracker;
|
||||
pub(crate) use outpoint_spends::OutpointSpends;
|
||||
pub(crate) use output_bins::OutputBins;
|
||||
pub(crate) use tx_graveyard::{TxGraveyard, TxTombstone};
|
||||
pub(crate) use tx_store::TxStore;
|
||||
|
||||
23
crates/brk_mempool/src/stores/output_bins.rs
Normal file
23
crates/brk_mempool/src/stores/output_bins.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use crate::TxEntry;
|
||||
use crate::{TxEntry, stores::OutputBins};
|
||||
|
||||
const RECENT_CAP: usize = 10;
|
||||
|
||||
/// Per-tx record: live tx body and its mempool entry, kept under one
|
||||
/// key so a single map probe returns both.
|
||||
/// Per-tx record: live tx body, its mempool entry, and the pre-bucketed
|
||||
/// oracle bins for its outputs. Kept under one key so a single map probe
|
||||
/// returns everything readers need.
|
||||
pub struct TxRecord {
|
||||
pub tx: Transaction,
|
||||
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
|
||||
@@ -63,10 +76,6 @@ impl TxStore {
|
||||
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) {
|
||||
let prefix = entry.txid_prefix();
|
||||
debug_assert!(
|
||||
@@ -77,7 +86,7 @@ impl TxStore {
|
||||
if tx.input.iter().any(|i| i.prevout.is_none()) {
|
||||
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) {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
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 vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
@@ -159,7 +159,7 @@ fn main() {
|
||||
let ref_config = Config::default();
|
||||
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 next_ft = first_tx_index
|
||||
.get(h + 1)
|
||||
@@ -187,10 +187,6 @@ fn main() {
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
|
||||
if h < earliest_start {
|
||||
continue;
|
||||
}
|
||||
|
||||
let values: Vec<Sats> = indexer
|
||||
.vecs
|
||||
.outputs
|
||||
@@ -203,8 +199,8 @@ fn main() {
|
||||
.collect_range_at(out_start, out_end);
|
||||
|
||||
// Build full histogram and per-digit histograms.
|
||||
let mut full_hist = [0u32; NUM_BINS];
|
||||
let mut digit_hist = [[0u32; NUM_BINS]; 9];
|
||||
let mut full_hist = Histogram::zeros();
|
||||
let mut digit_hist: [Histogram; 9] = std::array::from_fn(|_| Histogram::zeros());
|
||||
|
||||
for (sats, output_type) in values.into_iter().zip(output_types) {
|
||||
if ref_config.excluded_output_types.contains(&output_type) {
|
||||
@@ -214,11 +210,11 @@ fn main() {
|
||||
continue;
|
||||
}
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
full_hist[bin] += 1;
|
||||
full_hist.increment(bin);
|
||||
if is_round(*sats) {
|
||||
let d = leading_digit(*sats);
|
||||
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.
|
||||
for (mi, &(mask, _)) in masks.iter().enumerate() {
|
||||
// Build filtered histogram for this mask.
|
||||
let mut hist = full_hist;
|
||||
let mut hist = full_hist.clone();
|
||||
(0..9usize).for_each(|d| {
|
||||
if mask & (1 << d) != 0 {
|
||||
for bin in 0..NUM_BINS {
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
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 vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
@@ -52,8 +54,6 @@ fn main() {
|
||||
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 ref_config = Config::default();
|
||||
|
||||
// Reference oracle at 575k.
|
||||
let ref_start = START_HEIGHT;
|
||||
let mut ref_oracle = Oracle::new(seed_bin(ref_start), Config::default());
|
||||
@@ -112,18 +112,10 @@ fn main() {
|
||||
.output_type
|
||||
.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) {
|
||||
if ref_config.excluded_output_types.contains(&output_type) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
if let Some(bin) = default_eligible_bin(sats, output_type) {
|
||||
hist.increment(bin as usize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
fn price_seed_bin(start_height: usize) -> f64 {
|
||||
fn seed_bin(start_height: usize) -> f64 {
|
||||
let price: f64 = PRICES
|
||||
.lines()
|
||||
.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`.
|
||||
fn clamp_top_n(src: &[u32; NUM_BINS], dst: &mut [u32; NUM_BINS], n: usize) {
|
||||
// Find the (n+1)th largest value.
|
||||
// Collect non-zero counts, sort descending, take the (n+1)th.
|
||||
fn clamp_top_n(src: &Histogram, dst: &mut Histogram, n: usize) {
|
||||
let mut top: Vec<u32> = src.iter().copied().filter(|&v| v > 0).collect();
|
||||
top.sort_unstable_by(|a, b| b.cmp(a));
|
||||
let clamp_to = if top.len() > n { top[n] } else { 0 };
|
||||
@@ -102,7 +100,7 @@ fn main() {
|
||||
let total_blocks = total_heights - lowest;
|
||||
|
||||
struct BlockData {
|
||||
hist: Box<[u32; NUM_BINS]>,
|
||||
hist: Histogram,
|
||||
high_bin: f64,
|
||||
low_bin: f64,
|
||||
}
|
||||
@@ -144,21 +142,12 @@ fn main() {
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
|
||||
let mut hist = Box::new([0u32; NUM_BINS]);
|
||||
let mut hist = Histogram::zeros();
|
||||
for i in out_start..out_end {
|
||||
let sats: Sats = value_reader.get(i);
|
||||
let output_type = output_type_reader.get(i);
|
||||
if config.excluded_output_types.contains(&output_type) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
if let Some(bin) = default_eligible_bin(sats, output_type) {
|
||||
hist.increment(bin as usize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +195,7 @@ fn main() {
|
||||
println!("{}", "-".repeat(72));
|
||||
|
||||
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 mut worst_err: f64 = 0.0;
|
||||
@@ -217,7 +206,7 @@ fn main() {
|
||||
let mut total_sq_err: f64 = 0.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() {
|
||||
if clamp_n > 0 {
|
||||
clamp_top_n(&bd.hist, &mut clamped_hist, clamp_n);
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::path::PathBuf;
|
||||
|
||||
use brk_indexer::Indexer;
|
||||
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 vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
@@ -188,8 +189,6 @@ fn main() {
|
||||
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 ref_config = Config::default();
|
||||
|
||||
let mut year_stats: Vec<YearStats> = Vec::new();
|
||||
let mut overall = YearStats::new(0);
|
||||
let mut worst_blocks: Vec<BlockError> = Vec::new();
|
||||
@@ -238,18 +237,10 @@ fn main() {
|
||||
.output_type
|
||||
.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) {
|
||||
if ref_config.excluded_output_types.contains(&output_type) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
if let Some(bin) = default_eligible_bin(sats, output_type) {
|
||||
hist.increment(bin as usize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
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 vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
@@ -117,7 +117,7 @@ impl Stats {
|
||||
}
|
||||
|
||||
struct BlockData {
|
||||
full_hist: Box<[u32; NUM_BINS]>,
|
||||
full_hist: Histogram,
|
||||
/// (bin_index, leading_digit) for outputs that are round values.
|
||||
round_outputs: Vec<(u16, u8)>,
|
||||
high_bin: f64,
|
||||
@@ -173,7 +173,7 @@ fn main() {
|
||||
let total_blocks = total_heights - sweep_start;
|
||||
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 next_ft = first_tx_index
|
||||
.get(h + 1)
|
||||
@@ -201,10 +201,6 @@ fn main() {
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
|
||||
if h < sweep_start {
|
||||
continue;
|
||||
}
|
||||
|
||||
let values: Vec<Sats> = indexer
|
||||
.vecs
|
||||
.outputs
|
||||
@@ -216,7 +212,7 @@ fn main() {
|
||||
.output_type
|
||||
.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();
|
||||
|
||||
for (sats, output_type) in values.into_iter().zip(output_types) {
|
||||
@@ -227,7 +223,7 @@ fn main() {
|
||||
continue;
|
||||
}
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
full_hist[bin] += 1;
|
||||
full_hist.increment(bin);
|
||||
if is_round(*sats) {
|
||||
let d = leading_digit(*sats);
|
||||
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();
|
||||
eprintln!(
|
||||
"\r {} blocks precomputed ({:.1} GB hists + {:.0} MB rounds) in {:.1}s",
|
||||
@@ -308,7 +304,7 @@ fn main() {
|
||||
let mut stats = Stats::new();
|
||||
|
||||
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 {
|
||||
if mask & (1 << (digit - 1)) != 0 {
|
||||
hist[bin as usize] -= 1;
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
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 vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
@@ -114,7 +114,7 @@ struct RoundOutput {
|
||||
}
|
||||
|
||||
struct BlockData {
|
||||
full_hist: Box<[u32; NUM_BINS]>,
|
||||
full_hist: Histogram,
|
||||
round_outputs: Vec<RoundOutput>,
|
||||
high_bin: f64,
|
||||
low_bin: f64,
|
||||
@@ -175,7 +175,7 @@ fn main() {
|
||||
// Outputs beyond 5% relative error will never be filtered at any tolerance.
|
||||
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 next_ft = first_tx_index
|
||||
.get(h + 1)
|
||||
@@ -203,10 +203,6 @@ fn main() {
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
|
||||
if h < sweep_start {
|
||||
continue;
|
||||
}
|
||||
|
||||
let values: Vec<Sats> = indexer
|
||||
.vecs
|
||||
.outputs
|
||||
@@ -218,7 +214,7 @@ fn main() {
|
||||
.output_type
|
||||
.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();
|
||||
|
||||
for (sats, output_type) in values.into_iter().zip(output_types) {
|
||||
@@ -229,7 +225,7 @@ fn main() {
|
||||
continue;
|
||||
}
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
full_hist[bin] += 1;
|
||||
full_hist.increment(bin);
|
||||
let d = leading_digit(*sats);
|
||||
if (1..=9).contains(&d) {
|
||||
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
|
||||
.iter()
|
||||
.map(|b| b.round_outputs.len() * std::mem::size_of::<RoundOutput>())
|
||||
@@ -350,7 +346,7 @@ fn main() {
|
||||
let mut stats = Stats::new();
|
||||
|
||||
for bd in blocks.iter() {
|
||||
let mut hist = *bd.full_hist;
|
||||
let mut hist = bd.full_hist.clone();
|
||||
|
||||
// Remove outputs matching this tolerance + mask.
|
||||
let tol_f32 = tolerance as f32;
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
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 vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
@@ -155,8 +157,6 @@ fn main() {
|
||||
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 ref_config = Config::default();
|
||||
|
||||
for h in START_HEIGHT..total_heights {
|
||||
let ft = first_tx_index[h];
|
||||
let next_ft = first_tx_index
|
||||
@@ -197,18 +197,10 @@ fn main() {
|
||||
.output_type
|
||||
.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) {
|
||||
if ref_config.excluded_output_types.contains(&output_type) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
if let Some(bin) = default_eligible_bin(sats, output_type) {
|
||||
hist.increment(bin as usize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
crates/brk_oracle/src/config.rs
Normal file
40
crates/brk_oracle/src/config.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
41
crates/brk_oracle/src/histogram.rs
Normal file
41
crates/brk_oracle/src/histogram.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,14 @@
|
||||
//! Detects round-dollar transaction patterns ($1, $5, $10, ... $10,000) in Bitcoin
|
||||
//! 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.
|
||||
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.
|
||||
/// 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).
|
||||
@@ -140,40 +174,9 @@ fn find_best_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)]
|
||||
pub struct Oracle {
|
||||
histograms: Vec<[u32; NUM_BINS]>,
|
||||
histograms: Vec<Histogram>,
|
||||
ema: Box<[f64; NUM_BINS]>,
|
||||
cursor: usize,
|
||||
filled: usize,
|
||||
@@ -196,7 +199,7 @@ impl Oracle {
|
||||
.iter()
|
||||
.fold(0u16, |mask, ot| mask | (1 << *ot as u8));
|
||||
Self {
|
||||
histograms: vec![[0u32; NUM_BINS]; window_size],
|
||||
histograms: vec![Histogram::zeros(); window_size],
|
||||
ema: Box::new([0.0; NUM_BINS]),
|
||||
cursor: 0,
|
||||
filled: 0,
|
||||
@@ -208,81 +211,21 @@ impl Oracle {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_block(&mut self, block: &Block) -> f64 {
|
||||
self.process_outputs(
|
||||
block
|
||||
.txdata
|
||||
.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.
|
||||
/// Create an oracle restored from a known price. `fill` should call
|
||||
/// `process_histogram` for the warmup blocks; during warmup the ring
|
||||
/// fills without recomputing EMA or searching, then we recompute once
|
||||
/// at the end so the first non-warmup call has a primed EMA.
|
||||
pub fn from_checkpoint(ref_bin: f64, config: Config, fill: impl FnOnce(&mut Self)) -> Self {
|
||||
let mut oracle = Self::new(ref_bin, config);
|
||||
oracle.warmup = true;
|
||||
fill(&mut oracle);
|
||||
oracle.warmup = false;
|
||||
oracle.recompute_ema();
|
||||
oracle.ref_bin = ref_bin;
|
||||
oracle
|
||||
}
|
||||
|
||||
pub fn process_histogram(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
|
||||
self.ingest(hist)
|
||||
}
|
||||
|
||||
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;
|
||||
pub fn process_histogram(&mut self, hist: &Histogram) -> f64 {
|
||||
self.histograms[self.cursor] = hist.clone();
|
||||
self.cursor = (self.cursor + 1) % self.config.window_size;
|
||||
if self.filled < self.config.window_size {
|
||||
self.filled += 1;
|
||||
@@ -301,6 +244,35 @@ impl Oracle {
|
||||
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) {
|
||||
self.ema.fill(0.0);
|
||||
for age in 0..self.filled {
|
||||
|
||||
@@ -17,6 +17,7 @@ brk_computer = { workspace = true }
|
||||
brk_error = { workspace = true, features = ["jiff", "vecdb"] }
|
||||
brk_indexer = { workspace = true }
|
||||
brk_mempool = { workspace = true }
|
||||
brk_oracle = { workspace = true }
|
||||
brk_reader = { workspace = true }
|
||||
brk_rpc = { workspace = true }
|
||||
brk_traversable = { workspace = true }
|
||||
|
||||
@@ -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(
|
||||
&self,
|
||||
addr: &Addr,
|
||||
@@ -236,8 +217,15 @@ impl Query {
|
||||
Ok(mempool.addr_txs(&bytes, limit))
|
||||
}
|
||||
|
||||
/// Height of the last on-chain activity for an address (last tx_index → height).
|
||||
pub fn addr_last_activity_height(&self, addr: &Addr) -> Result<Height> {
|
||||
/// Height of the last on-chain activity for an address (last tx_index to 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 store = self
|
||||
.indexer()
|
||||
@@ -246,12 +234,25 @@ impl Query {
|
||||
.get(output_type)
|
||||
.data()?;
|
||||
let tx_index_len = self.safe_lengths().tx_index;
|
||||
let last_tx_index = store
|
||||
.prefix(type_index)
|
||||
.rev()
|
||||
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
|
||||
.find(|tx_index| *tx_index < tx_index_len)
|
||||
.ok_or(Error::UnknownAddr)?;
|
||||
let last_tx_index = match before_txid {
|
||||
Some(txid) => {
|
||||
let before_tx_index = self.resolve_tx_index(txid)?;
|
||||
let min = AddrIndexTxIndex::min_for_addr(type_index);
|
||||
let cursor = AddrIndexTxIndex::from((type_index, before_tx_index));
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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::{
|
||||
Dollars, ExchangeRates, HistoricalPrice, HistoricalPriceEntry, Hour4, INDEX_EPOCH, Timestamp,
|
||||
};
|
||||
use vecdb::ReadableVec;
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
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() {
|
||||
mempool.process_live_outputs(|iter| oracle.process_outputs(iter));
|
||||
/// Oracle warmed by the last `window_size` committed blocks, seeded from
|
||||
/// 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> {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![allow(clippy::module_inception)]
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use brk_computer::Computer;
|
||||
use brk_error::{OptionData, Result};
|
||||
use brk_indexer::{Indexer, Lengths};
|
||||
use brk_mempool::Mempool;
|
||||
use brk_oracle::Oracle;
|
||||
use brk_reader::Reader;
|
||||
use brk_rpc::Client;
|
||||
use brk_types::{BlockHash, BlockHashPrefix, Height, SyncStatus};
|
||||
@@ -32,6 +36,7 @@ struct QueryInner<'a> {
|
||||
indexer: &'a Indexer<Ro>,
|
||||
computer: &'a Computer<Ro>,
|
||||
mempool: Option<Mempool>,
|
||||
live_oracle: RwLock<Option<(Height, Arc<Oracle>)>>,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
@@ -54,6 +59,7 @@ impl Query {
|
||||
indexer,
|
||||
computer,
|
||||
mempool,
|
||||
live_oracle: RwLock::new(None),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@ use crate::{
|
||||
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 {
|
||||
fn add_addr_routes(self) -> Self;
|
||||
}
|
||||
@@ -26,7 +34,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
_: Empty,
|
||||
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
|
||||
}, |op| op
|
||||
.id("get_address")
|
||||
@@ -49,13 +57,24 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
_: Empty,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, false);
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, 50, 50)).await
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, false, None);
|
||||
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
|
||||
.id("get_address_txs")
|
||||
.addrs_tag()
|
||||
.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>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
@@ -72,8 +91,8 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
_: Empty,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, true);
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, None, 25)).await
|
||||
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, CHAIN_PAGE)).await
|
||||
}, |op| op
|
||||
.id("get_address_confirmed_txs")
|
||||
.addrs_tag()
|
||||
@@ -95,8 +114,8 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
_: Empty,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, true);
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, Some(path.after_txid), 25)).await
|
||||
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), CHAIN_PAGE)).await
|
||||
}, |op| op
|
||||
.id("get_address_confirmed_txs_after")
|
||||
.addrs_tag()
|
||||
@@ -119,7 +138,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
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
|
||||
.id("get_address_mempool_txs")
|
||||
.addrs_tag()
|
||||
@@ -141,7 +160,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
_: Empty,
|
||||
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;
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr, max_utxos)).await
|
||||
}, |op| op
|
||||
|
||||
@@ -135,7 +135,8 @@ impl MiningRoutes for ApiRouter<AppState> {
|
||||
"/api/v1/mining/pool/{slug}/blocks",
|
||||
get_with(
|
||||
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.id("get_pool_blocks")
|
||||
|
||||
@@ -29,13 +29,7 @@ impl ServerRoutes for ApiRouter<AppState> {
|
||||
let uptime = state.started_instant.elapsed();
|
||||
let started_at = state.started_at.to_string();
|
||||
let sync = state
|
||||
.run(move |q| {
|
||||
let tip_height = q
|
||||
.client()
|
||||
.get_last_height()
|
||||
.unwrap_or(q.height());
|
||||
q.sync_status(tip_height)
|
||||
})
|
||||
.run(move |q| q.sync_status(q.height()))
|
||||
.await
|
||||
.expect("health sync task panicked");
|
||||
let mut response = axum::Json(Health {
|
||||
@@ -57,7 +51,7 @@ impl ServerRoutes for ApiRouter<AppState> {
|
||||
op.id("get_health")
|
||||
.server_tag()
|
||||
.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>()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -6,13 +6,13 @@ use axum::{
|
||||
};
|
||||
use brk_query::AsyncQuery;
|
||||
use brk_types::{
|
||||
Addr, BlockHash, BlockHashPrefix, Date, Height, ONE_HOUR_IN_SEC, Timestamp as BrkTimestamp,
|
||||
Txid, Version,
|
||||
Addr, BlockHash, BlockHashPrefix, Date, Height, ONE_HOUR_IN_SEC, PoolSlug,
|
||||
Timestamp as BrkTimestamp, Txid, Version,
|
||||
};
|
||||
use derive_more::Deref;
|
||||
use jiff::Timestamp;
|
||||
use serde::Serialize;
|
||||
use vecdb::ReadableVec;
|
||||
use vecdb::{ReadableVec, VecIndex};
|
||||
|
||||
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.
|
||||
/// - Address has mempool txs → `MempoolHash(addr_specific_hash)`
|
||||
/// - No mempool, has on-chain activity → `BlockBound(last_activity_block)`
|
||||
/// - Unknown address → `Tip`
|
||||
pub fn addr_strategy(&self, version: Version, addr: &Addr, chain_only: bool) -> CacheStrategy {
|
||||
/// Smart address caching. Checks mempool activity first (unless `chain_only`), then on-chain.
|
||||
/// - Address has mempool txs: `MempoolHash(addr_specific_hash)`
|
||||
/// - No mempool, has on-chain activity: `BlockBound(last_activity_block)`
|
||||
/// - Unknown address: `Tip`
|
||||
///
|
||||
/// `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| {
|
||||
if !chain_only && let Some(mempool_hash) = q.addr_mempool_hash(addr) {
|
||||
return CacheStrategy::MempoolHash(mempool_hash);
|
||||
}
|
||||
q.addr_last_activity_height(addr)
|
||||
q.addr_last_activity_height(addr, before_txid)
|
||||
.and_then(|h| {
|
||||
let block_hash = q.block_hash_by_height(h)?;
|
||||
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 {
|
||||
let hash = self.sync(|q| q.mempool().map(|m| m.next_block_hash().into()).unwrap_or(0));
|
||||
CacheStrategy::MempoolHash(hash)
|
||||
|
||||
Reference in New Issue
Block a user