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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Per-method notes, auth, and the shindex matrix live in
| relayfee / estimatefee / histogram / `mempool.get_info` | done | Libre min + live median. `mempool.get_info` is 1.6 (`minrelaytxfee` replaces `relayfee` for 1.6 clients; `relayfee` stays for 1.4). |
| outpoint.get_status / subscribe / unsubscribe | done | Electrum **1.7** methods; `protocol_max` stays **1.6** until `scriptpubkey.*`. Spent = confirmed-strong or mempool. |
| silentpayments.subscribe / unsubscribe | done | Frigate remote-scanner: session-only scan key; historical + tip notifies via tweak index / naive `tweaks_for_height`. Not Cake `tweaks.subscribe`. |
| TLS | external | terminate at reverse proxy; node is plain TCP. In-binary 50002 + onion is parked **Q-63**. |
| TLS | external | terminate at reverse proxy for **clearnet**; onion is plain TCP (`ADD_ONION` to loopback). In-binary 50002 + TLS is parked **Q-63**. |

### Protocol versions

Expand Down Expand Up @@ -215,7 +215,8 @@ without the dialect is an error. `server.features.protocol_max` remains
## Esplora REST surface

Plain HTTP via `--esplora-listen` / conf `esplora_listen` (default **off**). TLS
via reverse proxy; app `ServeLimits` always on (same model as Electrum).
via reverse proxy for **clearnet**; onion is plain TCP (`ADD_ONION` to loopback).
App `ServeLimits` always on (same model as Electrum).

| Endpoint group | Status | Notes |
|----------------|--------|-------|
Expand Down
11 changes: 8 additions & 3 deletions OPERATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ Clean smoke:
| `--sp-tweaks-dust SATS` | `sp_tweaks_dust=` | **1000** — omit served P2TR outs with `value <= SATS` (`0` = serve all; **546** matches Cake electrs) |
| `--electrum-listen [ADDR]` | `electrum_listen=` | disabled; omit ADDR → `127.0.0.1:50001`. Address/scripthash methods need `--sh-index` |
| `--esplora-listen [ADDR\|PATH]` | `esplora_listen=` | disabled (Esplora REST); omit ADDR → `127.0.0.1:3000`; a filesystem path is unix HTTP (mode **0660**, dummy `Host: api` is fine). Address/scripthash methods need `--sh-index` |
| `--esplora-onion[=0\|1]` | `esplora_onion=` | **on** — `ADD_ONION` for Esplora when `--tor-control` is set |
| `--esplora-block-template` | `esplora_block_template=` | **off** — `GET /block-template` is 404; on = GBT JSON (same as RPC template mode) |
| `--rpc` | `rpc=` | **off** — unix JSON-RPC `{datadir}/rpc.sock` (mode 0600) |
| `--rpc-listen [ADDR]` | `rpc_listen=` | disabled — implies `--rpc`; omit ADDR → `127.0.0.1` and Core-matching RPC port |
Expand Down Expand Up @@ -458,9 +459,13 @@ ADDR for `127.0.0.1:9051`. Failed AUTH is a start error. Unset: no control
socket. With `--electrum-listen`, the node `ADD_ONION`s that TCP port to
`127.0.0.1:<bound>` and logs `….onion:port`. The private key is
`{datadir}/onion/electrum.priv` (0600). `server.features.hosts` is
`{ "<id>.onion": { "tcp_port": N } }` with no `ssl_port`. JSON-RPC stays off
the onion (`rpc.sock` / `--rpc-listen` only). Cookie path differs by distro;
pass `--tor-control-cookie` rather than globbing.
`{ "<id>.onion": { "tcp_port": N } }` with no `ssl_port`. With
`--esplora-listen`, the same control port `ADD_ONION`s Esplora (`{datadir}/onion/esplora.priv`);
REST and `/ws` share that TCP port (`http://….onion:<port>`). `--esplora-onion=0`
skips Esplora HS. `getnetworkinfo.localaddresses` lists those onion hostnames
even with `--no-discover`. Sparrow: `tcp://<id>.onion:50001` (plain TCP; no
in-binary TLS). JSON-RPC stays off the onion (`rpc.sock` / `--rpc-listen` only).
Cookie path differs by distro; pass `--tor-control-cookie` rather than globbing.

