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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ before 1.0).

### Fixed

- **Esplora mempool-only tx JSON includes vin/vout/size/weight:** Class A
miss uses the mempool wire body (`GET /tx`, `/txs`, `/txs/mempool`, WS
address-transactions). No more txid+fee stub.

- **Mempool `relay_seq` / `accept_at` drop on unindex:** confirm, RBF, and
eviction no longer leak per-admit INV maps for the process lifetime.

Expand Down
4 changes: 2 additions & 2 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ via reverse proxy; app `ServeLimits` always on (same model as Electrum).
| Tip | done | `/blocks/tip/height`, `/blocks/tip/hash`. REST stamps `X-Bitcoin-Chain-Tip` / `X-Bitcoin-Chain-Tip-Height` (CORS-exposed): **live tip** for block/tx/header routes; **SH watermark** for `/address/` and `/scripthash/` so wallet JSON matches the SH join. Empty chain omits them (existing 503). If the pin dies mid-request: **503** `chain view moved`. |
| Blocks list | done | `/blocks`, `/blocks/:start_height` (10 summaries, newest-first) |
| Block | done | `/block/:hash` JSON, `/raw`, `/status`, `/header`, `/txids`, `/txid/:i`, `/txs[/:start]`. JSON `bits` is the compact-target **u32** (Esplora schema, not Core hex). `size` / `weight` are BIP144 total size and BIP141 weight (witness included). |
| Tx | done | `/tx/:txid` full JSON, `/hex`, `/raw`, `/status`, Electrum `/merkle-proof`, BIP37 `/merkleblock-proof`, `/outspend(s)`. `?asof=<hash>` on `/status` and `/outspend(s)`: confirmed/spent as of that ancestor; 404 if not on chain. |
| Address / scripthash | done | stats + `/utxo` + `/txs` + `/txs/mempool` + `/txs/chain[/:last_seen_txid]`; `/utxo` matches Electrum listunspent (mempool funding + drop mempool-spent confirmed); `/txs` from SH join fks; last SH join reused across sequential REST calls until SH-view **hash** changes; needs SH finalize. Stamp is visible SH (durable + pending write-behind), matching live tip while jobs sit in RAM. `?asof=<hash>` on `/`, `/utxo`, `/txs`, `/txs/chain`: confirmed join at that ancestor **at or behind visible SH**, **no** mempool; headers are the asof hash; 404 if not on chain or ahead of visible SH. |
| Tx | done | `/tx/:txid` full JSON, `/hex`, `/raw`, `/status`, Electrum `/merkle-proof`, BIP37 `/merkleblock-proof`, `/outspend(s)`. Mempool-only txs (not in Class A) use the wire body from the mempool hub (`vin`/`vout`/`size`/`weight`/`fee`, `status.confirmed` false). `?asof=<hash>` on `/status` and `/outspend(s)`: confirmed/spent as of that ancestor; 404 if not on chain. |
| Address / scripthash | done | stats + `/utxo` + `/txs` + `/txs/mempool` + `/txs/chain[/:last_seen_txid]`; `/utxo` matches Electrum listunspent (mempool funding + drop mempool-spent confirmed); `/txs` and `/txs/mempool` use full Esplora tx JSON for mempool-only rows (wire from the hub). Last **one** SH join reused across sequential REST calls until SH-view **hash** changes; concurrent different SHs re-join. Needs SH finalize. Stamp is visible SH (durable + pending write-behind), matching live tip while jobs sit in RAM. `?asof=<hash>` on `/`, `/utxo`, `/txs`, `/txs/chain`: confirmed join at that ancestor **at or behind visible SH**, **no** mempool; headers are the asof hash; 404 if not on chain or ahead of visible SH. |
| Mempool / fees | done | `/mempool`, `/mempool/txids`, `/mempool/recent` (accept-order ring), `/fee-estimates` |
| `POST /tx` | done | broadcast via mempool hub; **503** if hub absent |
| `POST /txs/package` | done | JSON array of hex txs → `accept_package`; **503** without hub; max 25 txs |
Expand Down
78 changes: 44 additions & 34 deletions crates/rbitcoin-esplora/src/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
//! Esplora route handlers beyond tip/header/basic tx.

