Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Per-method notes, auth, and the shindex matrix live in
| scripthash.get_mempool / subscribe | done | Status on mempool announce **and** when SH applies a height that creates or spends the hash (posting-list probe; no Class A expand on a miss). Headers subscribe still live tip. Reorg (`TipNotify.reorg_from_height`) restatuses every watch even if the new block misses the script. Status preimage is the protocol's `txid:height:` for every row: Electrum desktop recomputes it from `get_history` and drops the history on a mismatch. A same-height replace that keeps every `(txid, height)` keeps the status; the `chain_tip` stamp carries that A-B-A. Row **order** is confirmed height-asc then mempool tail, same as `get_history`. A script with no history has status `null` (subscribe reply and pushes), never `""`: wallets read any non-null status as a used address. `unsubscribe` is implemented. |
| transaction.get / get_merkle | done | get falls back to mempool unless `asof:`; confirmed responses stamp `chain_tip`. Verbose get is electrs-shaped: `time`/`blocktime`/`confirmations`/`blockhash` from the confirming header (mempool verbose is `confirmations: 0` with no block stamp), plus `vin`/`vout`/`size`/`version`/`locktime`/`hash` (wtxid) decoded from the hex already loaded. Trailing `asof:<blockhash>` (same dialect as scripthash): get returns the tx only if confirmed at or behind that ancestor (**no** mempool); get_merkle rejects a `height` above the asof pin (`asof not on chain`). `id_from_pos` is a txid string; third arg `merkle=true` returns `{tx_hash, merkle}` (same walk as `get_merkle`). |
| transaction.broadcast | done | Mempool accept + P2P inv. `broadcast_package` wraps `accept_package` (Electrum **1.6**; P2P BIP331 still Q-48). |
| relayfee / estimatefee / histogram / `mempool.get_info` | done | `minrelaytxfee` is the configured minimum. `relayfee` and `mempoolminfee` are the enforced floor. `incrementalrelayfee` stays 100 sat/kvB. Estimates are the 10-minute flow/history product ([`docs/mempool-fee-estimation.md`](./docs/mempool-fee-estimation.md)). `mempool.get_info` is 1.6 (`minrelaytxfee` replaces `relayfee` for 1.6 clients; `relayfee` stays for 1.4). |
| relayfee / estimatefee / histogram / `mempool.get_info` | done | `minrelaytxfee` is the configured minimum. `relayfee` and `mempoolminfee` are the enforced floor. `incrementalrelayfee` stays 100 sat/kvB. Estimates are the 10-minute flow/history product ([`docs/mempool-fee-estimation.md`](./docs/mempool-fee-estimation.md)), at least `mempoolminfee` like `estimatesmartfee`. `mempool.get_info` is 1.6 (`minrelaytxfee` replaces `relayfee` for 1.6 clients; `relayfee` stays for 1.4). |
| outpoint.get_status / subscribe / unsubscribe | done | Electrum **1.7** methods; `protocol_max` stays **1.6** until `scriptpubkey.*`. Spent = confirmed-strong or mempool. |
| silentpayments.subscribe / unsubscribe | done | Frigate remote-scanner: session-only scan key; historical + tip notifies via tweak index / naive `tweaks_for_height`. Not Cake `tweaks.subscribe`. |
| TLS | external | terminate at reverse proxy for **clearnet**; onion is plain TCP (`ADD_ONION` to loopback). In-binary 50002 + TLS is parked **Q-63**. |
Expand Down Expand Up @@ -244,7 +244,7 @@ App `ServeLimits` always on (same model as Electrum).
| Output by position | done | `GET /block-height/:height/tx/:index` is the same JSON as `GET /tx/:txid` for that confirmed tx, including a pruned partial and the genesis coinbase. A missing height or index is **404**. A non-integer segment is **400**. `GET /block-height/:height/tx/:index/out/:vout`, `GET /tx/:txid/out/:vout`, and `POST /block-heights/txs/outs` return one confirmed output: Esplora vout fields plus boolean `spent` and `status` of the creating transaction. `spent: true` is **200** and still includes the script and value (`gettxout` returns null once spent). A mempool spend stays `spent: false` with the script present. Genesis coinbase outputs, a mempool-only tx, a header with no body, and a missing vout are **404** on a GET and JSON `null` in a batch slot. The batch body is `[[height, index, vout], …]` (same length and order, duplicates kept). One bad element is **400** for the whole body. More than 300 is **422** `body too long`. An empty body is **200** `[]`. `?asof=<hash>` on all four pins one best-chain ancestor: tip headers and status name that pin, a height above it is **404** or batch `null`, a later spend stays `spent: false`, and a dead or disconnected hash is **404** `Not Found` for the whole GET or POST (no sibling at that height). `out` is this output. `outspend` is the spender. No `--sh-index`. |
| Address / scripthash | done | stats + `/utxo` + `/txs` + `/txs/mempool` + `/txs/chain[/:last_seen_txid]` + `/txs/summary[/:last_seen_txid]` (dialect; next row). `/utxo` matches Electrum listunspent (mempool funding + drop mempool-spent confirmed); `/txs` and `/txs/mempool` use full Esplora tx JSON for mempool-only rows (wire from the hub). `?after_txid=` on `/txs` and `/txs/summary` skips through that tx (mempool then chain); unknown or unparseable → **422** `after_txid not found`. A mempool-only cursor past the first 50 mempool rows returns the confirmed page. `POST /addresses/txs` and `POST /scripthashes/txs` (and `/txs/summary`) take a JSON array (max **300**; over → **422** `body too long`); unique scripts join once per request and merge newest-first into one page of 25 confirmed rows (mempool rows stay in front on `/txs`, 50 per script). A later page walks past each script's first 25. Unix/loopback `X-Rbitcoin-Client`: last-1 GET join + last-bulk POST (16 MiB packed/client), 30s idle, 256 clients, singleflight on the same `(client, sh)`. Public TCP ignores the header (stack-local join). Concurrent different GET scripts replace last-1. Unbounded process LRU stays **X-M3**. Needs SH finalize. Stamp is visible SH (durable + pending write-behind), matching live tip while jobs sit in RAM. `?asof=<hash>` on `/`, `/utxo`, `/txs`, `/txs/chain`, `/txs/summary`: confirmed join at that ancestor **at or behind visible SH**, **no** mempool; headers are the asof hash; 404 if not on chain or ahead of visible SH. |
| `/txs/summary` | dialect | **Not** in Blockstream Esplora [`API.md`](https://github.com/Blockstream/esplora/blob/master/API.md). Compact `{txid, value, height, time, tx_position}` like mempool.space `/address/:addr/txs/summary`. Confirmed only (25/page, newest first, including a multi-script post of that same merged page); path cursor `/:last_seen_txid` and mempool.space `?after_txid=` (unknown → **422**). `value` is net sats for that script in that tx (funded − spent). `time` is the confirming header timestamp (`0` if the header is missing). `tx_position` is that tx's index in the block. Mempool rows stay on `/txs` and `/txs/mempool`. A page is served above `--max-sh-creates`; unpaged stats and `/utxo` still **503**. |
| Mempool / fees | done | `/mempool`, `/mempool/txids`, `/mempool/txids/page[/:last]`, `/mempool/recent` (accept-order ring), `/fee-estimates` (sat/vB at the estimator's 1 sat/kvB resolution; a target without an estimate is left out; when none has one, mainnet is **503** `fee estimates unavailable` and every other network is **200** `{}`). mempool.space `/api/v1/fees/recommended` is its backend's surface, not served here (**404**) |
| Mempool / fees | done | `/mempool`, `/mempool/txids`, `/mempool/txids/page[/:last]`, `/mempool/recent` (accept-order ring), `/fee-estimates` (sat/vB at the estimator's 1 sat/kvB resolution, each at least `mempoolminfee`; a target without an estimate is left out; when none has one, mainnet is **503** `fee estimates unavailable` and every other network is **200** `{}`). mempool.space `/api/v1/fees/recommended` is its backend's surface, not served here (**404**) |
| `POST /tx` | done | broadcast via mempool hub; **503** if hub absent. `GET /broadcast` is not served (**404**). |
| `POST /txs/test` | done | JSON array of hex (max 25) → `MempoolHub::test_accept` (no admit). `?maxfeerate=` is BTC/kvB like electrs (`0` unlimited; omitted → 0.1 BTC/kvB). |
| `POST /txs/package` | done | JSON array of hex txs → `accept_package`; **503** without hub; max 25 txs |
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/fee-quote-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fixed

- `estimatesmartfee` and `estimaterawfee` read `conf_target` as Core's 32-bit integer. `4294967297` was cut to 32 bits and answered as target 1; a value outside the int range, or a fraction, is now `-1` `JSON integer out of range`, and `-1` is `-8` like `0` and `1009`.
- When live arrivals priced above 1000 sat/vB overfilled the next blocks at every rate the flow estimator prices, a pool holding less than those blocks quoted the 0.1 sat/vB minimum relay rate for the 1- to 5-block targets and capped every longer target at it. Those targets now come from block history, and have no estimate when history has none.
- Electrum `blockchain.estimatefee`, Esplora `/fee-estimates`, and `estimaterawfee` are at least `mempoolminfee`, like `estimatesmartfee`. They were floored at 0.1 sat/vB only, so with a raised `-minrelaytxfee` or a full pool they could quote a rate this node rejects. A raised floor applies to the next answer, even while the estimate table is being rebuilt.
54 changes: 14 additions & 40 deletions crates/rbitcoin-mempool/src/fee_est.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub const SECONDS_PER_BLOCK: u64 = 600;

/// Inclusion confidence at N=1 (10-minute default). Higher → higher sat/vB.
pub const CONFIDENCE_NEAR: f64 = 0.999;
/// Inclusion confidence for historical/blended targets from N=2 onward.
/// Flow capacity confidence from N=2 onward (history uses its own quantile).
pub const CONFIDENCE_FAR: f64 = 0.99;
/// Fraction of `N×4e6` WU to fill at `CONFIDENCE_NEAR` (leave shock room).
pub const FILL_AT_NEAR: f64 = 0.80;
Expand Down Expand Up @@ -164,8 +164,8 @@ pub fn first_bucket_above(rate_sat_per_kvb: u64) -> usize {
/// `stock_above(R) + projected_in(R, H) ≤ effective_capacity(N)`.
///
/// `stock_above` is a callback (live mining-chunk weight strictly above R).
/// `candidate_rates` is searched ascending (bucket edges + optional extras).
/// Returns `None` if even the highest candidate cannot fit (caller applies floors).
/// `None` when no candidate fits: demand priced above the ladder is not a
/// rate this projection can quote.
pub fn min_rate_for_capacity<F>(
stock_above: F,
inflow_wu_per_s_by_bucket: &[u64],
Expand All @@ -179,19 +179,12 @@ where
let h = inflow_horizon_secs(n_blocks);
let lam = lambda_mult(inclusion_confidence(n_blocks));
let suffix = inflow_suffix_wu(inflow_wu_per_s_by_bucket, h);
let mut best: Option<u64> = None;
for &r in candidate_rates {
let fits = |r: u64| {
let idx = first_bucket_above(r).min(suffix.len().saturating_sub(1));
let stressed = (suffix[idx] as f64 * lam).round() as u64;
let load = stock_above(r).saturating_add(stressed);
if load <= cap {
best = Some(match best {
Some(b) => b.min(r),
None => r,
});
}
}
best
stock_above(r).saturating_add(stressed) <= cap
};
candidate_rates.iter().copied().filter(|&r| fits(r)).min()
}

/// Default candidate ladder: bucket edges.
Expand All @@ -208,7 +201,7 @@ pub fn fine_candidate_rates() -> Vec<u64> {
pub const INFLOW_HORIZON_CAP_SECS: u64 = 600;
/// Blend length in blocks (~1 hour). `w(1)=1`, `w(6)≈0.43`, `w(144)≈0`.
pub const BLEND_N0: f64 = 6.0;
/// Under-full pool may still answer min-relay when `blend_weight ≥` this (N=1–5).
/// A pool thinner than N blocks still quotes flow when `blend_weight ≥` this (N=1–5).
pub const NEAR_BLEND_FLOOR: f64 = 0.5;

/// Horizon used for inflow projection (capped; not N×10 minutes for N=144).
Expand All @@ -224,32 +217,14 @@ pub fn blend_weight(n_blocks: u32) -> f64 {

/// Combine projected inflow with the N-block frontier.
///
/// If the pool is thinner than N blocks, live stock does not set a far rate
/// (`w < NEAR_BLEND_FLOOR`). Near depths with any live stock still answer
/// min-relay (everything fits now).
pub fn flow_for_depth(
projected: Option<u64>,
frontier: Option<u64>,
has_live_stock: bool,
n_blocks: u32,
min_relay: u64,
) -> Option<u64> {
let mut flow = match (projected, frontier) {
/// If the pool is thinner than N blocks, a far depth (`w < NEAR_BLEND_FLOOR`)
/// has no flow rate and a near depth keeps the projected rate.
pub fn flow_for_depth(projected: Option<u64>, frontier: Option<u64>, n_blocks: u32) -> Option<u64> {
match (projected, frontier) {
(Some(p), Some(f)) => Some(p.max(f)),
(Some(p), None) => Some(p),
(None, Some(f)) => Some(f),
(None, None) => None,
};
if frontier.is_none() {
if blend_weight(n_blocks) >= NEAR_BLEND_FLOOR {
if flow.is_none() && has_live_stock {
flow = Some(min_relay);
}
} else {
flow = None;
}
(projected, None) => projected.filter(|_| blend_weight(n_blocks) >= NEAR_BLEND_FLOOR),
}
flow
}

/// `pct` in 0..=100. Empty → None.
Expand Down Expand Up @@ -317,9 +292,8 @@ pub fn warmup_blend(alpha: f64, cold: Option<u64>, warm: Option<u64>) -> Option<
};
match (cold, warm) {
(Some(c), Some(w)) => Some(((1.0 - alpha) * c as f64 + alpha * w as f64).round() as u64),
(Some(c), None) => (alpha < 1.0).then_some(c),
(None, Some(w)) => (alpha >= 1.0).then_some(w),
(None, None) => None,
(cold, None) => cold,
}
}

Expand Down
30 changes: 9 additions & 21 deletions crates/rbitcoin-mempool/tests/fee_est.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ fn warmup_blend_is_history_at_zero_and_flow_at_full() {
assert_eq!(warmup_blend(0.5, Some(1_000), Some(3_000)), Some(2_000));
assert_eq!(warmup_blend(0.0, Some(4_000), None), Some(4_000));
assert_eq!(warmup_blend(0.5, Some(4_000), None), Some(4_000));
assert_eq!(warmup_blend(1.0, Some(4_000), None), None);
assert_eq!(warmup_blend(1.0, Some(4_000), None), Some(4_000));
assert_eq!(warmup_blend(0.0, None, Some(8_000)), None);
assert_eq!(warmup_blend(0.25, None, Some(8_000)), None);
assert_eq!(warmup_blend(1.0, None, Some(8_000)), Some(8_000));
Expand Down Expand Up @@ -426,26 +426,14 @@ fn inflow_horizon_is_capped() {
}

#[test]
fn underfull_live_stock_defines_near_not_far() {
fn underfull_pool_defines_near_not_far() {
assert!(blend_weight(5) >= NEAR_BLEND_FLOOR);
assert!(blend_weight(6) < NEAR_BLEND_FLOOR);
let min_r = 100u64;
assert_eq!(flow_for_depth(None, None, true, 1, min_r), Some(min_r));
assert_eq!(flow_for_depth(None, None, true, 5, min_r), Some(min_r));
assert_eq!(flow_for_depth(None, None, true, 6, min_r), None);
assert_eq!(flow_for_depth(None, None, true, 144, min_r), None);
assert_eq!(flow_for_depth(None, None, false, 1, min_r), None);
assert_eq!(
flow_for_depth(Some(5_000), None, true, 1, min_r),
Some(5_000)
);
assert_eq!(flow_for_depth(Some(5_000), None, true, 144, min_r), None);
assert_eq!(
flow_for_depth(Some(4_000), Some(3_000), true, 1, min_r),
Some(4_000)
);
assert_eq!(
flow_for_depth(None, Some(3_000), true, 144, min_r),
Some(3_000)
);
assert_eq!(flow_for_depth(Some(5_000), None, 1), Some(5_000));
assert_eq!(flow_for_depth(Some(5_000), None, 5), Some(5_000));
assert_eq!(flow_for_depth(Some(5_000), None, 6), None);
assert_eq!(flow_for_depth(Some(5_000), None, 144), None);
assert_eq!(flow_for_depth(None, None, 1), None);
assert_eq!(flow_for_depth(Some(4_000), Some(3_000), 1), Some(4_000));
assert_eq!(flow_for_depth(None, Some(3_000), 144), Some(3_000));
}
28 changes: 17 additions & 11 deletions crates/rbitcoin-net/src/tx_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ pub fn persist_unbroadcast_file(dir: &Path, set: &HashSet<Txid>) {
}

/// Depths the refresh evaluates. Every other confirm target is
/// [`fee_at_target_sat_kvb`] on this table (after 0–2 → default map).
/// [`fee_at_target_sat_kvb`] on this table (after target 0 → default map).
#[doc(hidden)]
pub const FEE_SNAPSHOT_DEPTHS: &[u32] = &[1, 2, 3, 4, 5, 6, 10, 20, 144, 504, 1008];
/// Txstat body bytes scanned and retained for historical fee estimates.
Expand Down Expand Up @@ -242,10 +242,11 @@ impl FeeSnapshot {
}
}

/// `target`'s rate, at least `floor_sat_kvb`, in BTC/kvB; `-1.0` without one.
#[doc(hidden)]
pub fn rate_btc_per_kb(&self, target: u32) -> f64 {
pub fn rate_btc_per_kb(&self, target: u32, floor_sat_kvb: u64) -> f64 {
match fee_at_target_sat_kvb(FEE_SNAPSHOT_DEPTHS, &self.depth_rates_sat_kvb, target) {
Some(sat) => sat as f64 / 100_000_000.0,
Some(sat) => sat.max(floor_sat_kvb) as f64 / 100_000_000.0,
None => -1.0,
}
}
Expand Down Expand Up @@ -741,7 +742,7 @@ pub struct MempoolHub {
pub recent_confirmed: Mutex<RecentConfirmed>,
/// Core `m_lazy_recent_rejects` (wtxid). Forcerelay second-send skips ATMP.
pub recent_rejects: Mutex<HashSet<Wtxid>>,
/// Recently confirmed package feerates (sat/kvB) for N=1 sanity clip.
/// Own feerates (sat/kvB) of the 64 newest confirmed entries, for the N=1 floor.
pub confirm_feerate_memory: Mutex<std::collections::VecDeque<u64>>,
/// Per-block vsize-weighted p10 hurdle, bounded by txstat body bytes.
pub block_p10_history: Mutex<FeeHistory>,
Expand Down Expand Up @@ -3076,7 +3077,7 @@ impl MempoolHub {
let projected = inflow
.as_ref()
.and_then(|inf| min_rate_for_capacity(|r| stock.above(r), inf, depth, &candidates));
let flow = flow_for_depth(projected, frontier, !chunks.is_empty(), depth, min_r);
let flow = flow_for_depth(projected, frontier, depth);
let hist = history.get(&depth).copied().flatten();
let rate = published_depth_rate(depth, alpha, flow, frontier, hist, confirm_floor);
ordered.push((depth, rate.map(|r| r.max(min_r))));
Expand Down Expand Up @@ -4669,17 +4670,20 @@ impl MempoolHub {

/// Standard / target-depth fee in BTC/kB (Engine v2 when flow meter warm).
///
/// **Default product answer is 10-minute inclusion** (`target_blocks` 0–2 →
/// **Default product answer is 10-minute inclusion** (`target_blocks` 0 →
/// depth of [`Self::DEFAULT_HORIZON_BLOCKS`] blocks).
///
/// **Non-blocking vs accept:** returns from a **published snapshot** (Arc).
/// Graph linearize runs only on dirty/stale singleflight **refresh** (≤~1 s
/// stale; one `mining_chunks` per refresh for all depths). Request path does
/// not hold the hub lock across multi-pass walks.
/// not hold the hub lock across multi-pass walks. The answer is at least
/// the live `mempoolminfee`, read under a short read lock.
pub fn estimate_fee_btc_per_kb(&self, target_blocks: u32) -> f64 {
self.maybe_refresh_fee_snapshot();
let depth = Self::fee_depth(target_blocks);
self.fee_snapshot.load().rate_btc_per_kb(depth)
self.fee_snapshot
.load()
.rate_btc_per_kb(depth, self.mempool_min_fee_sat_kvb())
}

/// Esplora `/fee-estimates` targets in one Arc load (+ optional refresh):
Expand All @@ -4688,10 +4692,11 @@ impl MempoolHub {
pub fn fee_estimates_btc_per_kb(&self) -> Vec<(u32, f64)> {
self.maybe_refresh_fee_snapshot();
let snap = self.fee_snapshot.load_full();
let floor = self.mempool_min_fee_sat_kvb();
(1..=25)
.chain([144, 504, 1008])
.filter_map(|d| {
let rate = snap.rate_btc_per_kb(d);
let rate = snap.rate_btc_per_kb(d, floor);
(rate >= 0.0).then_some((d, rate))
})
.collect()
Expand All @@ -4708,8 +4713,9 @@ impl MempoolHub {
rbitcoin_consensus::policy::MIN_RELAY_FEE_RATE_SAT_PER_KVB as f64 / 100_000_000.0
}

/// Ring of recently confirmed package feerates (sat/kvB), newest last.
/// Filled from `remove_for_block` when live entries leave the pool.
/// p90 of the 64 newest confirmed entries' own feerates (sat/kvB), at
/// least min relay. Filled in block order from `remove_for_block` when
/// live entries leave the pool.
#[doc(hidden)]
pub fn confirm_memory_floor_sat_per_kvb(&self) -> Option<u64> {
let mem = self.confirm_feerate_memory.lock().unwrap();
Expand Down
Loading
Loading