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
2 changes: 1 addition & 1 deletion COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface.
| WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 |
| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23` | Core new/tried buckets (~80k); same 1000 / 23% |
| Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire |
| Pruning / GUI | Not supported | Supported |
| Pruning / GUI | inwit watermark / `NETWORK_LIMITED` (`--prune-inwit`) plus RAM window cap (`--prune-inwit-ram-threshold-bytes`) and recent witness spill segments under `store/inwit.window/`; not Core `-prune` of headers/txout | Supported |
| Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack |
| Wallets | Electrum clients (requires `--shindex`) | Descriptor + legacy |
| Scripthash index | Optional (`--shindex`, default **off**); bulk at tip when on | External ElectrumX / Fulcrum; Core `-txindex` is different (txid→block) |
Expand Down
2 changes: 2 additions & 0 deletions OPERATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,8 @@ Clean smoke:
| `--asmap PATH` | `asmap=` | unset — try `{datadir}/ip_asn.dat` if present; else prefix groups |
| `--no-seeds` | `no_seeds=` | seeds on |
| `--sh-index` | `sh_index=` | **off** — Class B scripthash (address/history; Electrum/Esplora start without it) |
| `--prune-inwit` | `prune_inwit=` | **off** — refuse wire reconstruct below tip−288 **heights**, advertise `NETWORK_LIMITED`, and keep recent witness in RAM + rolling spill files |
| `--prune-inwit-ram-threshold-bytes N` | `prune_inwit_ram_threshold_bytes=` | `268435456` (256 MiB) — RAM cap for prune+IBD recent witness window |
| `--max-sh-creates N` | `max_sh_creates=` | **0** — unlimited SH join; `N>0` refuses over-cap Electrum/Esplora (503 / JSON-RPC error) |
| `--sp-tweaks` | `sp_tweaks=` | **off** — thin BIP-352 tweak index (`sp_tweaks.*`) |
| `--sp-tweaks-dust SATS` | `sp_tweaks_dust=` | **1000** — omit served P2TR outs with `value <= SATS` (`0` = serve all; **546** matches Cake electrs) |
Expand Down
2 changes: 2 additions & 0 deletions SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ itself changed.
header.body / header.head # Class A headers + hash index (overflow: header.head.gN)
txout.body / create.loc / create.off / create.loc.ovf # Class A outs (hot loc)
inwit.body / inwit.loc / inwit.off / inwit.loc.ovf # Class A inputs+witness (cold loc)
inwit.prune # optional: u32 LE pruneheight sidecar (`--prune-inwit`; missing = off)
inwit.window/ # optional prune spill: per-height recent witness segments
inwit.reloc # optional: inwit lives under --datadir-cold/store
spent.body # sole-spender 8 B × n_out; leftover spent.off unlinked
tx.body / tx.idx.* # schema ≤14 packed (refused if non-empty)
Expand Down
8 changes: 7 additions & 1 deletion crates/rbitcoin-electrum/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1770,7 +1770,13 @@ fn dispatch_pinned(
true
};
if confirmed_ok {
let raw = query.tx_wire_bytes(fk).map_err(|e| e.to_string())?;
let raw = query.tx_wire_bytes(fk).map_err(|e| {
if matches!(e, StoreError::Pruned { .. }) {
"pruned".to_string()
} else {
e.to_string()
}
})?;
if verbose {
return Ok(verbose_tx_json(query, &raw, &txid, Some(fk), chain.network));
}
Expand Down
53 changes: 53 additions & 0 deletions crates/rbitcoin-esplora/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,7 @@ pub(crate) fn not_found() -> Response {
pub(crate) fn store_err(e: rbitcoin_query::QueryError) -> Response {
match e {
StoreError::NotFound => not_found(),
StoreError::Pruned { .. } => (StatusCode::NOT_FOUND, "pruned").into_response(),
StoreError::Stale(m) => (StatusCode::SERVICE_UNAVAILABLE, m).into_response(),
StoreError::Rejected(m) => (StatusCode::SERVICE_UNAVAILABLE, m).into_response(),
other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()).into_response(),
Expand Down Expand Up @@ -3813,6 +3814,58 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn pruned_tx_json_is_partial_but_raw_stays_404() {
let (dir, q) = temp_query("pruned-partial-json");
let mut prev = Fk::NULL;
let mut parent_hash: Option<[u8; 32]> = None;
let mut txids = Vec::new();
let mut hashes = Vec::new();
for h in 0..300u32 {
let (header, ta) = coinbase(h, prev, parent_hash);
parent_hash = Some(header.hash);
txids.push(ta.tx.txid);
hashes.push(header.hash);
prev = q.connect_block(Height(h), &header, &[ta]).unwrap();
}
q.set_prune_inwit(true).unwrap();
q.apply_prune_inwit_tip().unwrap();
let q = Arc::new(q);
let cfg = EsploraConfig::with_network("127.0.0.1:0".parse().unwrap(), Network::Regtest);
let handle = run_esplora(cfg, Arc::clone(&q), None, None)
.await
.expect("listen");
let addr = handle.local_addr;

let tx0 = block_hash_hex(&txids[0]);
let (st, body) = http_get(addr, &format!("/tx/{tx0}")).await;
assert_eq!(st, 200, "{body}");
let row: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(row["txid"], tx0);
assert_eq!(row["pruned"], true);
assert!(row.get("vin").is_none());
assert!(row.get("vout").is_some());

let (st, body) = http_get(addr, &format!("/tx/{tx0}/raw")).await;
assert_eq!(st, 404, "{body}");
assert!(body.contains("pruned"), "{body}");

let h0 = block_hash_hex(&hashes[0]);
let (st, body) = http_get(addr, &format!("/block/{h0}/txs")).await;
assert_eq!(st, 200, "{body}");
let arr: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(
arr.as_array()
.unwrap()
.iter()
.any(|t| t["txid"] == tx0 && t["pruned"] == true),
"{body}"
);

handle.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn after_txid_skips_and_unknown_is_422() {
use rbitcoin_store::script_hash;
Expand Down
27 changes: 26 additions & 1 deletion crates/rbitcoin-esplora/src/tx_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,13 @@ pub fn history_items_to_tx_json(

/// Full `GET /tx/:txid` body (Esplora API.md transaction format).
pub fn build_tx_json(query: &Query, tx_fk: Fk, network: Network) -> Result<Value, QueryError> {
let wire = query.reconstruct_tx(tx_fk)?;
let wire = match query.reconstruct_tx(tx_fk) {
Ok(w) => w,
Err(rbitcoin_store::StoreError::Pruned { .. }) => {
return build_tx_json_pruned(query, tx_fk, network);
}
Err(e) => return Err(e),
};
let status = tx_status_json(query, tx_fk)?;
let (_meta, stored_inputs, _outs) = query.store().get_tx_full(tx_fk)?;
let stored_txid = query
Expand All @@ -166,6 +172,25 @@ pub fn build_tx_json(query: &Query, tx_fk: Fk, network: Network) -> Result<Value
)
}

fn build_tx_json_pruned(query: &Query, tx_fk: Fk, network: Network) -> Result<Value, QueryError> {
let tx = query.store().get_tx(tx_fk)?;
let (_meta, outs) = query.store().get_tx_meta_and_outputs(tx_fk)?;
let txid = query.store().txs.body_txid(tx_fk).unwrap_or(tx.txid);
let status = tx_status_json(query, tx_fk)?;
let vout: Vec<Value> = outs
.iter()
.map(|o| vout_fields(&o.script, o.value, network))
.collect();
Ok(json!({
"txid": block_hash_hex(&txid),
"version": tx.version,
"locktime": tx.locktime,
"vout": vout,
"status": status,
"pruned": true,
}))
}

/// Esplora tx JSON from a mempool wire body (not in Class A).
pub fn build_tx_json_from_tx(
query: &Query,
Expand Down
9 changes: 9 additions & 0 deletions crates/rbitcoin-net/src/ibd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ pub async fn ibd_cancellable(
cfg: IbdConfig,
cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<u32, NetError> {
struct IbdModeGuard(std::sync::Arc<rbitcoin_query::Query>);
impl Drop for IbdModeGuard {
fn drop(&mut self) {
self.0.set_ibd_mode(false);
}
}
hub.query.set_ibd_mode(true);
let _ibd_mode_guard = IbdModeGuard(Arc::clone(&hub.query));

if peers.is_empty() {
return Err(NetError::Protocol("no peers for ibd"));
}
Expand Down
4 changes: 2 additions & 2 deletions crates/rbitcoin-net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ pub use net_permissions::{
pub use netaddr::{is_cjdns_ip, NetAddr, OnlyNet};
pub use netgroup::netgroup;
pub use peer::{
drain_pending_now, flush_tx_invs, force_announce_txid, local_service_flags, run_feeler_timed,
PendingBlocks, V2PlainSession, MAX_SERVE_BLOCKS,
drain_pending_now, flush_tx_invs, force_announce_txid, local_service_flags,
local_service_flags_pruned, run_feeler_timed, PendingBlocks, V2PlainSession, MAX_SERVE_BLOCKS,
};
pub use peer_dos::DEFAULT_MAX_INBOUND;
pub use peers::{
Expand Down
16 changes: 14 additions & 2 deletions crates/rbitcoin-net/src/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,16 @@

/// Services we advertise once store-backed reconstruct serve is available.
pub fn local_service_flags() -> ServiceFlags {
crate::seeds::required_seed_services()
local_service_flags_pruned(false)
}

/// BIP159: a pruned node offers `NETWORK_LIMITED`, not `NETWORK`.
pub fn local_service_flags_pruned(pruned: bool) -> ServiceFlags {
if pruned {
ServiceFlags::NETWORK_LIMITED | ServiceFlags::WITNESS | ServiceFlags::P2P_V2

Check warning on line 217 in crates/rbitcoin-net/src/peer.rs

View workflow job for this annotation

GitHub Actions / mutants-pr

Missed mutant

replace | with ^ in local_service_flags_pruned

Check warning on line 217 in crates/rbitcoin-net/src/peer.rs

View workflow job for this annotation

GitHub Actions / mutants-pr

Missed mutant

replace | with ^ in local_service_flags_pruned
} else {
crate::seeds::required_seed_services()
}
}

/// NETWORK_LIMITED is enough when the tip is shallower than this (~24h at 10m).
Expand Down Expand Up @@ -811,7 +820,8 @@
user_agent: &str,
policy: HandshakePolicy<'_>,
) -> Result<VersionMessage, NetError> {
let services = local_service_flags();
let pruned = policy.peers.map(|p| p.is_pruned()).unwrap_or(false);
let services = local_service_flags_pruned(pruned);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
Expand Down Expand Up @@ -4627,6 +4637,7 @@
}
match query.reconstruct_block_by_hash(&hash.to_byte_array()) {
Ok(b) => Ok(b),
Err(rbitcoin_store::StoreError::Pruned { .. }) => Ok(None),
Err(e) => Err(NetError::Consensus(e.to_string())),
}
}
Expand Down Expand Up @@ -4665,6 +4676,7 @@
Ok(Some(contents))
}
Ok(None) => Ok(None),
Err(rbitcoin_store::StoreError::Pruned { .. }) => Ok(None),
Err(e) => Err(NetError::Consensus(e.to_string())),
}
}
Expand Down
10 changes: 10 additions & 0 deletions crates/rbitcoin-net/src/peer_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ fn local_service_flags_include_network_witness_v2() {
assert!(f.has(ServiceFlags::P2P_V2));
}

#[test]
fn local_service_flags_pruned_are_limited_not_network() {
let f = local_service_flags_pruned(true);
assert!(f.has(ServiceFlags::NETWORK_LIMITED));
assert!(!f.has(ServiceFlags::NETWORK));
assert!(f.has(ServiceFlags::WITNESS));
assert!(f.has(ServiceFlags::P2P_V2));
assert_eq!(local_service_flags_pruned(false), local_service_flags());
}

#[test]
fn rand_nonce_changes() {
let a = rand_nonce();
Expand Down
12 changes: 11 additions & 1 deletion crates/rbitcoin-net/src/peers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@
self.next_local_addr_send
.compare_exchange(prev, next, Ordering::Relaxed, Ordering::Relaxed)
.ok()?;
let services = crate::peer::local_service_flags();
let services = crate::peer::local_service_flags_pruned(hub.is_pruned());
let t = now as u32;
if self.wants_addrv2() {
let mut v = Vec::new();
Expand Down Expand Up @@ -1149,6 +1149,7 @@
/// Clearnet P2P bind (not onion-only loopback). Needed to gossip `--external-ip`.
clearnet_listen: AtomicBool,
cjdns_reachable: AtomicBool,
pruned: AtomicBool,
asmap: Mutex<Option<Arc<crate::asmap::AsMap>>>,
/// Tip-mode mempool for Core `EraseForPeer` on disconnect.
mempool: Mutex<Option<Weak<crate::tx_relay::MempoolHub>>>,
Expand Down Expand Up @@ -1236,6 +1237,7 @@
discover: AtomicBool::new(true),
clearnet_listen: AtomicBool::new(true),
cjdns_reachable: AtomicBool::new(false),
pruned: AtomicBool::new(false),
asmap: Mutex::new(None),
mempool: Mutex::new(None),
net_perms: Mutex::new(crate::net_permissions::NetPermTable::default()),
Expand Down Expand Up @@ -1319,6 +1321,14 @@
self.cjdns_reachable.store(on, Ordering::Relaxed);
}

pub fn set_pruned(&self, on: bool) {
self.pruned.store(on, Ordering::Relaxed);
}

pub fn is_pruned(&self) -> bool {
self.pruned.load(Ordering::Relaxed)

Check warning on line 1329 in crates/rbitcoin-net/src/peers.rs

View workflow job for this annotation

GitHub Actions / mutants-pr

Missed mutant

replace PeerHub::is_pruned -> bool with false
}

pub fn set_listen_port(&self, port: u16) {
self.listen_port.store(port, Ordering::Relaxed);
}
Expand Down
31 changes: 30 additions & 1 deletion crates/rbitcoin-node/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ fn operator_usage() -> String {
[--tor-control [HOST:PORT]] [--tor-control-cookie PATH] [--tor-control-password PASS] \\\n\
[--i2p-sam [HOST:PORT]] [--i2p-accept-incoming] \\\n\
[--electrum-listen ADDR] [--esplora-listen ADDR] [--esplora-onion[=0|1]] \\\n\
[--sh-index] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--esplora-block-template] \\\n\
[--sh-index] [--prune-inwit] [--prune-inwit-ram-threshold-bytes N] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--esplora-block-template] \\\n\
[--rpc] [--rpc-listen [ADDR]] [--rpc-token-file PATH] [--rpc-work-queue N] \\\n\
[--milestone HEIGHT] \\\n\
[--max-outbound N] [--max-inbound N] \\\n\
Expand Down Expand Up @@ -335,6 +335,8 @@ Peers: --max-outbound (default 16 live download), --max-inbound (default 125).\n
--net-permission-relay (default on) / --net-permission-force-relay (default off) are implicit bits on a bare CIDR grant.\n\
Scripthash: --sh-index (default off) builds Class B for Electrum/Esplora address history.\n\
Electrum/Esplora start without it; scripthash/address methods fail closed.\n\
--prune-inwit refuse inwit reconstruct below tip-288 heights; advertise NETWORK_LIMITED (does not unlink inwit.body yet).\n\
--prune-inwit-ram-threshold-bytes N RAM cap for prune+IBD witness window (default 268435456).\n\
--max-sh-creates N refuses Electrum/Esplora joins with more than N creates (0 = unlimited).\n\
--esplora-block-template enables GET /block-template (GBT template JSON; default off).\n\
--esplora-onion (default on) ADD_ONION for --esplora-listen when --tor-control is set.\n\
Expand Down Expand Up @@ -377,6 +379,7 @@ fn is_bool_key(key: &str) -> bool {
matches!(
key,
"sh_index"
| "prune_inwit"
| "sp_tweaks"
| "esplora_block_template"
| "esplora_onion"
Expand Down Expand Up @@ -578,6 +581,7 @@ mod tests {
"--net-permission-force-relay",
"--signet-block-time",
"--sh-index",
"--prune-inwit",
"--sp-tweaks",
"--sp-tweaks-dust",
"--esplora-block-template",
Expand Down Expand Up @@ -990,6 +994,31 @@ mod tests {
);
}

#[test]
fn prune_inwit_is_kebab() {
let on = ready_config([
"rbitcoin-node",
"--prune-inwit",
"--prune-inwit-ram-threshold-bytes=4096",
]);
assert!(on.prune_inwit);
assert_eq!(on.prune_inwit_ram_threshold_bytes, 4096);
let mut conf = NodeConfig::default();
conf.apply_kv("prune_inwit", "1").unwrap();
conf.apply_kv("prune_inwit_ram_threshold_bytes", "8192")
.unwrap();
assert!(conf.prune_inwit);
assert_eq!(conf.prune_inwit_ram_threshold_bytes, 8192);
let h = operator_usage();
assert!(h.contains("--prune-inwit"));
assert!(h.contains("--prune-inwit-ram-threshold-bytes"));
assert!(!h.contains("--pruneinwit"));
assert_exit(
cli_main(["rbitcoin-node", "--pruneinwit"]),
ExitCode::from(2),
);
}

#[test]
fn tor_control_cli_defaults() {
let omitted = ready_config(["rbitcoin-node", "--tor-control"]);
Expand Down
22 changes: 21 additions & 1 deletion crates/rbitcoin-node/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub(crate) fn parse_btc_to_sat(s: &str) -> Result<u64, &'static str> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DatadirOpts {
pub path: PathBuf,
/// When set, Class A `inwit.body` / `inwit.loc` live under `{cold}/store`.
/// When set, cold witness artifacts (`inwit.*`, `inwit.window/*`) live under `{cold}/store`.
pub cold: Option<PathBuf>,
}

Expand Down Expand Up @@ -261,6 +261,10 @@ pub struct NodeConfig {
pub max_run_secs: Option<u64>,
/// Build Class B scripthash index (Electrum/Esplora history). Default **off**.
pub shindex: bool,
/// Drop Class A inwit below a 288-height watermark (`NETWORK_LIMITED`).
pub prune_inwit: bool,
/// RAM cap for prune+IBD witness window before spill/re-fetch behavior.
pub prune_inwit_ram_threshold_bytes: u64,
/// Persist / serve BIP-352 tweaks from `sp_tweaks.*`. Default **off**.
pub sptweaks: bool,
/// Electrum tweaks: omit P2TR outs with `value <=` this (sats). `0` serves
Expand Down Expand Up @@ -340,6 +344,8 @@ impl Default for NodeConfig {
head_scale: HeadScale::Mainnet,
max_run_secs: None,
shindex: false,
prune_inwit: false,
prune_inwit_ram_threshold_bytes: 256 * 1024 * 1024,
sptweaks: false,
sptweaks_dust: rbitcoin_electrum::DEFAULT_TWEAKS_MIN_DUST,
max_sh_creates: 0,
Expand Down Expand Up @@ -890,6 +896,20 @@ impl NodeConfig {
self.shindex = parse_conf_bool(val)
.map_err(|e| NodeError::Config(format!("conf sh_index: {e}")))?;
}
"prune_inwit" => {
self.prune_inwit = parse_conf_bool(val)
.map_err(|e| NodeError::Config(format!("conf prune_inwit: {e}")))?;
}
"prune_inwit_ram_threshold_bytes" => {
self.prune_inwit_ram_threshold_bytes = val.parse().map_err(|e| {
NodeError::Config(format!("conf prune_inwit_ram_threshold_bytes: {e}"))
})?;
if self.prune_inwit_ram_threshold_bytes == 0 {
return Err(NodeError::Config(
"conf prune_inwit_ram_threshold_bytes must be > 0".into(),
));
}
}
"sp_tweaks" => {
self.sptweaks = parse_conf_bool(val)
.map_err(|e| NodeError::Config(format!("conf sp_tweaks: {e}")))?;
Expand Down
Loading
Loading