use crate::server::{
block_hash_hex, maybe_attach_view, not_found, parse_hash32, pin_or_reject, plain_ok, store_err,
AppState, AsOf,
block_hash_hex, maybe_attach_view, mempool_wire, not_found, parse_hash32, pin_or_reject,
plain_ok, store_err, AppState, AsOf,
};
use crate::tx_json::{
build_tx_json, build_tx_json_from_tx, history_items_to_tx_json, tx_status_json_in,
utxo_list_json,
};
use crate::tx_json::{build_tx_json, history_items_to_tx_json, tx_status_json_in, utxo_list_json};
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
Expand All @@ -14,7 +17,7 @@ use bitcoin::address::Address;
use bitcoin::consensus::{deserialize, encode::serialize, Encodable};
use bitcoin::hashes::Hash;
use bitcoin::pow::{CompactTarget, Target};
use bitcoin::{MerkleBlock, Network};
use bitcoin::{MerkleBlock, Network, Txid};
use rbitcoin_primitives::{median_time_past_times, Height};
use rbitcoin_query::{ChainViewKind, HistoryFilter, Query, ScriptHashChainStats};
use rbitcoin_store::{script_hash, StoreError};
Expand Down Expand Up @@ -347,7 +350,15 @@ pub async fn tx_raw(State(st): State<AppState>, Path(txid_hex): Path<String>) ->
.into_response(),
Err(e) => store_err(e),
},
Ok(None) => not_found(),
Ok(None) => match mempool_wire(&st, &txid) {
Some(tx) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/octet-stream")],
bitcoin::consensus::serialize(&tx),
)
.into_response(),
None => not_found(),
},
Err(e) => store_err(e),
}
})
Expand Down Expand Up @@ -817,21 +828,7 @@ fn combined_txs(st: &AppState, sh: &[u8; 32], asof: Option<[u8; 32]>) -> Respons
};
let mut out = Vec::new();
if asof.is_none() {
if let Some(mp) = st.mempool.as_ref() {
for item in mp.scripthash_mempool(sh).into_iter().take(50) {
if let Ok(Some((fk, _))) = st.query.get_tx_by_txid(&item.txid) {
if let Ok(v) = build_tx_json(&st.query, fk, st.network) {
out.push(v);
continue;
}
}
out.push(json!({
"txid": block_hash_hex(&item.txid),
"status": { "confirmed": false },
"fee": item.fee,
}));
}
}
out.extend(mempool_txs_json(st, sh));
}
let filter = HistoryFilter::esplora_chain_page(None);
let resp = if asof.is_some() {
Expand Down Expand Up @@ -986,24 +983,37 @@ pub async fn address_txs_mempool(
}
}

fn mempool_txs_for_sh(st: &AppState, sh: &[u8; 32]) -> Response {
fn mempool_txs_json(st: &AppState, sh: &[u8; 32]) -> Vec<Value> {
let Some(mp) = st.mempool.as_ref() else {
return Vec::new();
};
let mut out = Vec::new();
if let Some(mp) = st.mempool.as_ref() {
for item in mp.scripthash_mempool(sh).into_iter().take(50) {
if let Ok(Some((fk, _))) = st.query.get_tx_by_txid(&item.txid) {
if let Ok(v) = build_tx_json(&st.query, fk, st.network) {
out.push(v);
continue;
}
for item in mp.scripthash_mempool(sh).into_iter().take(50) {
if let Ok(Some((fk, _))) = st.query.get_tx_by_txid(&item.txid) {
if let Ok(v) = build_tx_json(&st.query, fk, st.network) {
out.push(v);
continue;
}
out.push(json!({
"txid": block_hash_hex(&item.txid),
"status": { "confirmed": false },
"fee": item.fee,
}));
}
let txid = Txid::from_byte_array(item.txid);
let Some(tx) = mp.get_tx(&txid) else {
continue;
};
if let Ok(v) = build_tx_json_from_tx(
&st.query,
&tx,
st.network,
Some(item.fee),
Some(mp.as_ref()),
) {
out.push(v);
}
}
Json(out).into_response()
out
}

fn mempool_txs_for_sh(st: &AppState, sh: &[u8; 32]) -> Response {
Json(mempool_txs_json(st, sh)).into_response()
}

pub async fn post_tx(State(st): State<AppState>, body: Bytes) -> Response {
Expand Down
130 changes: 127 additions & 3 deletions crates/rbitcoin-esplora/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Esplora HTTP listener (axum + tower limits) and wallet WebSocket live path.

use crate::handlers;
use crate::tx_json::{build_tx_json, tx_status_json_in};
use crate::tx_json::{build_tx_json, build_tx_json_from_tx, tx_status_json_in};
use crate::ws;
use axum::extract::{FromRequestParts, Path, Query as AxumQuery, Request, State};
use axum::http::request::Parts;
Expand Down Expand Up @@ -527,7 +527,19 @@ async fn tx_full(State(st): State<AppState>, Path(txid_hex): Path<String>) -> Re
Ok(v) => Json(v).into_response(),
Err(e) => store_err(e),
},
Ok(None) => not_found(),
Ok(None) => match mempool_wire(&st, &txid) {
Some(tx) => match build_tx_json_from_tx(
&st.query,
&tx,
st.network,
None,
st.mempool.as_deref(),
) {
Ok(v) => Json(v).into_response(),
Err(e) => store_err(e),
},
None => not_found(),
},
Err(e) => store_err(e),
}
})
Expand All @@ -545,7 +557,13 @@ async fn tx_hex(State(st): State<AppState>, Path(txid_hex): Path<String>) -> Res
Ok(raw) => plain_ok(rbitcoin_primitives::hex_encode(raw)),
Err(e) => store_err(e),
},
Ok(None) => not_found(),
Ok(None) => match mempool_wire(&st, &txid) {
Some(tx) => {
let raw = bitcoin::consensus::serialize(&tx);
plain_ok(rbitcoin_primitives::hex_encode(raw))
}
None => not_found(),
},
Err(e) => store_err(e),
}
})
Expand Down Expand Up @@ -628,6 +646,12 @@ pub(crate) fn parse_hash32(s: &str) -> Result<[u8; 32], ()> {
Ok(out)
}