`--i2p-sam [HOST:PORT]` talks to **system i2pd** SAM v3 (not SOCKS, not Arti).
Omit ADDR for `127.0.0.1:7656`. Failed HELLO / `SESSION CREATE` is a start
Expand Down
29 changes: 24 additions & 5 deletions crates/rbitcoin-net/src/peers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,7 @@
peer_timeout_secs: AtomicU64,
/// Addresses we advertise (`getnetworkinfo.localaddresses`).
external_ips: Mutex<Vec<IpAddr>>,
wallet_onions: Mutex<Vec<(String, u16)>>,
/// P2P listen port used with advertised external IPs.
listen_port: AtomicU16,
/// Core `-discover`. Off: never self-announce, even with `--external-ip`.
Expand Down Expand Up @@ -1182,6 +1183,7 @@
addr_response_cache: Mutex::new(HashMap::new()),
peer_timeout_secs: AtomicU64::new(60),
external_ips: Mutex::new(Vec::new()),
wallet_onions: Mutex::new(Vec::new()),
listen_port: AtomicU16::new(0),
discover: AtomicBool::new(true),
asmap: Mutex::new(None),
Expand Down Expand Up @@ -1239,6 +1241,13 @@
*self.external_ips.lock().unwrap_or_else(|e| e.into_inner()) = ips;
}

pub fn set_wallet_onion(&self, host: String, port: u16) {
self.wallet_onions
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((host, port));
}

pub fn set_listen_port(&self, port: u16) {
self.listen_port.store(port, Ordering::Relaxed);
}
Expand All @@ -1249,22 +1258,32 @@

/// `getnetworkinfo.localaddresses` rows for operator-advertised IPs.
pub fn rpc_local_addresses(&self) -> Vec<(String, u16, i32)> {
const LOCAL_MANUAL: i32 = 4;
let mut rows: Vec<(String, u16, i32)> = self
.wallet_onions
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.cloned()
.map(|(address, port)| (address, port, LOCAL_MANUAL))
.collect();
if !self.discover.load(Ordering::Relaxed) {
return Vec::new();
return rows;
}
let port = self.listen_port.load(Ordering::Relaxed);
if port == 0 {

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

View workflow job for this annotation

GitHub Actions / mutants-pr

Missed mutant

replace == with != in PeerHub::rpc_local_addresses
return Vec::new();
return rows;
}
let ips = self
.external_ips
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
ips.into_iter()
.map(|ip| (ip.to_string(), port, LOCAL_MANUAL))
.collect()
rows.extend(
ips.into_iter()
.map(|ip| (ip.to_string(), port, LOCAL_MANUAL)),
);
rows

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

View workflow job for this annotation

GitHub Actions / mutants-pr

Missed mutant

replace PeerHub::rpc_local_addresses -> Vec<(String, u16, i32)> with vec![]
}

