diff --git a/COMPAT.md b/COMPAT.md index a48fb4538..7c9063c0a 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -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 | Unpruned: `inwit.body`. `--prune-inwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/inwit.window/` plus a RAM cap (`--prune-inwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. 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) | diff --git a/OPERATOR.md b/OPERATOR.md index c7793e9f0..09c71fde9 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -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** — unpruned reads `inwit.body`. On: refuse wire reconstruct below tip−288 **heights**, advertise `NETWORK_LIMITED`, and keep those heights as `store/inwit.window/{height}.bin` plus a RAM cache | +| `--prune-inwit-ram-threshold-bytes N` | `prune_inwit_ram_threshold_bytes=` | `268435456` (256 MiB). `0` keeps nothing in RAM: every height, including tiny IBD blocks, is read from its file | | `--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) | diff --git a/SCHEMA.md b/SCHEMA.md index 96a43452c..061a8b9ae 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -208,6 +208,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 window: one {height}.bin per kept height 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) diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index 02260d3dc..c43385b93 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -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)); } diff --git a/crates/rbitcoin-esplora/src/server.rs b/crates/rbitcoin-esplora/src/server.rs index 88b8b60d4..1ae22e93a 100644 --- a/crates/rbitcoin-esplora/src/server.rs +++ b/crates/rbitcoin-esplora/src/server.rs @@ -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(), @@ -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; diff --git a/crates/rbitcoin-esplora/src/tx_json.rs b/crates/rbitcoin-esplora/src/tx_json.rs index ef031722a..547035cde 100644 --- a/crates/rbitcoin-esplora/src/tx_json.rs +++ b/crates/rbitcoin-esplora/src/tx_json.rs @@ -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 { - 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 @@ -166,6 +172,25 @@ pub fn build_tx_json(query: &Query, tx_fk: Fk, network: Network) -> Result Result { + 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 = 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, diff --git a/crates/rbitcoin-net/src/ibd/mod.rs b/crates/rbitcoin-net/src/ibd/mod.rs index de6afe45b..74936f610 100644 --- a/crates/rbitcoin-net/src/ibd/mod.rs +++ b/crates/rbitcoin-net/src/ibd/mod.rs @@ -254,6 +254,15 @@ pub async fn ibd_cancellable( cfg: IbdConfig, cancel: Option>, ) -> Result { + struct IbdModeGuard(std::sync::Arc); + 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")); } diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index 583229b7a..2b861e6d0 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -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::{ diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index dbd1403f8..81dc79378 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -208,7 +208,16 @@ fn stash_pending_block(pending: &mut PendingBlocks, hash: BlockHash, block: bitc /// 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 + } else { + crate::seeds::required_seed_services() + } } /// NETWORK_LIMITED is enough when the tip is shallower than this (~24h at 10m). @@ -811,7 +820,8 @@ async fn application_handshake( user_agent: &str, policy: HandshakePolicy<'_>, ) -> Result { - 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) @@ -4627,6 +4637,7 @@ fn block_for_peer( } 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())), } } @@ -4665,6 +4676,7 @@ fn encode_served_witness_block( Ok(Some(contents)) } Ok(None) => Ok(None), + Err(rbitcoin_store::StoreError::Pruned { .. }) => Ok(None), Err(e) => Err(NetError::Consensus(e.to_string())), } } diff --git a/crates/rbitcoin-net/src/peer_tests.rs b/crates/rbitcoin-net/src/peer_tests.rs index 6501c1e33..2a698f83e 100644 --- a/crates/rbitcoin-net/src/peer_tests.rs +++ b/crates/rbitcoin-net/src/peer_tests.rs @@ -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(); diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index 11564850b..4cd0d8e77 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -461,7 +461,7 @@ impl LivePeer { 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(); @@ -1160,6 +1160,7 @@ pub struct PeerHub { /// Clearnet P2P bind (not onion-only loopback). Needed to gossip `--external-ip`. clearnet_listen: AtomicBool, cjdns_reachable: AtomicBool, + pruned: AtomicBool, asmap: Mutex>>, /// Tip-mode mempool for Core `EraseForPeer` on disconnect. mempool: Mutex>>, @@ -1248,6 +1249,7 @@ impl PeerHub { 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()), @@ -1341,6 +1343,14 @@ impl PeerHub { 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) + } + pub fn set_listen_port(&self, port: u16) { self.listen_port.store(port, Ordering::Relaxed); } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 0290bdc14..3879a3dfd 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -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\ @@ -335,6 +335,9 @@ 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.\n\ + Kept heights are store/inwit.window/{{height}}.bin plus a RAM cache. Unpruned nodes read inwit.body.\n\ + --prune-inwit-ram-threshold-bytes N RAM cap for that cache (default 268435456; 0 keeps nothing in RAM).\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\ @@ -377,6 +380,7 @@ fn is_bool_key(key: &str) -> bool { matches!( key, "sh_index" + | "prune_inwit" | "sp_tweaks" | "esplora_block_template" | "esplora_onion" @@ -578,6 +582,7 @@ mod tests { "--net-permission-force-relay", "--signet-block-time", "--sh-index", + "--prune-inwit", "--sp-tweaks", "--sp-tweaks-dust", "--esplora-block-template", @@ -990,6 +995,34 @@ 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); + conf.apply_kv("prune_inwit_ram_threshold_bytes", "0") + .unwrap(); + assert_eq!(conf.prune_inwit_ram_threshold_bytes, 0); + 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"]); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index bee9d94d4..9e5f29d54 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -62,7 +62,7 @@ pub(crate) fn parse_btc_to_sat(s: &str) -> Result { #[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, } @@ -261,6 +261,10 @@ pub struct NodeConfig { pub max_run_secs: Option, /// 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 the prune witness window. `0` keeps nothing in RAM. + 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 @@ -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, @@ -890,6 +896,15 @@ 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}")) + })?; + } "sp_tweaks" => { self.sptweaks = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf sp_tweaks: {e}")))?; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index b6ba607f1..4e8feb105 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -478,6 +478,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { addrman.set_cjdns_reachable(config.listen.cjdns_reachable); node.peers .set_cjdns_reachable(config.listen.cjdns_reachable); + node.peers.set_pruned(config.prune_inwit); node.peers.set_asmap(asmap); for c in &config.listen.connect { addrman.add_addr(*c); @@ -1249,6 +1250,16 @@ fn apply_startup_index_mode( ) -> Result<(), NodeError> { query.set_sh_index_enabled(config.shindex); query.set_max_sh_creates(config.max_sh_creates); + query.set_inwit_ram_threshold_bytes(config.prune_inwit_ram_threshold_bytes)?; + if !config.prune_inwit && query.prune_inwit() { + return Err(NodeError::Config( + "datadir is pruned-inwit; restart with --prune-inwit enabled".into(), + )); + } + if config.prune_inwit { + query.set_prune_inwit(true)?; + query.apply_prune_inwit_tip()?; + } if let Err(e) = query.set_sptweaks_enabled(config.sptweaks, rbitcoin_primitives::Height(taproot_height)) { @@ -1920,6 +1931,31 @@ mod tests { .with_tiny_heads() } + #[test] + fn startup_refuses_non_pruned_config_on_pruned_datadir() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("rbitcoin-prune-refuse-{nanos}")); + std::fs::create_dir_all(&dir).unwrap(); + let store = dir.join("store"); + let q = Query::open_or_create_tiny(&store).unwrap(); + q.set_prune_inwit(true).unwrap(); + q.set_pruneheight(Some(rbitcoin_primitives::Height(0))) + .unwrap(); + let mut cfg = tiny_regtest(&dir); + cfg.prune_inwit = false; + let err = apply_startup_index_mode(&q, &cfg, 0) + .unwrap_err() + .to_string(); + assert!( + err.contains("pruned-inwit") || err.contains("--prune-inwit"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// Perf (5s) and RPC-stop (50ms) ticks must still evaluate stale redial. /// A one-shot sleep in the same `select!` is reset on every such wake. #[test] diff --git a/crates/rbitcoin-query/src/archive.rs b/crates/rbitcoin-query/src/archive.rs index e4ce76a07..26ac20cf8 100644 --- a/crates/rbitcoin-query/src/archive.rs +++ b/crates/rbitcoin-query/src/archive.rs @@ -1015,6 +1015,11 @@ impl Query { "tx put_full_batch fk mismatch (plan not committed in order)", )); } + if self.prune_inwit() { + let ins: Vec> = + plan.packed.iter().map(|(_, v)| v.clone()).collect(); + self.note_appended_inwit_inputs(&got_tx_fks, &ins); + } for ((pin, _), pair) in plan.packed.iter().zip(loc.iter()) { pin.set_loc(*pair); } diff --git a/crates/rbitcoin-query/src/connect.rs b/crates/rbitcoin-query/src/connect.rs index b378f639b..9edc70eb5 100644 --- a/crates/rbitcoin-query/src/connect.rs +++ b/crates/rbitcoin-query/src/connect.rs @@ -80,7 +80,8 @@ impl Query { let mut abs_edges: Vec<(u64, Fk, u32, Fk, u32)> = Vec::new(); for item in items { for &spend_fk in &item.tx_fks { - let (_tx, ins, _outs) = self.store.get_tx_full(spend_fk)?; + let tx = self.get_tx(spend_fk)?; + let ins = self.tx_input_run_class_a(spend_fk, &tx)?; for (vin, inp) in ins.into_iter().enumerate() { if inp.is_coinbase() { continue; @@ -248,10 +249,22 @@ impl Query { if let Some(tip) = self.tip_height() { let _ = self.ensure_height_by_hash_index(tip); } + self.record_confirmed_inwit_window(items)?; Ok(out) } + fn record_confirmed_inwit_window(&self, items: &[ConfirmPrepared]) -> Result<(), QueryError> { + self.apply_prune_inwit_tip()?; + if !self.prune_inwit() { + return Ok(()); + } + for item in items { + self.note_inwit_ram_for_confirmed(item.height, &item.tx_fks)?; + } + Ok(()) + } + fn enqueue_sh_pending( &self, items: &[ConfirmPrepared], @@ -666,7 +679,12 @@ impl Query { if tx.input_count == 0 { return Ok(Vec::new()); } - let (_, inputs, _) = self.store.get_tx_full(create_fk)?; + let inputs = if let Some(v) = self.inwit_cached_inputs(create_fk, tx.input_count)? { + v + } else { + let (_, inputs, _) = self.store.get_tx_full(create_fk)?; + inputs + }; if inputs.len() as u32 != tx.input_count { return Err(StoreError::Corrupt("packed input count mismatch")); } @@ -712,6 +730,8 @@ impl Query { let height = self .tip_height() .ok_or(StoreError::Corrupt("no tip to disconnect"))?; + self.require_inwit_at(height)?; + self.drop_inwit_ram_height(height.0); let _appender = self.sh.appender.lock().unwrap(); if drop_pending { self.drop_sh_pending_from(height); diff --git a/crates/rbitcoin-query/src/lib.rs b/crates/rbitcoin-query/src/lib.rs index 49a8f9103..453a14cde 100644 --- a/crates/rbitcoin-query/src/lib.rs +++ b/crates/rbitcoin-query/src/lib.rs @@ -219,6 +219,14 @@ impl std::ops::DerefMut for BodyQueueInner { } } +#[derive(Default)] +struct InwitRamWindow { + by_height: BTreeMap>, + by_fk: U64Map>, + bytes: u64, + evictions: u64, +} + /// SH write-behind: confirm enqueues; one Class B appender drains. /// /// Separate mutexes on purpose: confirm enqueues on the write thread while @@ -319,6 +327,18 @@ pub struct Query { confirm_stats: Arc, /// Tip height of last in-process io_uring recover (`u32::MAX` = none). uring_recover_tip: AtomicU32, + /// Highest height whose inwit was dropped (`u32::MAX` = none dropped). + pruneheight: AtomicU32, + /// Operator `--prune-inwit` (advertise NETWORK_LIMITED even before a drop). + prune_inwit: AtomicBool, + /// True while the net IBD engine is active. + ibd_mode: AtomicBool, + /// In prune+IBD mode, cap for recent witness kept in RAM. + inwit_ram_threshold_bytes: AtomicU64, + /// Recent witness cache keyed by confirmed heights/create fks. + inwit_ram_window: Mutex, + /// Inputs from the most recent Class A append wave (fk-keyed). + inwit_append_cache: Mutex>>, } /// In-process hash→height map for the confirmed tip chain (~33 MiB raw at 1e6 tips). @@ -331,6 +351,7 @@ struct HeightByHashIndex { } impl Query { + pub const DEFAULT_INWIT_RAM_THRESHOLD_BYTES: u64 = 256 * 1024 * 1024; pub fn open_or_create(store_path: impl AsRef) -> Result { Self::open_or_create_layout(StoreLayout::single(store_path.as_ref().to_path_buf())) } @@ -385,6 +406,7 @@ impl Query { } else { (None, 0) }; + let (ph, prune_on) = Self::load_pruneheight(&store_path)?; let q = Self { store, spend_index: std::sync::atomic::AtomicBool::new(true), @@ -418,6 +440,12 @@ impl Query { disconnect_gen: AtomicU64::new(0), confirm_stats: Arc::new(ConfirmStats::default()), uring_recover_tip: AtomicU32::new(u32::MAX), + pruneheight: AtomicU32::new(ph), + prune_inwit: AtomicBool::new(prune_on), + ibd_mode: AtomicBool::new(false), + inwit_ram_threshold_bytes: AtomicU64::new(Self::DEFAULT_INWIT_RAM_THRESHOLD_BYTES), + inwit_ram_window: Mutex::new(InwitRamWindow::default()), + inwit_append_cache: Mutex::new(U64Map::default()), }; if let Some(tip) = q.tip_height() { let _ = q.ensure_height_by_hash_index(tip); @@ -436,6 +464,437 @@ impl Query { Arc::clone(&self.confirm_stats) } + fn load_pruneheight(store_path: &Path) -> Result<(u32, bool), QueryError> { + let path = store_path.join("inwit.prune"); + match std::fs::read(&path) { + Ok(bytes) => { + let arr: [u8; 4] = bytes + .as_slice() + .try_into() + .map_err(|_| StoreError::Corrupt("invariant: inwit.prune size"))?; + Ok((u32::from_le_bytes(arr), true)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((u32::MAX, false)), + Err(e) => Err(StoreError::io(path, e)), + } + } + + pub fn prune_inwit(&self) -> bool { + self.prune_inwit.load(AtomicOrdering::Acquire) + } + + pub fn ibd_mode(&self) -> bool { + self.ibd_mode.load(AtomicOrdering::Acquire) + } + + pub fn set_ibd_mode(&self, on: bool) { + self.ibd_mode.store(on, AtomicOrdering::Release); + } + + pub fn inwit_ram_threshold_bytes(&self) -> u64 { + self.inwit_ram_threshold_bytes.load(AtomicOrdering::Acquire) + } + + pub fn set_inwit_ram_threshold_bytes(&self, bytes: u64) -> Result<(), QueryError> { + self.inwit_ram_threshold_bytes + .store(bytes, AtomicOrdering::Release); + Ok(()) + } + + #[inline] + pub fn prune_ibd_mode(&self) -> bool { + self.prune_inwit() && self.ibd_mode() + } + + pub fn set_prune_inwit(&self, on: bool) -> Result<(), QueryError> { + let was_on = self.prune_inwit(); + if !on && self.prune_inwit() { + return Err(StoreError::Layout( + "refusing to disable prune-inwit on a pruned datadir".into(), + )); + } + self.prune_inwit.store(on, AtomicOrdering::Release); + if on { + if self.pruneheight().is_none() { + self.persist_pruneheight(u32::MAX)?; + } + if !was_on { + self.seed_recent_inwit_from_store()?; + } + } else { + self.clear_inwit_ram_window(); + self.set_pruneheight(None)?; + } + Ok(()) + } + + /// Durable-later watermark: creates at this height and below have no inwit. + pub fn pruneheight(&self) -> Option { + match self.pruneheight.load(AtomicOrdering::Acquire) { + u32::MAX => None, + h => Some(Height(h)), + } + } + + pub fn set_pruneheight(&self, height: Option) -> Result<(), QueryError> { + let v = height.map(|h| h.0).unwrap_or(u32::MAX); + self.pruneheight.store(v, AtomicOrdering::Release); + if height.is_some() { + self.prune_inwit.store(true, AtomicOrdering::Release); + self.persist_pruneheight(v)?; + self.prune_inwit_spill_below(v.saturating_add(1))?; + Ok(()) + } else { + self.prune_inwit.store(false, AtomicOrdering::Release); + self.persist_pruneheight_clear() + } + } + + fn persist_pruneheight(&self, v: u32) -> Result<(), QueryError> { + let path = self.store.path().join("inwit.prune"); + std::fs::write(&path, v.to_le_bytes()).map_err(|e| StoreError::io(path, e)) + } + + fn persist_pruneheight_clear(&self) -> Result<(), QueryError> { + let path = self.store.path().join("inwit.prune"); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(StoreError::io(path, e)), + } + } + + pub const INWIT_KEEP_HEIGHTS: u32 = 288; + + pub fn apply_prune_inwit_tip(&self) -> Result<(), QueryError> { + if !self.prune_inwit() { + return Ok(()); + } + let Some(tip) = self.tip_height() else { + return Ok(()); + }; + if tip.0 <= Self::INWIT_KEEP_HEIGHTS { + return Ok(()); + } + self.set_pruneheight(Some(Height(tip.0 - Self::INWIT_KEEP_HEIGHTS))) + } + + /// `false` when this create's connected height is at/below [`Self::pruneheight`]. + pub fn inwit_available(&self, fk: Fk) -> Result { + let Some(ph) = self.pruneheight() else { + return Ok(true); + }; + match self.store.tx_height_get(fk)? { + None => Ok(true), + Some(h) => Ok(h > ph.0), + } + } + + fn require_inwit_at(&self, height: Height) -> Result<(), QueryError> { + if let Some(ph) = self.pruneheight() { + if height.0 <= ph.0 { + return Err(StoreError::Pruned { height: height.0 }); + } + } + Ok(()) + } + + fn require_inwit_fk(&self, fk: Fk) -> Result<(), QueryError> { + if self.inwit_available(fk)? { + return Ok(()); + } + let height = self.store.tx_height_get(fk)?.unwrap_or(0); + Err(StoreError::Pruned { height }) + } + + fn drop_inwit_ram_height(&self, height: u32) { + let mut g = self.inwit_ram_window.lock().unwrap(); + let Some(old_fks) = g.by_height.remove(&height) else { + return; + }; + for fk in old_fks { + let Some(id) = fk.get() else { + continue; + }; + let Some(old) = g.by_fk.remove(&id) else { + continue; + }; + let n = old.iter().map(|i| i.encoded_len() as u64).sum(); + g.bytes = g.bytes.saturating_sub(n); + } + } + + pub(crate) fn clear_inwit_ram_window(&self) { + *self.inwit_ram_window.lock().unwrap() = InwitRamWindow::default(); + self.inwit_append_cache.lock().unwrap().clear(); + } + + #[cfg(test)] + pub(crate) fn inwit_ram_window_stats(&self) -> (usize, usize, u64, u64) { + let g = self.inwit_ram_window.lock().unwrap(); + (g.by_height.len(), g.by_fk.len(), g.bytes, g.evictions) + } + + pub(crate) fn inwit_ram_inputs(&self, fk: Fk) -> Option> { + let id = fk.get()?; + self.inwit_ram_window + .lock() + .unwrap() + .by_fk + .get(&id) + .cloned() + } + + fn inwit_spill_dir(&self) -> std::path::PathBuf { + self.store.path().join("inwit.window") + } + + fn inwit_spill_file(&self, height: u32) -> Result { + let dir = self.inwit_spill_dir(); + let name = format!("{height}.bin"); + let stem_ok = name + .strip_suffix(".bin") + .and_then(|s| s.parse::().ok()) + == Some(height); + if !stem_ok { + return Err(StoreError::Corrupt("invariant: inwit spill name")); + } + let path = dir.join(&name); + if !path.starts_with(&dir) { + return Err(StoreError::Corrupt( + "invariant: inwit spill path escaped window dir", + )); + } + Ok(path) + } + + fn prune_inwit_spill_below(&self, min_keep_height: u32) -> Result<(), QueryError> { + let dir = self.inwit_spill_dir(); + let Ok(rd) = std::fs::read_dir(&dir) else { + return Ok(()); + }; + for ent in rd { + let ent = ent.map_err(|e| StoreError::io(&dir, e))?; + let path = ent.path(); + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(h) = stem.parse::() else { + continue; + }; + if h < min_keep_height { + let path = self.inwit_spill_file(h)?; + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(StoreError::io(path, e)), + } + } + } + Ok(()) + } + + fn persist_inwit_spill_height( + &self, + height: Height, + rows: &[(Fk, Vec)], + ) -> Result<(), QueryError> { + let dir = self.inwit_spill_dir(); + std::fs::create_dir_all(&dir).map_err(|e| StoreError::io(&dir, e))?; + let path = self.inwit_spill_file(height.0)?; + let tmp_name = format!("{}.bin.tmp", height.0); + if tmp_name + .strip_suffix(".bin.tmp") + .and_then(|s| s.parse::().ok()) + != Some(height.0) + { + return Err(StoreError::Corrupt("invariant: inwit spill name")); + } + let tmp = dir.join(&tmp_name); + if !tmp.starts_with(&dir) { + return Err(StoreError::Corrupt( + "invariant: inwit spill path escaped window dir", + )); + } + let mut out = Vec::new(); + for (fk, ins) in rows { + let Some(id) = fk.get() else { + continue; + }; + out.extend_from_slice(&id.to_le_bytes()); + let mut enc = Vec::new(); + rbitcoin_store::encode_inwit_with_secret(ins, &mut enc, None); + out.extend_from_slice(&(enc.len() as u32).to_le_bytes()); + out.extend_from_slice(&enc); + } + std::fs::write(&tmp, out).map_err(|e| StoreError::io(&tmp, e))?; + std::fs::rename(&tmp, &path).map_err(|e| StoreError::io(&path, e)) + } + + fn inwit_spill_inputs_with_count( + &self, + fk: Fk, + input_count: u32, + ) -> Result>, QueryError> { + if !self.prune_inwit() { + return Ok(None); + } + let Some(height) = self.store.tx_height_get(fk)? else { + return Ok(None); + }; + if self.pruneheight().is_some_and(|ph| height <= ph.0) { + return Ok(None); + } + let path = self.inwit_spill_file(height)?; + let canon = match path.canonicalize() { + Ok(p) => p, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(StoreError::io(&path, e)), + }; + let root = self + .inwit_spill_dir() + .canonicalize() + .map_err(|e| StoreError::io(self.inwit_spill_dir(), e))?; + if !canon.starts_with(&root) { + return Err(StoreError::Corrupt( + "invariant: inwit spill path escaped window dir", + )); + } + let raw = match std::fs::read(&canon) { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(StoreError::io(&canon, e)), + }; + let mut i = 0usize; + let want = fk.get().ok_or(StoreError::InvalidFk)?; + while i.saturating_add(12) <= raw.len() { + let id = u64::from_le_bytes(raw[i..i + 8].try_into().unwrap()); + i += 8; + let n = u32::from_le_bytes(raw[i..i + 4].try_into().unwrap()) as usize; + i += 4; + if i.saturating_add(n) > raw.len() { + return Err(StoreError::Corrupt("inwit spill short row")); + } + if id == want { + let ins = rbitcoin_store::decode_inwit_secret(&raw[i..i + n], input_count, None)?; + return Ok(Some(ins)); + } + i += n; + } + Ok(None) + } + + pub(crate) fn inwit_cached_inputs( + &self, + fk: Fk, + input_count: u32, + ) -> Result>, QueryError> { + if let Some(ins) = self.inwit_ram_inputs(fk) { + return Ok(Some(ins)); + } + self.inwit_spill_inputs_with_count(fk, input_count) + } + + pub(crate) fn note_appended_inwit_inputs(&self, fks: &[Fk], ins: &[Vec]) { + let mut cache = self.inwit_append_cache.lock().unwrap(); + for (fk, inputs) in fks.iter().zip(ins.iter()) { + if let Some(id) = fk.get() { + cache.insert(id, inputs.clone()); + } + } + } + + pub(crate) fn note_inwit_ram_for_confirmed( + &self, + height: Height, + tx_fks: &[Fk], + ) -> Result<(), QueryError> { + if !self.prune_inwit() || tx_fks.is_empty() { + return Ok(()); + } + let threshold = self.inwit_ram_threshold_bytes(); + let mut staged: Vec<(Fk, Vec, u64)> = Vec::with_capacity(tx_fks.len()); + let mut appended = self.inwit_append_cache.lock().unwrap(); + for &fk in tx_fks { + let ins = if let Some(id) = fk.get() { + if let Some(v) = appended.remove(&id) { + v + } else { + let (_tx, ins, _outs) = self.store.get_tx_full(fk)?; + ins + } + } else { + let (_tx, ins, _outs) = self.store.get_tx_full(fk)?; + ins + }; + let bytes = ins.iter().map(|i| i.encoded_len() as u64).sum(); + staged.push((fk, ins, bytes)); + } + drop(appended); + self.drop_inwit_ram_height(height.0); + let spill_rows: Vec<(Fk, Vec)> = staged + .iter() + .map(|(fk, ins, _)| (*fk, ins.clone())) + .collect(); + self.persist_inwit_spill_height(height, &spill_rows)?; + if threshold == 0 { + return Ok(()); + } + let mut g = self.inwit_ram_window.lock().unwrap(); + let mut at_height: Vec = Vec::with_capacity(staged.len()); + for (fk, ins, bytes) in staged { + if let Some(id) = fk.get() { + if let Some(old) = g.by_fk.insert(id, ins) { + g.bytes = g + .bytes + .saturating_sub(old.iter().map(|i| i.encoded_len() as u64).sum::()); + } + g.bytes = g.bytes.saturating_add(bytes); + at_height.push(fk); + } + } + g.by_height.insert(height.0, at_height); + while g.by_height.len() > Self::INWIT_KEEP_HEIGHTS as usize || g.bytes > threshold { + let Some((&old_h, old_fks)) = g.by_height.first_key_value() else { + break; + }; + let old_fks = old_fks.clone(); + g.by_height.remove(&old_h); + for fk in old_fks { + if let Some(id) = fk.get() { + if let Some(old) = g.by_fk.remove(&id) { + g.bytes = g.bytes.saturating_sub( + old.iter().map(|i| i.encoded_len() as u64).sum::(), + ); + g.evictions = g.evictions.saturating_add(1); + } + } + } + } + Ok(()) + } + + fn seed_recent_inwit_from_store(&self) -> Result<(), QueryError> { + let Some(tip) = self.tip_height() else { + return Ok(()); + }; + let from = tip + .0 + .saturating_sub(Self::INWIT_KEEP_HEIGHTS.saturating_sub(1)); + for h in from..=tip.0 { + let tx_fks = match self.block_tx_fks(Height(h)) { + Ok(v) => v, + Err(StoreError::NotFound) => continue, + Err(e) => return Err(e), + }; + if tx_fks.is_empty() { + continue; + } + self.note_inwit_ram_for_confirmed(Height(h), &tx_fks)?; + } + Ok(()) + } + /// After `head_insert_many` returned these fks (inclusive max). pub fn note_head_drain_fk(&self, max_fk: u64) { if max_fk == 0 { @@ -1248,10 +1707,36 @@ impl Query { if i >= tx.input_count { return Err(StoreError::NotFound); } - let (_, inputs, _) = self.store.get_tx_full(create_fk)?; + if let Some(inputs) = self.inwit_cached_inputs(create_fk, tx.input_count)? { + return inputs.get(i as usize).cloned().ok_or(StoreError::NotFound); + } + self.require_inwit_fk(create_fk)?; + let (_, inputs, _) = match self.store.get_tx_full(create_fk) { + Ok(v) => v, + Err(StoreError::NotFound) if self.prune_inwit() => { + let height = self.store.tx_height_get(create_fk)?.unwrap_or(0); + return Err(StoreError::Pruned { height }); + } + Err(e) => return Err(e), + }; inputs.get(i as usize).cloned().ok_or(StoreError::NotFound) } + pub(crate) fn tx_prevouts_for_fk(&self, fk: Fk) -> Result, QueryError> { + match self.store.get_tx_meta_and_prevouts(fk) { + Ok((_, prevs)) => Ok(prevs), + Err(StoreError::NotFound) if self.prune_inwit() => { + let tx = self.get_tx(fk)?; + let Some(inputs) = self.inwit_cached_inputs(fk, tx.input_count)? else { + let height = self.store.tx_height_get(fk)?.unwrap_or(0); + return Err(StoreError::Pruned { height }); + }; + Ok(inputs.iter().map(|i| (i.create_fk, i.prev_index)).collect()) + } + Err(e) => Err(e), + } + } + /// Output `vout` of a tx row (run-addressed). pub fn tx_output(&self, tx: &TxRecord, vout: u32) -> Result { if vout >= tx.output_count { diff --git a/crates/rbitcoin-query/src/query_tests.rs b/crates/rbitcoin-query/src/query_tests.rs index 582ed9d8e..b5049adeb 100644 --- a/crates/rbitcoin-query/src/query_tests.rs +++ b/crates/rbitcoin-query/src/query_tests.rs @@ -2588,6 +2588,278 @@ fn reconstruct_archived_contiguous_skips_get_tx_full() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn reconstruct_pruned_returns_pruned_not_corrupt() { + let (dir, q) = temp_query("reconstruct-pruned"); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + let mut hashes = Vec::new(); + for h in 0..3u32 { + let (header, ta) = coinbase_block(h, prev, parent_hash); + parent_hash = Some(header.hash); + hashes.push(header.hash); + prev = q.connect_block(Height(h), &header, &[ta]).unwrap(); + } + q.set_pruneheight(Some(Height(0))).unwrap(); + assert_eq!(q.pruneheight(), Some(Height(0))); + let fks0 = q.block_tx_fks(Height(0)).unwrap(); + assert!( + !q.inwit_available(fks0[0]).unwrap(), + "height 0 must be at/below watermark" + ); + let err = q.reconstruct_archived_block(&hashes[0]).unwrap_err(); + assert!( + matches!(err, StoreError::Pruned { height: 0 }), + "below watermark must be Pruned, not {err:?}" + ); + let err = q.reconstruct_block_at_height(Height(0)).unwrap_err(); + assert!(matches!(err, StoreError::Pruned { height: 0 }), "{err:?}"); + let err = q.witness_block_bytes_by_hash(&hashes[0]).unwrap_err(); + assert!(matches!(err, StoreError::Pruned { height: 0 }), "{err:?}"); + let err = q.tx_wire_bytes(fks0[0]).unwrap_err(); + assert!(matches!(err, StoreError::Pruned { height: 0 }), "{err:?}"); + let err = q.reconstruct_tx(fks0[0]).unwrap_err(); + assert!(matches!(err, StoreError::Pruned { height: 0 }), "{err:?}"); + assert_eq!(q.block_txids(Height(0)).unwrap().len(), 1); + assert!(q.tx_output_at_fk(fks0[0], 0).is_ok()); + let kept = q.reconstruct_archived_block(&hashes[1]).unwrap().unwrap(); + assert_eq!(kept.txdata.len(), 1); + let fks1 = q.block_tx_fks(Height(1)).unwrap(); + assert!(q.inwit_available(fks1[0]).unwrap()); + assert!(q.tx_wire_bytes(fks1[0]).is_ok()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn reorg_through_pruneheight_refuses() { + let (dir, q) = temp_query("reorg-pruneheight"); + let (h0, t0) = coinbase_block(0, Fk::NULL, None); + let hash0 = h0.hash; + q.connect_block(Height(0), &h0, &[t0]).unwrap(); + let prev = q.tip_header_fk().unwrap().unwrap(); + let (h1, t1) = coinbase_block(1, prev, Some(hash0)); + q.connect_block(Height(1), &h1, &[t1]).unwrap(); + q.set_pruneheight(Some(Height(0))).unwrap(); + q.disconnect_tip().unwrap(); + assert_eq!(q.tip_height(), Some(Height(0))); + let err = q.disconnect_tip().unwrap_err(); + assert!( + matches!(err, StoreError::Pruned { height: 0 }), + "disconnect at/below pruneheight must be Pruned, got {err:?}" + ); + assert_eq!(q.tip_height(), Some(Height(0))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn prune_watermark_survives_reopen() { + let (dir, q) = temp_query("prune-reopen"); + let (h0, t0) = coinbase_block(0, Fk::NULL, None); + q.connect_block(Height(0), &h0, &[t0]).unwrap(); + q.set_pruneheight(Some(Height(0))).unwrap(); + drop(q); + let q = Query::open_or_create_tiny(dir.path()).unwrap(); + assert_eq!(q.pruneheight(), Some(Height(0))); + assert!(q.prune_inwit()); + let err = q.reconstruct_block_at_height(Height(0)).unwrap_err(); + assert!(matches!(err, StoreError::Pruned { height: 0 }), "{err:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn prune_ibd_ram_window_keeps_last_288_heights() { + let (dir, q) = temp_query("prune-ibd-ram-window"); + q.set_prune_inwit(true).unwrap(); + q.set_ibd_mode(true); + q.set_inwit_ram_threshold_bytes(1 << 30).unwrap(); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + for h in 0..320u32 { + let (header, mut ta) = coinbase_block(h, prev, parent_hash); + ta.inputs[0].witness = vec![vec![0x44; 96]]; + parent_hash = Some(header.hash); + prev = q.connect_block(Height(h), &header, &[ta]).unwrap(); + } + let (heights, fks, _bytes, evictions) = q.inwit_ram_window_stats(); + assert!(heights <= Query::INWIT_KEEP_HEIGHTS as usize); + assert!(fks <= Query::INWIT_KEEP_HEIGHTS as usize); + assert!(evictions > 0, "old heights must be evicted"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn prune_ram_window_drops_fks_on_disconnect_and_replace() { + let (dir, q) = temp_query("prune-ram-replace"); + q.set_prune_inwit(true).unwrap(); + q.set_ibd_mode(true); + q.set_inwit_ram_threshold_bytes(1 << 30).unwrap(); + let (h0, mut t0) = coinbase_block(0, Fk::NULL, None); + t0.inputs[0].witness = vec![vec![0x11; 16]]; + let hash0 = h0.hash; + let prev = q.connect_block(Height(0), &h0, &[t0]).unwrap(); + let (h1, mut t1) = coinbase_block(1, prev, Some(hash0)); + t1.inputs[0].witness = vec![vec![0x22; 16]]; + q.connect_block(Height(1), &h1, &[t1]).unwrap(); + assert_eq!(q.inwit_ram_window_stats().1, 2); + q.disconnect_tip().unwrap(); + assert_eq!( + q.inwit_ram_window_stats().1, + 1, + "disconnect must drop that height's inwit" + ); + let (h1b, mut t1b) = coinbase_block(1, prev, Some(hash0)); + t1b.inputs[0].witness = vec![vec![0x33; 16]]; + q.connect_block(Height(1), &h1b, &[t1b]).unwrap(); + let (heights, fks, _, _) = q.inwit_ram_window_stats(); + assert_eq!(heights, 2); + assert_eq!( + fks, 2, + "replaced height must not keep the disconnected create" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn prune_ibd_ram_window_honors_byte_threshold() { + let (dir, q) = temp_query("prune-ibd-ram-threshold"); + q.set_prune_inwit(true).unwrap(); + q.set_ibd_mode(true); + q.set_inwit_ram_threshold_bytes(80).unwrap(); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + for h in 0..8u32 { + let (header, mut ta) = coinbase_block(h, prev, parent_hash); + ta.inputs[0].witness = vec![vec![0x77; 128]]; + parent_hash = Some(header.hash); + prev = q.connect_block(Height(h), &header, &[ta]).unwrap(); + } + let (_heights, _fks, bytes, evictions) = q.inwit_ram_window_stats(); + assert!(bytes <= 80, "bytes={bytes}"); + assert!(evictions > 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn prune_ibd_ram_window_serves_recent_and_restart_uses_spill() { + let (dir, q) = temp_query("prune-ibd-ram-serve"); + q.set_prune_inwit(true).unwrap(); + q.set_ibd_mode(true); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + for h in 0..300u32 { + let (header, mut ta) = coinbase_block(h, prev, parent_hash); + ta.inputs[0].witness = vec![vec![0x99; 48]]; + parent_hash = Some(header.hash); + prev = q.connect_block(Height(h), &header, &[ta]).unwrap(); + } + q.apply_prune_inwit_tip().unwrap(); + let fk0 = q.block_tx_fks(Height(299)).unwrap()[0]; + let tx0 = q.get_tx(fk0).unwrap(); + assert_eq!( + q.tx_input_at_fk(fk0, &tx0, 0).unwrap().witness.len(), + 1, + "recent witness is served from RAM" + ); + let window = q.store.path().join("inwit.window"); + for ent in std::fs::read_dir(&window).unwrap() { + let p = ent.unwrap().path(); + assert_eq!(p.parent(), Some(window.as_path()), "{p:?}"); + let name = p.file_name().unwrap().to_str().unwrap(); + let stem = name.strip_suffix(".bin").expect(name); + assert!(stem.parse::().is_ok(), "{name}"); + } + drop(q); + let q2 = Query::open_or_create_tiny(dir.path()).unwrap(); + let tx1 = q2.get_tx(fk0).unwrap(); + assert_eq!( + q2.tx_input_at_fk(fk0, &tx1, 0).unwrap().witness.len(), + 1, + "recent witness is served from spill after restart" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn spill_symlink_outside_window_is_corrupt() { + let (dir, q) = temp_query("prune-spill-symlink"); + q.set_prune_inwit(true).unwrap(); + q.set_ibd_mode(true); + let (header, mut ta) = coinbase_block(0, Fk::NULL, None); + ta.inputs[0].witness = vec![vec![0x42; 16]]; + q.connect_block(Height(0), &header, &[ta]).unwrap(); + let fk = q.block_tx_fks(Height(0)).unwrap()[0]; + let spill = q.store.path().join("inwit.window").join("0.bin"); + let outside = dir.path().join("outside.bin"); + std::fs::rename(&spill, &outside).unwrap(); + std::os::unix::fs::symlink(&outside, &spill).unwrap(); + q.clear_inwit_ram_window(); + let tx = q.get_tx(fk).unwrap(); + let err = q.tx_input_at_fk(fk, &tx, 0).unwrap_err(); + assert!( + matches!(err, rbitcoin_store::StoreError::Corrupt(msg) if msg.contains("escaped")), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn prune_ram_threshold_zero_spills_tiny_blocks() { + let (dir, q) = temp_query("prune-ram-zero"); + q.set_inwit_ram_threshold_bytes(0).unwrap(); + q.set_prune_inwit(true).unwrap(); + q.set_ibd_mode(true); + let (header, mut ta) = coinbase_block(0, Fk::NULL, None); + ta.inputs[0].witness = vec![vec![0x11]]; + q.connect_block(Height(0), &header, &[ta]).unwrap(); + let (heights, fks, bytes, _) = q.inwit_ram_window_stats(); + assert_eq!(heights, 0, "threshold 0 keeps no RAM heights"); + assert_eq!(fks, 0); + assert_eq!(bytes, 0); + let fk = q.block_tx_fks(Height(0)).unwrap()[0]; + let tx = q.get_tx(fk).unwrap(); + assert_eq!( + q.tx_input_at_fk(fk, &tx, 0).unwrap().witness, + vec![vec![0x11]], + "tiny block is served from the height file" + ); + assert!(q.store.path().join("inwit.window/0.bin").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn enable_prune_after_history_seeds_recent_spill_window() { + let (dir, q) = temp_query("prune-enable-seed"); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + for h in 0..300u32 { + let (header, mut ta) = coinbase_block(h, prev, parent_hash); + ta.inputs[0].witness = vec![vec![0x55; 72]]; + parent_hash = Some(header.hash); + prev = q.connect_block(Height(h), &header, &[ta]).unwrap(); + } + let tip_fk = q.block_tx_fks(Height(299)).unwrap()[0]; + q.set_prune_inwit(true).unwrap(); + q.apply_prune_inwit_tip().unwrap(); + let tx = q.get_tx(tip_fk).unwrap(); + assert_eq!(q.tx_input_at_fk(tip_fk, &tx, 0).unwrap().witness.len(), 1); + drop(q); + let q2 = Query::open_or_create_tiny(dir.path()).unwrap(); + let tx = q2.get_tx(tip_fk).unwrap(); + assert_eq!(q2.tx_input_at_fk(tip_fk, &tx, 0).unwrap().witness.len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn prune_mode_refuses_disable() { + let (dir, q) = temp_query("prune-disable-refuse"); + q.set_prune_inwit(true).unwrap(); + q.set_pruneheight(Some(Height(0))).unwrap(); + let err = q.set_prune_inwit(false).unwrap_err().to_string(); + assert!(err.contains("refusing to disable prune-inwit"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn reconstruct_span_batches_foreign_parent_txids() { let (dir, q) = temp_query("reconstruct-parent-batch"); diff --git a/crates/rbitcoin-query/src/reconstruct.rs b/crates/rbitcoin-query/src/reconstruct.rs index a9e7cb3b0..51fc28629 100644 --- a/crates/rbitcoin-query/src/reconstruct.rs +++ b/crates/rbitcoin-query/src/reconstruct.rs @@ -9,9 +9,24 @@ impl Query { &self, fk: Fk, ) -> Result<(TxRecord, Vec, Vec), QueryError> { + let (tx, outs) = self.store.get_tx_meta_and_outputs(fk)?; + if let Some(inputs) = self.inwit_cached_inputs(fk, tx.input_count)? { + if inputs.len() as u32 != tx.input_count { + return Err(StoreError::Corrupt("packed input count mismatch")); + } + return Ok((tx, outs, inputs)); + } + self.require_inwit_fk(fk)?; let t0 = Instant::now(); crate::note_confirm(&self.confirm_stats().wf_body_store, 1); - let (tx, inputs, outs) = self.store.get_tx_full(fk)?; + let (tx, inputs, outs) = match self.store.get_tx_full(fk) { + Ok(v) => v, + Err(StoreError::NotFound) if self.prune_inwit() => { + let height = self.store.tx_height_get(fk)?.unwrap_or(0); + return Err(StoreError::Pruned { height }); + } + Err(e) => return Err(e), + }; crate::note_confirm( &self.confirm_stats().wf_body_store_ns, t0.elapsed().as_nanos() as u64, @@ -242,19 +257,24 @@ impl Query { &self, tx_fks: &[Fk], ) -> Result, Vec)>, QueryError> { + if let Some(&fk) = tx_fks.first() { + self.require_inwit_fk(fk)?; + } let mut prev_txid_cache: U64Map<[u8; 32]> = U64Map::default(); - if let Some((first, last)) = Self::contiguous_fk_run(tx_fks) { - let mut rows = self.store.get_tx_full_span(first, last)?; - if rows.len() != tx_fks.len() { - return Err(StoreError::Corrupt("invariant: span reconstruct length")); - } - for (i, (rec_tx, stored_inputs, _)) in rows.iter_mut().enumerate() { - if let Some(id) = tx_fks[i].get() { - prev_txid_cache.insert(id, rec_tx.txid); + if !self.prune_inwit() { + if let Some((first, last)) = Self::contiguous_fk_run(tx_fks) { + let mut rows = self.store.get_tx_full_span(first, last)?; + if rows.len() != tx_fks.len() { + return Err(StoreError::Corrupt("invariant: span reconstruct length")); + } + for (i, (rec_tx, stored_inputs, _)) in rows.iter_mut().enumerate() { + if let Some(id) = tx_fks[i].get() { + prev_txid_cache.insert(id, rec_tx.txid); + } + self.fill_input_prev_txids_cached(stored_inputs, &mut prev_txid_cache)?; } - self.fill_input_prev_txids_cached(stored_inputs, &mut prev_txid_cache)?; + return Ok(rows); } - return Ok(rows); } let mut rows = Vec::with_capacity(tx_fks.len()); for &fk in tx_fks { @@ -270,6 +290,9 @@ impl Query { &self, hash: &[u8; 32], ) -> Result>, QueryError> { + if let Some(h) = self.height_of_hash(hash)? { + self.require_inwit_at(h)?; + } let Some((header_fk, rec)) = self.get_header_by_hash(hash)? else { return Ok(None); }; @@ -296,6 +319,9 @@ impl Query { pub fn reconstruct_archived_block(&self, hash: &[u8; 32]) -> Result, QueryError> { self.note_reconstruct_archived(); + if let Some(h) = self.height_of_hash(hash)? { + self.require_inwit_at(h)?; + } let Some((header_fk, rec)) = self.get_header_by_hash(hash)? else { return Ok(None); }; @@ -368,6 +394,7 @@ impl Query { /// Reconstruct a full wire block at a confirmed height from the relational archive. pub fn reconstruct_block_at_height(&self, height: Height) -> Result { + self.require_inwit_at(height)?; let (_fk, rec) = self.header_at_height(height)?.ok_or(StoreError::NotFound)?; let tx_fks = self.block_tx_fks(height)?; let block = self.reconstruct_archived_block_from_parts_cached(rec.clone(), tx_fks, None)?; diff --git a/crates/rbitcoin-query/src/scripthash.rs b/crates/rbitcoin-query/src/scripthash.rs index c32e33cd1..fd73fa530 100644 --- a/crates/rbitcoin-query/src/scripthash.rs +++ b/crates/rbitcoin-query/src/scripthash.rs @@ -984,7 +984,7 @@ impl Query { } } if !hit { - let (_, prevs) = self.store.get_tx_meta_and_prevouts(*fk)?; + let prevs = self.tx_prevouts_for_fk(*fk)?; for (create_fk, _) in prevs { if let Some(id) = create_fk.get() { if posting.binary_search(&id).is_ok() { @@ -1218,7 +1218,8 @@ impl Query { for h in 0..=tip.0 { let fks = self.block_tx_fks(Height(h))?; for (ti, fk) in fks.into_iter().enumerate() { - let (tx, _ins, outs) = self.store.get_tx_full(fk)?; + let tx = self.get_tx(fk)?; + let (_meta, outs) = self.store.get_tx_meta_and_outputs(fk)?; let coinbase = ti == 0; for (vout, o) in outs.iter().enumerate() { if !scripts.iter().any(|s| s.as_slice() == o.script.as_slice()) { diff --git a/crates/rbitcoin-rpc/src/blockstats.rs b/crates/rbitcoin-rpc/src/blockstats.rs index 4832412a9..dd327840d 100644 --- a/crates/rbitcoin-rpc/src/blockstats.rs +++ b/crates/rbitcoin-rpc/src/blockstats.rs @@ -1,7 +1,7 @@ //! Core `getblockstats` — reconstruct a block and sum fees / UTXO / weight. use crate::methods::{ - parse_hash32_display, rpc_error, RpcContext, RpcParams, ERR_INVALID_ADDRESS_OR_KEY, + map_query, parse_hash32_display, rpc_error, RpcContext, RpcParams, ERR_INVALID_ADDRESS_OR_KEY, ERR_INVALID_PARAMETER, ERR_MISC, }; use bitcoin::consensus::Encodable; @@ -485,7 +485,7 @@ pub fn getblockstats(ctx: &RpcContext, params: &RpcParams) -> Result { @@ -498,7 +498,7 @@ pub fn getblockstats(ctx: &RpcContext, params: &RpcParams) -> Result { diff --git a/crates/rbitcoin-rpc/src/methods/chain.rs b/crates/rbitcoin-rpc/src/methods/chain.rs index 6e4a7a61d..b1ceaaa41 100644 --- a/crates/rbitcoin-rpc/src/methods/chain.rs +++ b/crates/rbitcoin-rpc/src/methods/chain.rs @@ -83,7 +83,7 @@ pub(crate) fn getblockchaininfo(ctx: &RpcContext) -> Result { } else { (0u32, 0u32) }; - Ok(json!({ + let mut info = json!({ "chain": chain_name(ctx.network), "blocks": tip, "headers": headers, @@ -95,9 +95,13 @@ pub(crate) fn getblockchaininfo(ctx: &RpcContext) -> Result { "initialblockdownload": ibd, "chainwork": chainwork_hex(ctx, ctx.query.tip_height()), "size_on_disk": ctx.query.store().datadir_bytes(), - "pruned": false, + "pruned": ctx.query.prune_inwit(), "warnings": rpc_warnings(ctx), - })) + }); + if let Some(h) = ctx.query.pruneheight() { + info["pruneheight"] = json!(h.0); + } + Ok(info) } pub(crate) fn rpc_warnings(ctx: &RpcContext) -> Vec { @@ -378,7 +382,7 @@ pub(crate) fn getblock(ctx: &RpcContext, params: &RpcParams) -> Result Result< let tx = ctx .query .reconstruct_tx(fk) - .map_err(|e| rpc_error(ERR_MISC, e.to_string()))?; + .map_err(|e| map_query(e, "Transaction not available (pruned data)"))?; if !verbose { return Ok(json!(serialize_hex(&tx))); } diff --git a/crates/rbitcoin-rpc/src/methods/mod.rs b/crates/rbitcoin-rpc/src/methods/mod.rs index 293e248f0..ccb7d2bfc 100644 --- a/crates/rbitcoin-rpc/src/methods/mod.rs +++ b/crates/rbitcoin-rpc/src/methods/mod.rs @@ -171,6 +171,13 @@ pub const ERR_VERIFY_REJECTED: i64 = -26; pub const ERR_INVALID_PARAMS: i64 = -32602; pub const ERR_METHOD_NOT_FOUND: i64 = -32601; +pub(crate) fn map_query(e: rbitcoin_query::QueryError, pruned: &'static str) -> Value { + match e { + rbitcoin_store::StoreError::Pruned { .. } => rpc_error(ERR_INVALID_PARAMETER, pruned), + other => rpc_error(ERR_MISC, other.to_string()), + } +} + /// JSON-RPC `params`: positional array or Core named object. #[derive(Clone, Debug, Default)] pub struct RpcParams { diff --git a/crates/rbitcoin-rpc/src/methods/net.rs b/crates/rbitcoin-rpc/src/methods/net.rs index d0f2fbc58..10f11140b 100644 --- a/crates/rbitcoin-rpc/src/methods/net.rs +++ b/crates/rbitcoin-rpc/src/methods/net.rs @@ -347,7 +347,7 @@ pub(crate) fn getnetworkinfo(ctx: &RpcContext) -> Value { } else { (0, ctx.connections.load(Ordering::Relaxed), 0) }; - let flags = rbitcoin_net::local_service_flags(); + let flags = rbitcoin_net::local_service_flags_pruned(ctx.query.prune_inwit()); let svc_bits = flags.to_u64(); json!({ "version": rpc_client_version(env!("CARGO_PKG_VERSION")), diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index 70a0e1d29..3ec7bb931 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -1921,6 +1921,41 @@ fn getblock_named_verbose_genesis_and_hex() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn getblock_pruned_minus8() { + let (ctx, dir, _hub) = ctx_regtest_hub(); + dispatch(&ctx, "generate", vec![json!(2)]).unwrap(); + let genesis = dispatch(&ctx, "getblockhash", vec![json!(0)]).unwrap(); + ctx.query.set_pruneheight(Some(Height(0))).unwrap(); + let info = dispatch(&ctx, "getblockchaininfo", vec![]).unwrap(); + assert_eq!(info["pruned"], true); + assert_eq!(info["pruneheight"], 0); + let err = dispatch(&ctx, "getblock", vec![genesis.clone(), json!(0)]).unwrap_err(); + assert_eq!(err["code"], json!(-8)); + assert!(err["message"].as_str().unwrap().contains("pruned"), "{err}"); + let v1 = dispatch(&ctx, "getblock", vec![genesis.clone(), json!(1)]).unwrap(); + assert_eq!(v1["tx"].as_array().unwrap().len(), 1); + let err2 = dispatch(&ctx, "getblock", vec![genesis, json!(2)]).unwrap_err(); + assert_eq!(err2["code"], json!(-8)); + let txid = v1["tx"][0].clone(); + let rerr = dispatch(&ctx, "getrawtransaction", vec![txid]).unwrap_err(); + assert_eq!(rerr["code"], json!(-8)); + assert!( + rerr["message"].as_str().unwrap().contains("pruned"), + "{rerr}" + ); + let net = dispatch(&ctx, "getnetworkinfo", vec![]).unwrap(); + let names: Vec<&str> = net["localservicesnames"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(names.contains(&"NETWORK_LIMITED"), "{names:?}"); + assert!(!names.contains(&"NETWORK"), "{names:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn generateblock_submit_false_returns_hex_without_connecting() { let (ctx, dir, hub) = ctx_regtest_hub(); diff --git a/crates/rbitcoin-store/src/error.rs b/crates/rbitcoin-store/src/error.rs index 6bbba57d1..20e05140e 100644 --- a/crates/rbitcoin-store/src/error.rs +++ b/crates/rbitcoin-store/src/error.rs @@ -31,6 +31,10 @@ pub enum StoreError { Stale(&'static str), /// Request refused (DoS cap) — not on-disk corruption. Rejected(&'static str), + /// Class A inwit dropped at/below the prune watermark. Not corruption. + Pruned { + height: u32, + }, } impl StoreError { @@ -83,6 +87,7 @@ impl fmt::Display for StoreError { StoreError::Layout(m) => write!(f, "{m}"), StoreError::Stale(m) => write!(f, "{m}"), StoreError::Rejected(m) => f.write_str(m), + StoreError::Pruned { height } => write!(f, "pruned data at height {height}"), } } } @@ -132,6 +137,7 @@ mod tests { StoreError::Layout("inwit is on a cold datadir".into()), StoreError::Stale("chain view moved"), StoreError::Rejected("scripthash join exceeds --max-sh-creates"), + StoreError::Pruned { height: 12 }, ]; let texts: Vec = arms.iter().map(|e| e.to_string()).collect(); assert_eq!(texts[0], "invalid store magic"); @@ -148,6 +154,7 @@ mod tests { assert_eq!(texts[10], "inwit is on a cold datadir"); assert_eq!(texts[11], "chain view moved"); assert_eq!(texts[12], "scripthash join exceeds --max-sh-creates"); + assert_eq!(texts[13], "pruned data at height 12"); for e in &arms { assert!(e.source().is_none()); } diff --git a/crates/rbitcoin-store/src/lib.rs b/crates/rbitcoin-store/src/lib.rs index f56ff37ac..5cbcf0316 100644 --- a/crates/rbitcoin-store/src/lib.rs +++ b/crates/rbitcoin-store/src/lib.rs @@ -97,9 +97,9 @@ pub use tx_table::HeadResizeSizeSnapshot; pub use tx_table::{ decode_inwit_secret, decode_packed_tx_outs_with_spender_rels, decode_packed_tx_outs_with_spender_rels_secret, decode_packed_tx_with_spender_rels_secret, - encode_packed_tx, encode_packed_tx_with_secret, encode_txout_meta_and_outs, - encode_unspent_output_into_secret, spend_meta_backend, spent_abs, InputRecord, OutputRecord, - PackedCreate, TxRecord, + encode_inwit_with_secret, encode_packed_tx, encode_packed_tx_with_secret, + encode_txout_meta_and_outs, encode_unspent_output_into_secret, spend_meta_backend, spent_abs, + InputRecord, OutputRecord, PackedCreate, TxRecord, }; pub(crate) use uring_session::IoCtx; pub use uring_session::{ diff --git a/crates/rbitcoin-store/src/store.rs b/crates/rbitcoin-store/src/store.rs index cc38f1ef7..fce79a6a8 100644 --- a/crates/rbitcoin-store/src/store.rs +++ b/crates/rbitcoin-store/src/store.rs @@ -705,13 +705,19 @@ impl Store { /// Absolute `inwit.body` `(offset, len)` for `fk`. pub fn tx_inwit_range(&self, fk: Fk) -> Result<(u64, u64), StoreError> { - self.txs - .inwit_loc - .range_batch(&[fk])? - .into_iter() - .next() - .flatten() - .ok_or(StoreError::NotFound) + self.txs.inwit_range(fk) + } + + pub fn prune_inwit_mode(&self) -> bool { + self.txs.prune_inwit_mode() + } + + pub fn set_prune_inwit_mode(&self, on: bool) { + self.txs.set_prune_inwit_mode(on); + } + + pub fn clear_durable_inwit(&self) -> Result<(), StoreError> { + self.txs.clear_durable_inwit() } /// Append packed full-tx Class A rows (preferred archive path). diff --git a/crates/rbitcoin-store/src/tx_table/mod.rs b/crates/rbitcoin-store/src/tx_table/mod.rs index 6ec6129e2..758b1c03b 100644 --- a/crates/rbitcoin-store/src/tx_table/mod.rs +++ b/crates/rbitcoin-store/src/tx_table/mod.rs @@ -413,6 +413,7 @@ pub struct TxTable { pending_head: pending_head::PendingHeadInserts, rebuild_seal_bits: u32, rebuild_workers: usize, + prune_inwit_mode: std::sync::atomic::AtomicBool, } /// Structural-meta backend from env hierarchy. @@ -428,6 +429,38 @@ fn class_a_body_occupied(dir: &Path, stem: &str) -> bool { } } +fn refuse_schema15_packed_tx_body(dir: &Path) -> Result<(), StoreError> { + if dir.join("tx.body").exists() + && !dir.join("txout.body").exists() + && class_a_body_occupied(dir, "tx") + { + return Err(StoreError::Corrupt( + "schema 15 refuses packed tx.body with creates; wipe datadir and redo IBD", + )); + } + Ok(()) +} + +fn open_or_create_create_loc(dir: &Path) -> Result { + if dir.join("create.loc").exists() { + crate::create_loc::CreateLoc::open(dir) + } else if class_a_body_occupied(dir, "txout") { + Err(StoreError::Corrupt("invariant: create.loc missing")) + } else { + crate::create_loc::CreateLoc::create(dir) + } +} + +fn open_or_create_inwit_loc(inwit_dir: &Path) -> Result { + if inwit_dir.join("inwit.loc").exists() { + crate::delta_loc::DeltaLoc::open(inwit_dir, "inwit") + } else if class_a_body_occupied(inwit_dir, "inwit") { + Err(StoreError::Corrupt("invariant: inwit.loc missing")) + } else { + crate::delta_loc::DeltaLoc::create(inwit_dir, "inwit") + } +} + fn unlink_leftover_class_a_idx(dir: &Path) -> Result<(), StoreError> { for stem in ["txout", "spent", "inwit"] { let p = dir.join(format!("{stem}.idx")); @@ -445,6 +478,84 @@ fn unlink_leftover_class_a_idx(dir: &Path) -> Result<(), StoreError> { Ok(()) } +fn repair_class_a_count_skew( + create_loc: &crate::create_loc::CreateLoc, + inwit_loc: &crate::delta_loc::DeltaLoc, + body: &VarTable, + spent: &VarTable, + inwit: &VarTable, + txids: &crate::txid_body::TxidBody, + prune_inwit_mode: bool, +) -> Result<(), StoreError> { + let n_loc = create_loc.count(); + let n_txids = txids.count(); + let n_inwit_loc = inwit_loc.count(); + if n_txids == n_loc && (prune_inwit_mode || n_inwit_loc == n_loc) { + return Ok(()); + } + let n = if prune_inwit_mode { + n_loc.min(n_txids) + } else { + n_loc.min(n_txids).min(n_inwit_loc) + }; + rbitcoin_log::warn!( + "store: Class A count skew loc={n_loc} inwit.loc={n_inwit_loc} \ + txid.body={n_txids} — truncating to {n}" + ); + let (tx_end, sp_end, in_end) = if n == 0 { + let h = crate::file::FILE_HEADER_LEN as u64; + (h, h, h) + } else { + let p = create_loc + .range_batch(&[Fk(n)])? + .into_iter() + .next() + .flatten() + .ok_or(StoreError::Corrupt("invariant: loc range for truncate"))?; + let in_end = if prune_inwit_mode { + crate::file::FILE_HEADER_LEN as u64 + } else { + let ir = inwit_loc + .range_batch(&[Fk(n)])? + .into_iter() + .next() + .flatten() + .ok_or(StoreError::Corrupt( + "invariant: inwit.loc range for truncate", + ))?; + ir.0.saturating_add(ir.1) + }; + ( + p.txout.0.saturating_add(p.txout.1), + p.spent.0.saturating_add(p.spent.1), + in_end, + ) + }; + create_loc.truncate_to_count(n)?; + if !prune_inwit_mode { + inwit_loc.truncate_to_count(n)?; + } + body.truncate_body_to(n, tx_end)?; + spent.truncate_body_to(n, sp_end)?; + if !prune_inwit_mode { + inwit.truncate_body_to(n, in_end)?; + } + if n_txids > n { + txids.truncate_to_count(n)?; + } + if body.count() != txids.count() || create_loc.count() != txids.count() { + return Err(StoreError::Corrupt( + "Class A stem counts still mismatch after repair (reindex required)", + )); + } + if !prune_inwit_mode && inwit_loc.count() != txids.count() { + return Err(StoreError::Corrupt( + "Class A stem counts still mismatch after repair (reindex required)", + )); + } + Ok(()) +} + impl TxTable { pub fn create(dir: &Path) -> Result { Self::create_with_opts(dir, HeadOpenOpts::MAINNET) @@ -500,6 +611,7 @@ impl TxTable { pending_head: pending_head::PendingHeadInserts::new(), rebuild_seal_bits: seal_bits, rebuild_workers: workers, + prune_inwit_mode: std::sync::atomic::AtomicBool::new(false), }) } @@ -552,14 +664,8 @@ impl TxTable { inwit_dir: &Path, opts: HeadOpenOpts, ) -> Result { - if dir.join("tx.body").exists() - && !dir.join("txout.body").exists() - && class_a_body_occupied(dir, "tx") - { - return Err(StoreError::Corrupt( - "schema 15 refuses packed tx.body with creates; wipe datadir and redo IBD", - )); - } + let prune_inwit_mode = false; + refuse_schema15_packed_tx_body(dir)?; let (seal_bits, workers) = Self::resolve_open_opts(opts); unlink_leftover_class_a_idx(dir)?; if inwit_dir != dir { @@ -569,22 +675,10 @@ impl TxTable { let had_txout = dir.join("txout.body").exists(); let had_inwit = inwit_dir.join("inwit.body").exists(); let had_spent = dir.join("spent.body").exists(); - let create_loc = if dir.join("create.loc").exists() { - crate::create_loc::CreateLoc::open(dir)? - } else if class_a_body_occupied(dir, "txout") { - return Err(StoreError::Corrupt("invariant: create.loc missing")); - } else { - crate::create_loc::CreateLoc::create(dir)? - }; - let inwit_loc = if inwit_dir.join("inwit.loc").exists() { - crate::delta_loc::DeltaLoc::open(inwit_dir, "inwit")? - } else if class_a_body_occupied(inwit_dir, "inwit") { - return Err(StoreError::Corrupt("invariant: inwit.loc missing")); - } else { - crate::delta_loc::DeltaLoc::create(inwit_dir, "inwit")? - }; + let create_loc = open_or_create_create_loc(dir)?; + let inwit_loc = open_or_create_inwit_loc(inwit_dir)?; let loc_count = create_loc.count(); - if inwit_loc.count() != loc_count { + if !prune_inwit_mode && inwit_loc.count() != loc_count { return Err(StoreError::Corrupt("invariant: inwit.loc count")); } let body = if had_txout { @@ -592,14 +686,19 @@ impl TxTable { } else { VarTable::create_body_only(dir, "txout", TableKind::TxOut)? }; - if had_txout && loc_count > 0 && (!had_inwit || !had_spent) { + if had_txout && loc_count > 0 && (!had_spent || (!had_inwit && !prune_inwit_mode)) { return Err(StoreError::Corrupt( "schema 15 Class A missing inwit/spent for existing txout creates; wipe + IBD \ (or --datadir-cold if inwit is on a cold volume)", )); } let inwit = if had_inwit { - VarTable::open_body_only(inwit_dir, "inwit", TableKind::Inwit, loc_count)? + let in_count = if prune_inwit_mode { + inwit_loc.count() + } else { + loc_count + }; + VarTable::open_body_only(inwit_dir, "inwit", TableKind::Inwit, in_count)? } else { VarTable::create_body_only(inwit_dir, "inwit", TableKind::Inwit)? }; @@ -613,56 +712,15 @@ impl TxTable { } else { crate::txid_body::TxidBody::create(dir)? }; - let n_loc = create_loc.count(); - let n_txids = txids.count(); - let n_inwit_loc = inwit_loc.count(); - if n_txids != n_loc || n_inwit_loc != n_loc { - let n = n_loc.min(n_txids).min(n_inwit_loc); - rbitcoin_log::warn!( - "store: Class A count skew loc={n_loc} inwit.loc={n_inwit_loc} \ - txid.body={n_txids} — truncating to {n}" - ); - let (tx_end, sp_end, in_end) = if n == 0 { - let h = crate::file::FILE_HEADER_LEN as u64; - (h, h, h) - } else { - let p = create_loc - .range_batch(&[Fk(n)])? - .into_iter() - .next() - .flatten() - .ok_or(StoreError::Corrupt("invariant: loc range for truncate"))?; - let ir = inwit_loc - .range_batch(&[Fk(n)])? - .into_iter() - .next() - .flatten() - .ok_or(StoreError::Corrupt( - "invariant: inwit.loc range for truncate", - ))?; - ( - p.txout.0.saturating_add(p.txout.1), - p.spent.0.saturating_add(p.spent.1), - ir.0.saturating_add(ir.1), - ) - }; - create_loc.truncate_to_count(n)?; - inwit_loc.truncate_to_count(n)?; - body.truncate_body_to(n, tx_end)?; - spent.truncate_body_to(n, sp_end)?; - inwit.truncate_body_to(n, in_end)?; - if n_txids > n { - txids.truncate_to_count(n)?; - } - if body.count() != txids.count() - || create_loc.count() != txids.count() - || inwit_loc.count() != txids.count() - { - return Err(StoreError::Corrupt( - "Class A stem counts still mismatch after repair (reindex required)", - )); - } - } + repair_class_a_count_skew( + &create_loc, + &inwit_loc, + &body, + &spent, + &inwit, + &txids, + prune_inwit_mode, + )?; let n_bodies = create_loc.count(); let mut need_rebuild = false; let head = if !crate::segmented_head::head_meta_exists(dir) { @@ -737,6 +795,7 @@ impl TxTable { pending_head: pending_head::PendingHeadInserts::new(), rebuild_seal_bits: seal_bits, rebuild_workers: workers, + prune_inwit_mode: std::sync::atomic::AtomicBool::new(prune_inwit_mode), }; if need_rebuild { let bits = t.head_bits(); @@ -778,6 +837,22 @@ impl TxTable { Ok(t) } + pub fn prune_inwit_mode(&self) -> bool { + self.prune_inwit_mode + .load(std::sync::atomic::Ordering::Acquire) + } + + pub fn set_prune_inwit_mode(&self, on: bool) { + self.prune_inwit_mode + .store(on, std::sync::atomic::Ordering::Release); + } + + pub fn clear_durable_inwit(&self) -> Result<(), StoreError> { + self.inwit_loc.truncate_to_count(0)?; + self.inwit + .truncate_body_to(0, crate::file::FILE_HEADER_LEN as u64) + } + /// Seal unsealed non-tails from Class A (crash/restart). Keys are not retained. fn seal_unsealed_nontail_from_body(&self) -> Result<(), StoreError> { let n_body = self.count(); @@ -959,6 +1034,9 @@ impl TxTable { &self, jobs: &mut [crate::IdxBodyJob], ) -> Result<(), StoreError> { + if self.prune_inwit_mode() { + return Ok(()); + } let mut need = Vec::new(); let mut slots = Vec::new(); for (i, j) in jobs.iter().enumerate() { @@ -1025,6 +1103,9 @@ impl TxTable { /// /// Used by load: discover parents without full parse into RAM. pub fn get_meta_and_prevouts(&self, fk: Fk) -> Result<(TxRecord, Vec<(Fk, u32)>), StoreError> { + if self.prune_inwit_mode() { + return Err(StoreError::NotFound); + } let mut tx = self.get(fk)?; let inwit = { let ir = self @@ -1227,6 +1308,9 @@ impl TxTable { /// `inwit.body` range for one create. pub fn inwit_range(&self, fk: Fk) -> Result<(u64, u64), StoreError> { + if self.prune_inwit_mode() { + return Err(StoreError::NotFound); + } self.inwit_loc .range_batch(&[fk])? .into_iter() @@ -1234,6 +1318,7 @@ impl TxTable { .flatten() .ok_or(StoreError::NotFound) } + pub fn spent_range(&self, fk: Fk) -> Result<(u64, u64), StoreError> { self.create_loc_range_batch(&[fk])? .into_iter() @@ -1562,6 +1647,9 @@ impl TxTable { &self, fk: Fk, ) -> Result<(TxRecord, Vec, Vec), StoreError> { + if self.prune_inwit_mode() { + return Err(StoreError::NotFound); + } let pair = self .create_loc_range_batch(&[fk])? .into_iter() @@ -1589,6 +1677,9 @@ impl TxTable { /// Contiguous create_fks `first..=last`: one libc span each of `txout.body` /// and `inwit.body`, plus `txid.body` range. Not the confirm uring pipeline. pub fn get_full_span(&self, first: u64, last: u64) -> Result, StoreError> { + if self.prune_inwit_mode() { + return Err(StoreError::NotFound); + } if first == 0 { return Err(StoreError::InvalidFk); } @@ -1758,7 +1849,7 @@ impl TxTable { .map(|(_tx, _ins, outs)| 16 + outs.len() * OutputRecord::SPENT_SLOT_LEN) .sum(); let base = self.body.count(); - if self.inwit.count() != base || self.spent.count() != base { + if (!self.prune_inwit_mode() && self.inwit.count() != base) || self.spent.count() != base { return Err(StoreError::Corrupt("Class A stem count mismatch on append")); } if items.iter().any(|(_, _, outs)| outs.is_empty()) { @@ -1825,7 +1916,7 @@ impl TxTable { .map(|(pin, _ins)| 16 + spent_record_len(pin.packed_n_out()) as usize) .sum(); let base = self.body.count(); - if self.inwit.count() != base || self.spent.count() != base { + if (!self.prune_inwit_mode() && self.inwit.count() != base) || self.spent.count() != base { return Err(StoreError::Corrupt("Class A stem count mismatch on append")); } for (i, (pin, _)) in items.iter().enumerate() { @@ -1896,17 +1987,9 @@ impl TxTable { let Some(p_out) = self.body.prepare_batch_encode(n, est_out, encode_out)? else { return Ok((Vec::new(), Vec::new())); }; - let Some(p_in) = self.inwit.prepare_batch_encode(n, est_inwit, encode_in)? else { - return Err(StoreError::Corrupt("Class A inwit prepare empty")); - }; let Some(p_sp) = self.spent.prepare_batch_encode(n, est_spent, encode_sp)? else { return Err(StoreError::Corrupt("Class A spent prepare empty")); }; - crate::var_table::write_prepared_bodies_one_wave(&[ - (&self.body, &p_out), - (&self.inwit, &p_in), - (&self.spent, &p_sp), - ])?; let tx_lens = p_out.aligned_lens(); let mut recs = Vec::with_capacity(n); let mut loc = Vec::with_capacity(n); @@ -1924,6 +2007,31 @@ impl TxTable { n_out: n_outs[i], }); } + + if self.prune_inwit_mode() { + crate::var_table::write_prepared_bodies_one_wave(&[ + (&self.body, &p_out), + (&self.spent, &p_sp), + ])?; + self.create_loc.append(&recs)?; + let fks = self.body.finish_prepared(p_out)?; + let fks_sp = self.spent.finish_prepared(p_sp)?; + if fks != fks_sp { + return Err(StoreError::Corrupt( + "Class A append fk mismatch across stems", + )); + } + return Ok((fks, loc)); + } + + let Some(p_in) = self.inwit.prepare_batch_encode(n, est_inwit, encode_in)? else { + return Err(StoreError::Corrupt("Class A inwit prepare empty")); + }; + crate::var_table::write_prepared_bodies_one_wave(&[ + (&self.body, &p_out), + (&self.inwit, &p_in), + (&self.spent, &p_sp), + ])?; self.create_loc.append(&recs)?; self.inwit_loc.append(&p_in.starts, &p_in.aligned_lens())?; let fks = self.body.finish_prepared(p_out)?; diff --git a/docs/personal-node-plans/09-inwit-prune.md b/docs/personal-node-plans/09-inwit-prune.md index 87c846dd5..456fd9264 100644 --- a/docs/personal-node-plans/09-inwit-prune.md +++ b/docs/personal-node-plans/09-inwit-prune.md @@ -2,11 +2,11 @@ ## Goal -A home node can drop **Class A `inwit` (scriptSig + witness + input prevout -encoding)** for creates below a prune watermark, reclaiming the ~486 GiB cold -stem ([`SCHEMA.md`](../../SCHEMA.md)), while still: +A home node can stop serving **Class A inwit** (scriptSig + witness + input +prevout encoding) below a 288-height watermark. Unpruned nodes keep +`inwit.body`. Pruned nodes serve only the kept window, while still: -1. Confirming new blocks (IBD and tip write `inwit` first, drop later). +1. Confirming new blocks (each connected height is recorded in the window). 2. Reorging within the kept window. 3. Serving the last **288 heights** on P2P (BIP159 **`NODE_NETWORK_LIMITED`**). 4. Serving wallet **scripthash / UTXO / status / outspend / vout** paths that @@ -16,11 +16,19 @@ This is **not** Core `-prune` of entire `blk*.dat` files. We keep headers, `txout`, `spent`, `txid.body`, SH, tweaks. We drop old **inputs and witnesses**. -**Layout:** a **rolling 288-height inwit stem** (append a segment, unlink the -oldest). Not `FALLOC_FL_PUNCH_HOLE` on the genesis-length `inwit.body`. -Orphan / stale / side-branch blocks at kept heights mean **more than 288 -inwit blocks** on disk — the pin is 288 **heights** behind tip, not 288 -records. +**Layout:** two modes, not a rolling `inwit.body`. + +- **Unpruned** (default): Class A `inwit.body` is the witness archive. + Reconstruct, getdata, and wire RPC read it. +- **`--prune-inwit`:** witness below the watermark is not served. The last + **288 heights** are one file each, `store/inwit.window/{height}.bin`, plus + a RAM cache of those heights capped by + `--prune-inwit-ram-threshold-bytes` (default 256 MiB). **`0` keeps nothing + in RAM** — every height, including tiny IBD blocks, is read back from its + file. `pruneheight = tip - 288` once `tip > 288`. Kept heights are + `h > pruneheight`. A reorg at a kept height replaces that height's file. + Disconnect at or below `pruneheight` fails closed. No `SCHEMA_VERSION` + bump: the mode is the `{store}/inwit.prune` sidecar. **JSON vs wire:** serve **honest partial objects** (vout/status/txids we still have). Refuse **wire** (hex/raw/P2P block/tx) rather than invent vin, fee, @@ -50,87 +58,26 @@ Today we advertise `NETWORK|WITNESS|P2P_V2` and tests pin **no** advertises `NETWORK_LIMITED|WITNESS|P2P_V2`. DNS/seed **desire** for IBD still asks `NETWORK` (full) peers; VERSION bits we *offer* are limited. -## Rolling inwit window (not punch) - -### Why punch is the wrong tool here - -`inwit.body` is one append-only Class A stem. Loc is **stride-8** and records -are packed (empty inwit is an **8-byte** zero pad; -[`SCHEMA.md`](../../SCHEMA.md)). `TableFile::zero_range` already has Linux -`FALLOC_FL_PUNCH_HOLE` (`KEEP_SIZE`). - -That primitive is a poor reclaim path for this table: - -| Fact | Consequence | -|------|-------------| -| Punch is **filesystem-block aligned** (4 KiB typical) | A few-hundred-byte inwit record shares a page with neighbors. Per-tx (or even per-small-block) punch **cannot** free one create without eating live bytes next to it, or else no-ops on a sub-block range. | -| One 486 GiB file, monotone `inwit.loc` abs | Per-record punch would split the extent tree into millions of holes. Journal + `fiemap` bloat; `stat`/`cp`/`tar` see a still-huge sparse file. | -| SSD cost | Punch is TRIM of those extents, not a rewrite of the payload, so NAND write amp is not “rewrite 486 GiB”. The churn is **FS metadata** (extent map, journal) and FTL mapping updates. Coarse one-shot prefix punch is tolerable; **per-tx punch is not**. | -| Logical size stays 486 GiB | Sparse `KEEP_SIZE` never shrinks `st_size`. Backups and NAS copies often expand the holes. | -| Home-node IBD with prune on | We must **never allocate** 486 GiB in the first place. Punching a file we should not have grown is the wrong shape. | - -A single aligned prefix punch of `[0, first_kept_off)` **after** a full archive -would reclaim `st_blocks` in one syscall. That is still a 486 GiB sparse inode -forever, and it does not help prune-from-genesis. **Do not ship that as the -production layout.** - -### Contract: append tail, unlink head - -Keep ≥ **288 connected heights** of inwit as a **small rolling log** -(watermark is height, not a count of inwit blocks): - -- Confirm still encodes full inwit on the Class A write thread and **appends** - to the current segment (fallocate grow + pwrite + publish HWM — same - `TableFile` discipline as today). -- When **every** height in a segment is `≤ pruneheight`, **unlink** the file - (background; not the confirm hot path). The OS drops the inode and TRIMs - the whole object. One create, one sequential write, one unlink per dropped - segment. A stale sibling at a still-kept height keeps the file. -- Window size on disk: ~486 GiB / ~900k blocks × 288 ≈ **150–400 MiB** of - inwit on a linear chain (witness era toward the high end). Orphans at kept - heights add more (see below). A few files, not a sparse 486 GiB stem. - -**288 heights ≠ 288 inwit blocks.** `pruneheight` is -`tip.saturating_sub(288 + buffer)` — a **height**. BIP159 / reorg need inwit -for every create whose **connected height** is `> pruneheight`. A reorg -leaves the disconnected (orphan / stale / side-branch) block’s inwit in -Class A until that *height* falls out of the window. Two blocks at the same -kept height are two inwit payloads. The rolling stem therefore often holds -**more than 288 blocks of inwit** in order to keep **288 heights** behind -tip. Do **not** unlink because “we already have 288 inwit blocks” while a -kept height still has a stale sibling. Do not size the window by counting -active-chain blocks only. - -**Segment grain (pin in SCHEMA, not both):** size-capped sealed files -(**64 MiB** target, or the current height if a single block is larger), not -one inode per height (288 main-chain heights is fine for the kernel; fewer -files is less open/stat noise on reconstruct of a whole block). Unlink only -when **every** height in the file is `≤ pruneheight`, including orphan -heights in that file (may hold up to ~64 MiB extra). - -**Loc:** SCHEMA bump. Global monotone abs into one `inwit.body` cannot -survive unlink of the prefix. Below watermark: loc **sentinel** (not a live -span) so `get_tx_full` is `QueryError::Pruned` rather than `Corrupt`. Kept -window: loc is **segment id + local off** (or height → segment map + per-file -loc). Unexpected hole **inside** the window → `Corrupt("invariant: …")`. - -**Enable on an existing archive:** sequential **copy every inwit whose -height is `> pruneheight`** (active chain **and** orphans in that height -window) from the old `inwit.body` into the rolling stem, then **unlink** the -genesis-length file. Do not punch it. A linear-chain copy is ~200 MiB; extra -stale blocks in the window add more. Refuse leftover single-stem -`inwit.body` without the new sidecar/schema (same commit as the format -code). - -**Prune-from-genesis / IBD:** write only the rolling stem; never grow a -historical body. Peak inwit bytes ≈ height window (plus orphan inwit at -those heights) + one in-flight segment. - -**`--datadir-cold`:** rolling files live with today’s cold inwit, not the hot -txout/spent dir. - -**Non-Linux:** unlink is portable. No punch fallback, no 1 MiB zero-fill of -old records. +## Kept window (288 height files + RAM) + +Unpruned nodes read `inwit.body`. Pruned nodes serve witness only from the +window: + +- Confirm writes `store/inwit.window/{height}.bin` for the connected height. +- RAM holds those heights while the encoded input bytes stay under + `--prune-inwit-ram-threshold-bytes`. **`0` skips RAM** and every lookup + reads the height file. +- When the tip moves, files with `height <= pruneheight` are removed. + `pruneheight` is `tip - 288` (a height, not a count of files). +- Enabling prune on an archive that already has `inwit.body` copies the kept + heights into the window once. After that, serving does not depend on a + rolling stem. A datadir that already has `inwit.prune` and is opened + without `--prune-inwit` refuses to start. +- `--datadir-cold` still places `inwit.body` on the cold path. The window + stays under the hot store next to `inwit.prune`. + +Do not punch `inwit.body`, and do not replace this window with a rolling +segment log. The operator choice is the full stem, or the 288-height window. ## Partial JSON vs 404 / `pruned` @@ -158,17 +105,14 @@ vout-only object that still claims to be `transaction.get`. - **No silent wipe.** Durable `pruneheight` in store meta / sidecar. Same commit as the format code ([`SCHEMA.md`](../../SCHEMA.md) bump **or** a named sidecar with refuse-on-mismatch). -- Keep **≥ 288 heights** of inwit (`tip - pruneheight >= 288`, plus a small - reorg buffer — Core keeps extra; pin **288 min heights**, extra is operator - `--prune-buffer` default 0 or 144). The **number of inwit blocks** in that - window is **≥ 288** and **greater** when orphan / stale blocks share those - heights. Watermark and unlink are by height, not by block count. +- Keep **288 heights** of witness (`pruneheight = tip - 288` once the tip is + above that). The window is those height files and the RAM cache, not a + count of orphan blocks. - Reorg that would disconnect **at or below** `pruneheight` → fail closed (Core: cannot reorg pruned). Do not invent undo from `spent` alone. -- Confirm/IBD still **writes** full inwit. Drop is a background unlink after - tip. Named `ibd: perf` timer if the walker joins write — prefer a - **non-write-thread** unlink so no timer; if it takes the Class A appender, - add the timer in the same commit. +- Confirm still records the height file (and RAM, unless the cap is 0) as + the block connects. Dropping a height is deleting `{height}.bin` once it + falls out of the window. - COMPAT “Pruning / GUI | Not supported” becomes “inwit prune / NETWORK_LIMITED; not Core `-prune` of headers/txout”. @@ -271,29 +215,23 @@ Heights **above** the watermark behave as today. Below: table. - **Verify:** `cargo test -p rbitcoin-query reconstruct_pruned_` - **Done when:** the [cycle](../how-we-plan.md#the-cycle-red--green--refactor) closed and the slice is committed -### Step 2 — Durable pruneheight + rolling inwit segments - -- **Contract:** `--prune-inwit` / conf (default **off**). When on, after tip - connect, watermark = `tip.saturating_sub(288 + buffer)` (**height**, not - a count of inwit blocks). SCHEMA bump: inwit is a rolling segment dir - (64 MiB sealed files under cold inwit), loc is window-relative, - below-watermark sentinel. Walker **unlinks** segments wholly `≤ pruneheight` - (every height in the file, including orphans). Kill-safe: watermark - advances only after those unlinks. Reopen restores watermark + open - segments. Leftover genesis-length `inwit.body` without the new layout → - refuse (OPERATOR: copy-tail then unlink on first pruned open, or refuse - and tell the operator). Confirm appends only to the live segment. A kept - height with an orphan sibling keeps **both** inwit payloads. +### Step 2 — Durable pruneheight + 288 height files + +- **Contract:** `--prune-inwit` / conf (default **off**). `{store}/inwit.prune` + is a 4-byte LE `pruneheight` (`u32::MAX` = on, nothing dropped yet; missing + file = off). After the tip passes 288 heights, `pruneheight = tip - 288`. + Kept witness is `store/inwit.window/{height}.bin` plus the RAM cache. + `--prune-inwit-ram-threshold-bytes 0` writes every height and retains none + in RAM. No schema bump. Reopen restores the sidecar. A pruned datadir + opened without the flag refuses to start. Enabling prune on an existing + archive seeds the window from `inwit.body` for the kept heights. - **Red:** `prune_watermark_survives_reopen`; - `unlink_segment_below_watermark_then_pruned`; - `kept_window_inwit_still_reconstructs`; - `prune_during_ibd_does_not_grow_historical_inwit_stem`; - `kept_288_heights_retains_orphan_inwit` — reorg at a kept height; both - blocks reconstruct; inwit block count **>** the height window. -- **Green:** store meta/sidecar; background unlink (not confirm hot path). -- **Refactor:** no second inwit encoding; no punch path on this table. -- **Verify:** `cargo test -p rbitcoin-store prune_inwit_` ; - `cargo test -p rbitcoin-query prune_watermark_` + `prune_ram_window_drops_fks_on_disconnect_and_replace`; + `prune_ram_threshold_zero_spills_tiny_blocks`; + spill symlink outside the window is `Corrupt`. +- **Green:** sidecar + height files + RAM cache. +- **Refactor:** no rolling `inwit.body`, no punch path on this table. +- **Verify:** `cargo test -p rbitcoin-query prune_` - **Done when:** the [cycle](../how-we-plan.md#the-cycle-red--green--refactor) closed and the slice is committed ### Step 3 — Reorg below pruneheight fail-closed @@ -348,27 +286,24 @@ Heights **above** the watermark behave as today. Below: table. ### Step 6 — OPERATOR / COMPAT / SCHEMA / rpc.md + NixOS module -- **Contract:** OPERATOR `--prune-inwit`, 288-**height** window (orphan - inwit can make the block count larger), NETWORK_LIMITED, - rolling cold inwit (unlink, not punch), cannot reorg through pruneheight, - archive convert = copy tail + unlink old stem. COMPAT prune row. +- **Contract:** OPERATOR `--prune-inwit`, 288-height files under + `inwit.window/`, RAM cap (`0` = files only), `NETWORK_LIMITED`, cannot + reorg through pruneheight. COMPAT prune row. `getblockchaininfo` fields. SCHEMA/sidecar bytes. Partial Esplora JSON. Do not copy this file into quality.md until scheduled. Module: - `pruneInwit` (and buffer if the CLI has one); `coldDataDir` already - exists. Eval asserts `--prune-inwit`. Runtime label only if tmpfiles / - `ReadWritePaths` change. + `pruneInwit`; `coldDataDir` already exists. Eval asserts `--prune-inwit`. + Runtime label only if tmpfiles / `ReadWritePaths` change. - **Red:** eval assert for `--prune-inwit`. - **Green:** module + eval + those doc owners. - **Verify:** `nix build .#checks.x86_64-linux.nixos-module-eval --no-link`; - grep `NETWORK_LIMITED`, `prune-inwit`, `inwit` segment. + grep `NETWORK_LIMITED`, `prune-inwit`, `inwit.window`. - **Done when:** the [cycle](../how-we-plan.md#the-cycle-red--green--refactor) closed and the slice is committed ## Test budget -Tiny `/tmp` chains (keep window 2–4 **heights** in tests, production 288 -heights; include one orphan so inwit block count exceeds the height window). -No mainnet open. One P2P notfound + reconstruct-serve of a kept tip. Unlink -tests on all OS (not Linux-only punch). +Tiny `/tmp` chains (production keep is 288 heights). No mainnet open. One +P2P notfound + reconstruct-serve of a kept tip. Height-file removal runs on +every OS. ## Risks / follow-ups diff --git a/docs/personal-node-plans/README.md b/docs/personal-node-plans/README.md index 713a975b5..59061d8cf 100644 --- a/docs/personal-node-plans/README.md +++ b/docs/personal-node-plans/README.md @@ -29,9 +29,10 @@ A node on CGNAT / no forwarded TCP still: 3. Serves **Electrum and Esplora** on Tor onion (plain TCP; no in-binary TLS). 4. Announces locally submitted txs on a **short-lived isolated Tor circuit**, not on the standing peer set. -5. Optionally **prunes Class A `inwit`** below a 288-**height** window - (orphan blocks at those heights keep extra inwit) and advertises BIP159 - `NODE_NETWORK_LIMITED` ([09](./09-inwit-prune.md)). +5. Optionally **prunes witness** below a 288-height window. Kept heights are + one file each plus a RAM cache (`0` means files only). The node advertises + BIP159 `NODE_NETWORK_LIMITED` ([09](./09-inwit-prune.md)). Unpruned nodes + keep `inwit.body`. ## Constraints (all numbered files) @@ -122,7 +123,7 @@ any PR. **07** can land as soon as **01 + 03** exist. **08** can land as soon as | [06-ephemeral-tor-broadcast.md](./06-ephemeral-tor-broadcast.md) | Isolated SOCKS one-shot for locally submitted txs | | [07-p2p-onion-inbound.md](./07-p2p-onion-inbound.md) | P2P `--listen-onion`: ADD_ONION → loopback BIP324 accept | | [08-cjdns.md](./08-cjdns.md) | BIP155 CJDNS, `--cjdns-reachable`, `--only-net=cjdns` | -| [09-inwit-prune.md](./09-inwit-prune.md) | Rolling 288-**height** `inwit` (unlink, not punch; orphans ⇒ more than 288 inwit blocks); BIP159 `NETWORK_LIMITED`; partial Esplora JSON, refuse wire | +| [09-inwit-prune.md](./09-inwit-prune.md) | Unpruned `inwit.body`, or a 288-height window (`inwit.window/{height}.bin` + RAM; `0` = files only); BIP159 `NETWORK_LIMITED`; partial Esplora JSON, refuse wire | NixOS first-class options (same PR as the flags). Label **`nixos-module-runtime`** when the row says runtime: diff --git a/docs/rpc.md b/docs/rpc.md index 2e518d4b2..334401778 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -83,11 +83,11 @@ still wait for durable SH when shindex is on. |--------|-------| | `help` / `getrpcinfo` / `uptime` / `stop` | Control | | `echo` | Testing RPC. Returns arguments as a positional array. AuthServiceProxy `{args: [...], argN: ...}` is peeled only here. Mixed `submitpackage`/`sendrawtransaction`/`testmempoolaccept` `{args, maxfeerate}` is expanded in the Core-functional proxy, not on the node. | -| `getblockchaininfo` / `getblockcount` / `getbestblockhash` / `getblockhash` | Chain tip. `getblockcount` / `getbestblockhash` wait for the in-flight tip-accept job (not the rest of a catch-up burst). `headers` is the best known header height (`submitheader` / P2P headers may lead `blocks`). `chainwork` is summed header work (regtest 2 per block). `size_on_disk` is a walk of `{datadir}/store` file lengths (plus `--datadir-cold` inwit when split). `verificationprogress` is `blocks / headers` clamped to `[0, 1]` (`1.0` when `headers` is 0). `initialblockdownload` is the Core RPC name for **relay-inhibited**: `--min-chain-work` and `--max-tip-age` after densify/`enter_tip_mode`, not “still catching up”. | -| `getblockheader` / `getblock` (verbosity 0/1/2) | Archive reconstruct. `getblockheader` includes `chainwork`. | -| `getblockstats` | All networks. Reconstruct the block; Core named keys `hash_or_height` / `stats`. Fees from archive prevouts. Genesis excluded from actual UTXO counts. OP_RETURN unspendable. Reconstruct miss is `block body not in store`. Dummy `blk00000.dat` is shim-only so `rpc_getblockstats.py`'s rename-file needle stays Core-phrased. | +| `getblockchaininfo` / `getblockcount` / `getbestblockhash` / `getblockhash` | Chain tip. `getblockcount` / `getbestblockhash` wait for the in-flight tip-accept job (not the rest of a catch-up burst). `headers` is the best known header height (`submitheader` / P2P headers may lead `blocks`). `chainwork` is summed header work (regtest 2 per block). `size_on_disk` is a walk of `{datadir}/store` file lengths (plus `--datadir-cold` inwit when split). `verificationprogress` is `blocks / headers` clamped to `[0, 1]` (`1.0` when `headers` is 0). `initialblockdownload` is the Core RPC name for **relay-inhibited**: `--min-chain-work` and `--max-tip-age` after densify/`enter_tip_mode`, not “still catching up”. `--prune-inwit` sets `pruned: true` and `pruneheight` (omitted when off). | +| `getblockheader` / `getblock` (verbosity 0/1/2) | Archive reconstruct. `getblockheader` includes `chainwork`. Verbosity 0/2 below `pruneheight` is `-8` `Block not available (pruned data)`; verbosity 1 (txids) stays. | +| `getblockstats` | All networks. Reconstruct the block; Core named keys `hash_or_height` / `stats`. Fees from archive prevouts. Genesis excluded from actual UTXO counts. OP_RETURN unspendable. Reconstruct miss is `block body not in store`. Dummy `blk00000.dat` is shim-only so `rpc_getblockstats.py`'s rename-file needle stays Core-phrased. Pruned height is `-8` Core pruned text. | | `getdifficulty` | From tip bits | -| `getnetworkinfo` / `getconnectioncount` / `getpeerinfo` | BIP324 v2-only; `getpeerinfo` is the live session table. `timeoffset` is VERSION clock minus connect time (`0` before handshake). `synced_headers` is the height of that peer's advertised best block when we know it, else `-1`. `synced_blocks` is that height when the hash is on our best chain, else `-1`. `getnetworkinfo.timeoffset` is the median of outbound handshake-complete offsets (`0` if none). `mapped_as` is present when `--asmap` / `{datadir}/ip_asn.dat` mapped the peer (Core field; omitted without a map or ASN 0). `version` is rbitcoin semver as a Core integer (`major*10000+minor*100+patch`: `0.1.0` → `100`, `0.5.0` → `500`, `0.6.0` → `600`, `0.6.99` → `699`, `0.7.0` → `700`, `0.7.99` → `799`), not a Core release. `localservices` matches advertised `NETWORK\|WITNESS\|P2P_V2`. `localaddresses` lists `--external-ip` (`score` = Core `LOCAL_MANUAL`) | +| `getnetworkinfo` / `getconnectioncount` / `getpeerinfo` | BIP324 v2-only; `getpeerinfo` is the live session table. `timeoffset` is VERSION clock minus connect time (`0` before handshake). `synced_headers` is the height of that peer's advertised best block when we know it, else `-1`. `synced_blocks` is that height when the hash is on our best chain, else `-1`. `getnetworkinfo.timeoffset` is the median of outbound handshake-complete offsets (`0` if none). `mapped_as` is present when `--asmap` / `{datadir}/ip_asn.dat` mapped the peer (Core field; omitted without a map or ASN 0). `version` is rbitcoin semver as a Core integer (`major*10000+minor*100+patch`: `0.1.0` → `100`, `0.5.0` → `500`, `0.6.0` → `600`, `0.6.99` → `699`, `0.7.0` → `700`, `0.7.99` → `799`), not a Core release. `localservices` matches advertised `NETWORK\|WITNESS\|P2P_V2`, or `NETWORK_LIMITED\|WITNESS\|P2P_V2` under `--prune-inwit`. `localaddresses` lists `--external-ip` (`score` = Core `LOCAL_MANUAL`) | | `getnettotals` | All networks. Raw TCP `totalbytesrecv` / `totalbytessent` on live sessions. `uploadtarget` is a Core-shaped stub (`target` 0). | | `ping` | All networks. Queues a ping on each live session (`null`). | | `addpeeraddress` | Hidden Core name. Inserts `{address,port}` into addrman RAM (does not rewrite `peers` per call). | @@ -95,7 +95,7 @@ still wait for durable SH when shindex is on. | `addnode` / `disconnectnode` / `addconnection` | All networks. `addnode onetry` / `add` dial; `disconnectnode` by `nodeid` or address | | `getmempoolinfo` / `getrawmempool` / `getmempoolentry` | MempoolHub. `maxmempool` is the operator weight budget (`--mempool-size-mb`). `ancestorcount` / `descendantcount` (and size/fee sums) walk the cluster graph. Verbose `fees.{base,modified,ancestor,descendant,chunk}` and `chunkweight` include `prioritisetransaction` deltas; top-level `ancestorfees` / `descendantfees` stay base satoshis. `unbroadcastcount` / `unbroadcast` track `sendrawtransaction` txs until a peer getdata's them. `orphanage.{size,bytes}` is the parked missing-parent side pool (vsize). `permitbaremultisig` is always `true` (Libre has no Core `IsStandard` bare-multisig gate; `--permitbaremultisig` is not a node flag). | | `getorphantxs` | Hidden operator dump. Verbosity 0 txids, 1 details + `from` peer ids, 2 + hex. Not listed by `help` / `getrpcinfo`. Counts-only `getmempoolinfo.orphanage` is not a substitute. | -| `getrawtransaction` | Class A + mempool. Optional Core `blockhash` arg is accepted and ignored. Verbose objects share `tx_to_json` with `decoderawtransaction` / `getblock` verbosity 2 (`scriptSig`, `scriptPubKey.type`). | +| `getrawtransaction` | Class A + mempool. Optional Core `blockhash` arg is accepted and ignored. Verbose objects share `tx_to_json` with `decoderawtransaction` / `getblock` verbosity 2 (`scriptSig`, `scriptPubKey.type`). Below `pruneheight`: `-8` `Transaction not available (pruned data)` (not `-5`). | | `decoderawtransaction` | All networks. Decode hex. Optional `iswitness`: `false` refuses a BIP141 marker (`-22 TX decode failed`). Extra trailing bytes also `-22`. `scriptSig.asm` is rust-bitcoin, not Core `ScriptToAsmStr` sighash suffixes. Coinbase vin is `txid`/`vout`/`scriptSig` (not Core's `coinbase` key). | | `decodescript` | All networks. `asm`, Core-style `type`, `hex`, and `address` when `Address::from_script` succeeds. No `p2sh` wrap, `segwit` wrap, or `desc` / miniscript. | | `validateaddress` | All networks. Valid: `isvalid`, `address`, `scriptPubKey`, `isscript`, `iswitness`, plus `witness_version` / `witness_program` when segwit. Invalid (parse fail or wrong chain): `{isvalid: false}` only — no `error` / `error_locations`. | diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 19a971a6f..96af4e881 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -99,6 +99,7 @@ let ++ optional cfg.i2p.acceptIncoming "--i2p-accept-incoming" ++ optional cfg.cjdns.reachable "--cjdns-reachable" ++ optional cfg.esplora.hiddenService "--esplora-onion" + ++ optional cfg.pruneInwit "--prune-inwit" ++ cfg.extraArgs; in { @@ -176,6 +177,12 @@ in description = "Build and serve the BIP-352 silent-payment tweak index."; }; + pruneInwit = mkOption { + type = types.bool; + default = false; + description = "Drop Class A inwit below tip-288 heights and advertise NETWORK_LIMITED. Not Core -prune of headers/txout."; + }; + environment = mkOption { type = types.attrsOf types.str; default = { }; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 8d31fd602..01e43eb0d 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -45,6 +45,7 @@ let acceptIncoming = true; }; cjdns.reachable = true; + pruneInwit = true; p2p = { address = "127.0.0.1"; openFirewall = true; @@ -150,6 +151,7 @@ assert builtins.match ".*--i2p-sam 127.0.0.1:7656.*" execStart != null; assert builtins.match ".*--i2p-accept-incoming.*" execStart != null; assert builtins.match ".*--listen-onion.*" execStart != null; assert builtins.match ".*--cjdns-reachable.*" execStart != null; +assert builtins.match ".*--prune-inwit.*" execStart != null; assert builtins.elem "tor.service" service.after; assert builtins.elem "tor.service" service.wants; assert builtins.elem "i2pd.service" service.after;