pub(crate) fn mempool_wire(st: &AppState, txid: &[u8; 32]) -> Option<bitcoin::Transaction> {
use bitcoin::hashes::Hash;
let tid = bitcoin::Txid::from_byte_array(*txid);
st.mempool.as_ref().and_then(|m| m.get_tx(&tid))
}

fn encode_header_hex(hdr: &bitcoin::block::Header) -> Result<String, String> {
let mut buf = Vec::with_capacity(80);
hdr.consensus_encode(&mut buf)
Expand Down Expand Up @@ -2240,4 +2264,104 @@ mod tests {
handle.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
}

/// Mempool-only txs (not in Class A) must be full Esplora JSON, not a stub.
#[tokio::test]
async fn mempool_only_tx_json_has_vin_vout_size_weight() {
use bitcoin::absolute::LockTime;
use bitcoin::hashes::Hash;
use bitcoin::transaction::Version as TxVersion;
use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness};
use rbitcoin_net::MempoolHub;
use rbitcoin_store::script_hash;

if std::env::var_os("RBITCOIN_HEAD_SCALE").is_none() {
std::env::set_var("RBITCOIN_HEAD_SCALE", "tiny");
}
let (dir, q) = temp_query("mp-tx-json");
let mut prev = Fk::NULL;
let mut parent_hash: Option<[u8; 32]> = None;
let mut coinbase_txids = Vec::new();
for h in 0..101u32 {
let (header, ta) = coinbase(h, prev, parent_hash);
parent_hash = Some(header.hash);
coinbase_txids.push(ta.tx.txid);
prev = q.connect_block(Height(h), &header, &[ta]).unwrap();
}
let q = Arc::new(q);
let mp_dir = dir.join("mp");
std::fs::create_dir_all(&mp_dir).unwrap();
let hub = MempoolHub::open(&mp_dir, Arc::clone(&q)).unwrap();
hub.set_relay_enabled(true);
let spend = Transaction {
version: TxVersion::TWO,
lock_time: LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint {
txid: bitcoin::Txid::from_byte_array(coinbase_txids[0]),
vout: 0,
},
script_sig: ScriptBuf::new(),
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: Witness::new(),
}],
output: vec![TxOut {
value: Amount::from_sat(50_0000_0000 - 1_000),
script_pubkey: ScriptBuf::from_bytes(vec![0x51]),
}],
};
hub.accept_tx(&spend).expect("accept mempool spend");
let txid_hex = display_txid(spend.compute_txid());
let sh_hex = block_hash_hex(&script_hash(&[0x51]));

let cfg = EsploraConfig::with_network("127.0.0.1:0".parse().unwrap(), Network::Regtest);
let handle = run_esplora(cfg, Arc::clone(&q), Some(Arc::clone(&hub)), None)
.await
.expect("listen");
let addr = handle.local_addr;

let (st, body) = http_get(addr, &format!("/scripthash/{sh_hex}/txs/mempool")).await;
assert_eq!(st, 200, "{body}");
let arr: serde_json::Value = serde_json::from_str(&body).unwrap();
let row = arr
.as_array()
.unwrap()
.iter()
.find(|r| r["txid"] == txid_hex);
let row = row.expect("mempool list contains spend");
assert!(row.get("vin").is_some(), "stub omitted vin: {row}");
assert!(row.get("vout").is_some(), "stub omitted vout: {row}");
assert!(row.get("size").is_some(), "stub omitted size: {row}");
assert!(row.get("weight").is_some(), "stub omitted weight: {row}");
assert_eq!(row["status"]["confirmed"], false);
assert_eq!(row["fee"], 1_000);

let (st, body) = http_get(addr, &format!("/scripthash/{sh_hex}/txs")).await;
assert_eq!(st, 200, "{body}");
let arr: serde_json::Value = serde_json::from_str(&body).unwrap();
let row = arr
.as_array()
.unwrap()
.iter()
.find(|r| r["txid"] == txid_hex)
.expect("combined /txs contains spend");
assert!(row.get("vin").is_some(), "combined stub omitted vin: {row}");

let (st, body) = http_get(addr, &format!("/tx/{txid_hex}")).await;
assert_eq!(st, 200, "GET /tx mempool-only: {body}");
let full: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(full.get("vin").is_some());
assert!(full.get("vout").is_some());
assert!(full.get("size").is_some());
assert!(full.get("weight").is_some());
assert_eq!(full["status"]["confirmed"], false);

let (st, hex_body) = http_get(addr, &format!("/tx/{txid_hex}/hex")).await;
assert_eq!(st, 200, "{hex_body}");
let (st, _) = http_get(addr, &format!("/tx/{txid_hex}/raw")).await;
assert_eq!(st, 200);

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