pub fn advertise_local_socket(&self) -> Option<SocketAddr> {
Expand Down
9 changes: 8 additions & 1 deletion crates/rbitcoin-node/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ fn operator_usage() -> String {
[--listen ADDR] [--no-listen] [--connect ADDR]... [--seed-node HOST]... [--proxy HOST:PORT] [--onion HOST:PORT] [--proxy-randomize[=0|1]] [--only-net NET]... \\\n\
[--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] \\\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\
[--rpc] [--rpc-listen [ADDR]] [--rpc-token-file PATH] [--rpc-work-queue N] \\\n\
[--milestone HEIGHT] \\\n\
Expand Down Expand Up @@ -335,6 +335,7 @@ Scripthash: --sh-index (default off) builds Class B for Electrum/Esplora address
Electrum/Esplora start without it; scripthash/address methods fail closed.\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\
Silent payments: --sp-tweaks (default off) writes/serves the thin BIP-352 tweak index.\n\
--sp-tweaks-dust SATS omits served P2TR outs with value <= SATS (default 1000; 0 = all; 546 = Cake electrs).\n\
RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer). No --rpcuser.\n\
Expand Down Expand Up @@ -376,6 +377,7 @@ fn is_bool_key(key: &str) -> bool {
"sh_index"
| "sp_tweaks"
| "esplora_block_template"
| "esplora_onion"
| "blocks_only"
| "prefill_compact"
| "persist_mempool"
Expand Down Expand Up @@ -574,6 +576,8 @@ mod tests {
"--sh-index",
"--sp-tweaks",
"--sp-tweaks-dust",
"--esplora-block-template",
"--esplora-onion",
"--rpc",
"--rpc-listen",
"--rpc-token-file",
Expand Down Expand Up @@ -1136,6 +1140,9 @@ mod tests {
assert!(gbt_eq.esplora_block_template);
let off = ready_config(["rbitcoin-node", "--esplora-block-template=0"]);
assert!(!off.esplora_block_template);
assert!(NodeConfig::default().esplora_onion);
let onion_off = ready_config(["rbitcoin-node", "--esplora-onion=0"]);
assert!(!onion_off.esplora_onion);
}

#[test]
Expand Down
7 changes: 7 additions & 0 deletions crates/rbitcoin-node/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ pub struct NodeConfig {
pub max_sh_creates: u32,
/// Opt-in Esplora `GET /block-template` (GBT template JSON). Default off.
pub esplora_block_template: bool,
/// ADD_ONION for `--esplora-listen` when `--tor-control` is set. Default on.
pub esplora_onion: bool,
/// Skip script/prevout checks for blocks at or below this height (0 = off).
pub milestone_height: u32,
/// Set when conf or CLI applied `milestone` (including 0).
Expand Down Expand Up @@ -323,6 +325,7 @@ impl Default for NodeConfig {
sptweaks_dust: rbitcoin_electrum::DEFAULT_TWEAKS_MIN_DUST,
max_sh_creates: 0,
esplora_block_template: false,
esplora_onion: true,
milestone_height: 0,
milestone_explicit: false,
inhibit_suspend: false,
Expand Down Expand Up @@ -839,6 +842,10 @@ impl NodeConfig {
self.esplora_block_template = parse_conf_bool(val)
.map_err(|e| NodeError::Config(format!("conf esplora_block_template: {e}")))?;
}
"esplora_onion" => {
self.esplora_onion = parse_conf_bool(val)
.map_err(|e| NodeError::Config(format!("conf esplora_onion: {e}")))?;
}
"rpc" => {
self.rpc.socket = parse_conf_bool(val)
.map_err(|e| NodeError::Config(format!("conf rpc: {e}")))?;
Expand Down
136 changes: 136 additions & 0 deletions crates/rbitcoin-node/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> {
h.local_addr.port()
);
let _ = electrum_onion.set((format!("{}.onion", hs.service_id), h.local_addr.port()));
node.peers
.set_wallet_onion(format!("{}.onion", hs.service_id), h.local_addr.port());
}
let (esplora_handles, esplora_tip_bridge) = start_esplora_if_ready(
sh_tip_ready,
Expand All @@ -673,6 +675,48 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> {
&mempool,
)
.await;
if config.esplora_onion {
if let (Some(ctl), Some(h)) = (tor_ctl.as_mut(), esplora_handles.first()) {
let hs = ctl
.add_esplora_onion(config.datadir.path(), h.local_addr)
.await?;
info!(
"esplora onion http://{}.onion:{} (/ws same port)",
hs.service_id,
h.local_addr.port()
);
node.peers
.set_wallet_onion(format!("{}.onion", hs.service_id), h.local_addr.port());
}
}
let mut i2p_wallet = Vec::new();
if config.listen.i2p_accept_incoming {
if let Some(addr) = config.listen.i2p_sam {
if let Some(h) = electrum_handles.first() {
i2p_wallet.push(
start_i2p_named_forward(
addr,
config.datadir.path(),
"electrum",
h.local_addr.port(),
)
.await?,
);
}
if let Some(h) = esplora_handles.first() {
i2p_wallet.push(
start_i2p_named_forward(
addr,
config.datadir.path(),
"esplora",
h.local_addr.port(),
)
.await?,
);
}
}
}
let _i2p_wallet = i2p_wallet;

let mut rpc_handle: Option<RpcHandle> = None;
if (config.rpc.socket || config.rpc.listen.is_some()) && !shutdown.requested() {
Expand Down Expand Up @@ -1336,6 +1380,23 @@ fn electrum_tip_notify(ev: TipEvent) -> Option<TipNotify> {
})
}

async fn start_i2p_named_forward(
sam_addr: SocketAddr,
datadir: &Path,
name: &str,
port: u16,
) -> Result<rbitcoin_net::I2pSam, NodeError> {
let dest = datadir.join("i2p").join(format!("{name}.priv"));
let mut sam = rbitcoin_net::I2pSam::connect_persistent(sam_addr, &dest)
.await
.map_err(|e| NodeError::Init(format!("i2p {name} session: {e}")))?;
sam.stream_forward(port)
.await
.map_err(|e| NodeError::Init(format!("i2p {name} STREAM FORWARD {port}: {e}")))?;
info!("i2p {name} STREAM FORWARD to 127.0.0.1:{port}");
Ok(sam)
}

async fn start_electrum_if_ready(
sh_tip_ready: bool,
addr: Option<SocketAddr>,
Expand Down Expand Up @@ -2615,4 +2676,79 @@ mod tests {
drop(held);
let _ = std::fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn electrum_i2p_forward_when_sam_incoming() {
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};

async fn write_line(s: &mut TcpStream, line: &str) {
s.write_all(line.as_bytes()).await.unwrap();
s.write_all(b"\n").await.unwrap();
s.flush().await.unwrap();
}
async fn read_line(s: &mut TcpStream) -> Option<String> {
let mut reader = BufReader::new(s);
let mut line = String::new();
let n = reader.read_line(&mut line).await.ok()?;
if n == 0 {
return None;
}
Some(line.trim_end_matches(['\r', '\n']).to_string())
}

let log = Arc::new(Mutex::new(Vec::new()));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let log_acc = Arc::clone(&log);
tokio::spawn(async move {
loop {
let Ok((mut s, _)) = listener.accept().await else {
break;
};
let log = Arc::clone(&log_acc);
tokio::spawn(async move {
loop {
let Some(line) = read_line(&mut s).await else {
break;
};
let up = line.to_ascii_uppercase();
if up.starts_with("HELLO VERSION") {
write_line(&mut s, "HELLO REPLY RESULT=OK VERSION=3.1").await;
} else if up.starts_with("SESSION CREATE") {
write_line(&mut s, "SESSION STATUS RESULT=OK DESTINATION=walletfake")
.await;
} else if up.starts_with("STREAM FORWARD") {
log.lock().unwrap().push(line);
write_line(&mut s, "STREAM STATUS RESULT=OK").await;
}
}
});
}
});

let dir = std::env::temp_dir().join(format!(
"rbtc-i2p-wallet-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let sam = start_i2p_named_forward(addr, &dir, "electrum", 50001)
.await
.unwrap();
drop(sam);
assert_eq!(
std::fs::read_to_string(dir.join("i2p").join("electrum.priv"))
.unwrap()
.trim(),
"walletfake"
);
let fw = log.lock().unwrap().clone();
assert_eq!(fw.len(), 1, "{fw:?}");
assert!(fw[0].contains("PORT=50001"), "{}", fw[0]);
let _ = std::fs::remove_dir_all(&dir);
}
}
Loading
Loading