Pinned reference version: Bitcoin Core v31.1 (same pin as
docs/core-functional.md and nightly fuzz).
BIP324 v2 interop. Package wire tracks BIP331 when rust-bitcoin exposes the
messages (Q-48 / RB-007).
Experimental 0.x — not a production Core or Fulcrum replacement. Design
contrasts: docs/architecture.md. Lab mainnet:
docs/experimental-mainnet.md.
A Core, Electrum, or Esplora difference that looks like a bug is often intentional. Read this file before changing shipped behavior. Name the row you are changing; do not mix unrelated dialect work into the same step.
Full P2P participant (blocks + tip-mode tx relay) and wallet-client
backends: in-process Electrum (confirmed + unconfirmed, libre-relay-class
admission) and optional Esplora-compatible REST for the same role (history,
UTXO, broadcast, block/tx fetch by id). Optional Core-class JSON-RPC subset
(see docs/rpc.md) — not full Core wallet / mining parity.
Scripthash index (--sh-index) defaults off. Electrum/Esplora start without it; address/scripthash methods fail closed (scripthash index disabled). Txid/outpoint/block/broadcast/fees work. Which wallets use that surface: docs/wallets.md. Lightning chain sources: docs/lightning.md.
On/off costs and start/IBD/tip behavior: OPERATOR.md
(Scripthash index). Disable later leaves SH files on disk; follow does not
wait on SH materialize.
Goal: serve wallet software (Electrum, Sparrow, custom wallets, light clients that already know their addresses/scripthashes or exact txids/block ids).
0.8: drop-in mempool/electrs or Blockstream electrs HTTP so nginx
/api/ can retire electrs. Core JSON-RPC for that stack is TCP plus an
opt-in Core cookie (--rpc-cookie-file), which stock mempool CORE_RPC
uses unpatched; a unix-socket patch (filesystem auth) stays optional.
mempool.space Node /api/v1/ (MariaDB, cubes, mining, lightning) stays
their process. Address-prefix
search is not in 0.8 (404). Surface table below.
Non-goal (stays): address-prefix autocomplete, Liquid/assets, in-binary
mempool.space catalogue UI (/api/v1/). Opt-in GET /block-template
is GBT (same JSON as RPC), not explorer search. Block/tx by full id and
address/exact scripthash history exist so wallets, APIs, and (after 0.8)
electrs-shaped explorers can verify and sync.
--max-sh-creates N (default 10000; 0 = unlimited) refuses an unpaged Electrum/Esplora SH
joins with more than N creates: Esplora HTTP 503, Electrum JSON-RPC error
scripthash join exceeds --max-sh-creates. Stats stay full when under the cap.
--heavy-sh-creates N (default 10000; 0 = off) runs unpaged joins with
more than N creates one at a time across the node; lighter joins stay parallel.
GET …/txs/summary is a dialect route (not Blockstream Esplora API.md).
Shape and owner row: Esplora REST surface.
Product: /tx/:txid/outspend/:vout and /outspends emit Blockstream
vin (spending input index) from the schema-22 spent slot. Mempool overlay
uses the hub tx’s input index. Unspent remains {spent:false} with no vin.
Full /tx/:txid JSON still has vin[]. Electrum has no outspend-vin surface.
| Area | This node | Bitcoin Core |
|---|---|---|
| Chainstore | Relational map-free archive (fd tables; see docs/io-modality.md) |
blocks/undo + LevelDB chainstate |
| Historical blocks | Reconstruct from archive; tip via body queue + peer wire | blocks/ blk*.dat |
| Transport | BIP324 v2 only | v1 + v2 |
| Mempool structure | Cluster graph + chunks | Cluster mempool (same lineage) |
| Admission policy | Libre-relay-class (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (sendrawtransaction / testmempoolaccept / submitpackage) still rejects "version" outside 1/2. Feerate, RBF, min relay and -blockmintxfee use Core's sigop-adjusted size max(weight, sigop_cost × 20) (--bytes-per-sigop, 0 disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus --block-reserved-sigops budget (default reserve 400). P2P accept_tx has the minimum relay feerate and no absurd-fee or -maxtxfee cap; RPC submit keeps its own default of 10,000 sat/vB (0 unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks u64 overflow (bad-txns-txouttotal-toolarge), not MAX_MONEY. Block connect rejects an output or an output sum above Amount::MAX_MONEY. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect |
Standardness + policy knobs (nVersion 1/2, MAX_STANDARD_TX_SIGOPS_COST 16,000, -bytespersigop) |
| Compact blocks | BIP152 v2 receive + reconstruct + getblocktxn serve. Fill is live mempool + orphanage + extra_compact (cap 100). Outbound extra prefill is on unless --prefill-compact=0 (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) |
v1/v2 high-bandwidth + extra_txn cache (-blockreconstructionextratxn); Core #35558 prefill still unmerged |
| 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. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, 0.0.0.0/8, CGNAT, link-local, documentation including 3fff::/20, benchmarking (198.18.0.0/15 and 2001:2::/48), class E, and the matching IPv6 ranges). 6to4, well-known NAT64 64:ff9b::/96, local NAT64 64:ff9b:1::/48, IPv4-compatible ::/96, and SIIT ::ffff:0:0:0/96 stay only when the embedded IPv4 is routable and the non-IPv4 bits are zero. Teredo 2001::/32 is dropped. Addr gossip also drops port 0 except I2P (advertised with port 0), Tor v2, and CJDNS addresses outside fc00::/8. Unknown BIP155 network ids are still relayed. addpeeraddress and --connect do not. Onion, I2P, and CJDNS are not IP bogons |
Core new/tried buckets (~80k); same 1000 / 23% |
| Package submit | RPC submitpackage / Esplora POST /txs/package (no P2P package command) |
BIP331 wire |
| Pruning / GUI | Unpruned: seqsigwit.body. --prune-seqsigwit: watermark + NETWORK_LIMITED, kept witness is 288 height files under store/seqsigwit.window/ plus a RAM cap (--prune-seqsigwit-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 |
| SV2 Template Distribution | In-process TDP v2 server over Noise NX (--sv2-tp-listen, default off): NewTemplate / SetNewPrevHash per tip, NewTemplate on a same-tip fee gain (--sv2-tp-fee-delta, --sv2-tp-template-interval, sv2-tp defaults), RequestTransactionData, SubmitSolution → block accept. No templates during IBD; every template on the tip kept, up to 64 per session. No plaintext, no pool / JD roles |
No TDP in Core; sv2-apps bitcoin-core-sv2 bridges Core's IPC mining interface to TDP |
| Wallets | Electrum clients (requires --sh-index) |
Descriptor + legacy |
| Scripthash index | Optional (--sh-index, default off); bulk at tip when on |
External ElectrumX / Fulcrum; Core -txindex is different (txid→block) |
| JSON-RPC | Documented subset (docs/rpc.md); Bearer token or opt-in Core cookie (--rpc-cookie-file), no --rpcuser / --rpcpassword; rbitcoin-cli. --rpc-work-queue defaults to 16 (HTTP occupancy, 503 when full; 0 is that default); a JSON-RPC array is one POST |
Full Core RPC; -rpcworkqueue is in-flight HTTP jobs (503) |
| GetData serve | Every hash is served in order. While 16 (MAX_SERVE_BLOCKS) getdata bodies or 4 MiB sit in the peer's queue (RAM cap), the rest waits on the session and no new message is read; none is dropped. Pings and the ping timeout still run |
Core ProcessGetData pauses on fPauseSend and serves the rest before the next message |
| Inbound eviction victim | After Core-shaped protect (netgroup / recent block / recent tx / min-ping / 8 longest-connected), disconnect the newest inbound in the largest remaining netgroup. A fully protected set rejects the new inbound | Core SelectNodeToEvict youngest in the oldest netgroup |
--sp-tweaks-dust |
Serve-time floor default 1000 sat (omit P2TR outs value <= floor). 546 matches Cake electrs. Not Cake/Electrum protocol |
n/a (Electrum tweaks are not Core) |
| IBD empty-headers EOF | Empty headers to our locator + idle path latches headers_done even if a peer advertises a taller less-work height / junk version.start_height. A proven header walk at the tip that every taller peer failed to extend also ends IBD |
Core header sync follows most-work; advertised start_height is not a remaining header count |
| BIP94 | Not implemented. There is no testnet4 chain and no -test=bip94. A retarget multiplies the parent, and a difficulty-boundary timestamp only has to be after the median of ancestor timestamps. This does not fork mainnet, testnet3, signet, or default regtest |
On testnet4, and on regtest only with -test=bip94, a retarget multiplies the first block of the period (that parent may be a min-difficulty exception) and a difficulty-boundary timestamp more than 600 seconds before its parent is time-timewarp-attack |
| Milestone script skip | A height-only --milestone HEIGHT skips scripts at and below that height with no anchor hash, including when the header lookup is empty. The default anchor skips once two height-to-hash lookups match: this block, and the anchor hash at the anchor height. It does not walk parents, does not read min_work_be, and does not require two weeks of burial. Prevouts, double spends, maturity, and fees still run. An output whose script starts with OP_RETURN, or is longer than 10,000 bytes, is still not a coin under the skip |
Core runs scripts unless the assumevalid block is an ancestor, the best header is on that chain, chain work meets nMinimumChainWork, and the proof-equivalent time from the assumevalid block to the best header is more than two weeks |
| Invalid marks across restart | A block that failed connect on a consensus rule stays invalid across restart (store/block.invalid, the newest 4096). An operator invalidateblock does not: it lasts until the process exits |
Both live in the block index (BLOCK_FAILED_VALID) and survive restart |
--trust-peer ADDR |
Default off. One peer (see docs/operator/operations.md). Checks still run; this is not a faster script skip. The waiver applies only to a block or transaction from that peer's current session. A warn-and-apply fault logs trust-peer: applied block <hash> height <h>: <reason> or trust-peer: applied tx <txid>: <reason> (the same reason text as today's reject). A script fault's applied line, and a lookup or load structural warn, are written only after that block connects. A header fault's applied line is written when that header check is kept. The fault is kept: script failure where scripts run; header median time, future time, and version; BIP34; signet solution, including a signet block that does not parse (signet: no witness commitment or a malformed solution; encoding a signet commitment stays a hard error); non-final locktime; sigop limit; subsidy mismatch; BIP68; immature coinbase; BIP30 overwrite (the new outputs are the ones a later spend resolves). Mempool script, immature, non-final, and BIP68 faults from that session stay through reorg eviction and a replacement that does not commit. Opening with the mode off removes those transactions. If a marked body or a descendant of it cannot be removed, open fails and the mark stays. The mark is loaded again only while the mode stays on. A block template skips a marked transaction, and a locally submitted block rechecks that transaction's script. A local non-final transaction is rejected. Two warnable faults in one block are two lines and one connect. The trusted session is not selected as a BIP152 high-bandwidth peer. A hostname used as --trust-peer or --connect keeps one current socket: a later resolution replaces it and disconnects the previous socket when no other current target still uses it. Proof-of-work (high-hash, bad-diffbits, a target above the pow limit) is not applied: the header is not stored and chain work does not move. A body that does not match its header (merkle root, witness commitment, witness nonce, or witness bytes before segwit) is not applied, stays refetchable, and is not marked permanently failed. Other hard rejects are unchanged: undecodable bytes; empty or mis-shaped block; duplicate txid; oversize weight; missing or already-spent prevout; duplicate input; same-block future spend; unspendable or genesis-coinbase spend; a value that does not fit a u64 fee. A missing confirmed mempool parent still leaves the pool. Local submit, submitblock, getblocktemplate proposals, SV2 block submission, sendrawtransaction, Electrum broadcast, and Esplora POST /tx stay strict and do not log a trust-peer: line. A wrong proposal prev stays inconclusive-not-best-prevblk. With --proxy or --onion, this node's own transactions are still announced on that one session. Isolated broadcast stays off, so no other peer is dialed and no other peer's transactions are waived. Standardness, dust, minimum relay fee, replace-by-fee, package limits, negative fee, and a missing parent stay policy rejects and are not trust-peer: lines. Flag off: those faults still reject, high-bandwidth selection is unchanged, and the log has no trust-peer: substring. getnetworkinfo.warnings and getblockchaininfo.warnings contain one string naming this peer while the mode is on, and omit that string when the mode is off. That string does not run -alertnotify. The template warnings key holds that string while the mode is on, and is absent when the array would be empty. The node will store and serve a chain it considers invalid |
Core has no single-peer warn-and-apply mode |
| Equal-work fork choice | A connected tip is replaced only by strictly greater chain work, or by preciousblock at equal work. After invalidateblock, equal work prefers precious, then the earlier held tip. Header ranking before either chain is connected also stays with the chain already chosen when work is equal (docs/architecture.md layer 1, “equal work keeps the first seen”). A block that was stored earlier but was not yet connectable does not take the tip later |
When work is equal, Core prefers the block seen first (nSequenceId). A block stored earlier but not yet connectable becomes the tip once it can be connected, even if a later equal-work block is already the tip. Neither rule makes a block valid on one side and invalid on the other |
Compact reconstruct fills short-ids from the live mempool graph, the
orphanage, and a small extra_compact ring (cap 100): RBF-replaced and
min-relay-rejected bodies, mempool removals (confirm/evict), inbound
cmpctblock prefills, and blocktxn bodies. Coinbase is skipped. That is
still not Core’s full -blockreconstructionextratxn cache of every recently
seen wire tx. Libre admission (0.1 sat/vB, no dust, full RBF) keeps more
bodies live than Core standardness, so a larger extra-txn ring would hit
less often than on Core — not never. Eviction still misses the short-id map
and costs a getblocktxn when the ring has already rolled off. Growing the
ring to Core’s extra-txn shape is worth later; not scheduled (no Open Q-id).
Inbound tx whose prevouts are spent or missing on a confirmed create is
MissingPrevout, not an orphan park. INV AlreadyHave is live mempool +
orphanage + a recent-confirmed txid/wtxid ring (filled at tip connect and IBD write) +
Class A tx_fk_by_txid_tip. Re-delivery of an already-parked orphan still
GETDATAs missing parents (TTL) but does not log a second txrelay: park.
Inbound cmpctblock may prefill any well-formed indexes (BIP152). We always
log reconstruct fill sources and fetched= blocktxn bytes, and outbound
cmpct announce … prefill=N/bytes when we send cmpctblock (tip announce or
MSG_CMPCT_BLOCK getdata). Sending extra prefills (beyond coinbase) is on
unless --prefill-compact=0. A pending compact owns the first-pass slot
bodies (mempool / extra / orphan hits). blocktxn overlays only the missing
indexes; apply does not re-walk a live short-id map.
Per-method notes, auth, and the shindex matrix live in
docs/rpc.md. Group status (intentional scope):
| Method group | Status |
|---|---|
Control (help, uptime, stop, getrpcinfo, echo) |
done (syncwithvalidationinterfacequeue omitted; functional proxy no-op for Core sync_mempools) |
Blockchain (getblockchaininfo, getblockcount, getbestblockhash, getblockhash, getblock/header, getdifficulty, getblockstats) |
done (getblockstats from txstat when stamped; size and count fields match Core, including utxo_increase_actual; omit coins-DB utxo_size_*) |
Network (getnetworkinfo, getconnectioncount, getpeerinfo, addnode, disconnectnode, addconnection) |
done (BIP324 v2-only; getnetworkinfo.version is the fixed Core 0.19 client integer 190000, semver stays in subversion, protocolversion is 70016; peer timeoffset / synced_* from session state; hostname addnode / --connect resolve at dial and retry until live) |
Mempool / rawtx (getmempool*, getrawtransaction, sendrawtransaction, testmempoolaccept) |
done (Libre; RPC maxfeerate / maxburnamount / "version" only) |
Coin / MiniWallet (gettxout) |
done |
Index / tips (getindexinfo, getchaintips, waitforblock*) |
done (txindex = Class A reconstruct) |
Fee (estimatesmartfee) |
done (10-minute inclusion — not Core historical) |
Decode (decoderawtransaction, decodescript, validateaddress) |
done (node subset; official Core dialect scripts stay rpc-dialect) |
Regtest generatetoaddress / generatetodescriptor / generateblock / generate / submitblock / setmocktime |
harness (generate* also on signet whose challenge is exactly OP_TRUE; setmocktime / mockscheduler stay regtest; submitblock is all networks) |
invalidateblock / reconsiderblock / preciousblock |
done |
Mining template (getblocktemplate, getmininginfo, prioritisetransaction, getmempoolcluster) |
done (no stratum / BIP9 testdummy / wallet keys) |
Wallet RPC; createrawtransaction / combinerawtransaction; gettxoutsetinfo; scantxoutset |
never (no UTXO set, no descriptor scan). The functional harness answers MiniWallet raw() scantxoutset from Esplora; it does not implement descriptors or gettxoutsetinfo |
| Method | Status | Notes |
|---|---|---|
| server.version / banner / features | done | Banner: libre-relay-class. server.version[0] is rbitcoin-electrs <workspace.package.version> electrumx — not electrs; see below. server.version negotiates: omitted → 1.6; "1.4" → 1.4; ["1.4","1.4.2"] → 1.4.2; ["1.4","1.6"] → 1.6; "1.4.2-asof" (or a range containing it) → as-of dialect. First call wins. features.protocol_max is 1.6; features.asof_protocol is 1.4.2-asof. features.genesis_hash is display-order hex (refuse a wrong-chain server before a tweaks scan). features.tweaks / silent_payments advertise the method; they are not a substitute for the stream. |
| JSON-RPC batch | done | A line holding a JSON array is a JSON-RPC 2.0 batch: one array line back, same order, each slot {id, result} or {id, error}. Read-only items (scripthash get_history / get_balance / listunspent / get_mempool, transaction.get / get_merkle / id_from_pos, block.header(s), estimatefee, outpoint.get_status, mempool.get_fee_histogram / get_info) run up to half the node's blocking pool at a time (at least 1; 2 on a 4-thread pool), and each reports only its own dispatch time; any other item waits for every earlier slot, then runs alone, so a subscribe is seen by the items after it. Slots are written in request order as soon as each is answered, and slot i is written before read i + window starts. A notification (no id member) is skipped: it is not run and gets no slot, and a batch of only notifications writes nothing. A non-object item gets a -32600 Invalid Request slot. [] is one -32600 empty batch; more than 345 items is one -32600 batch too large. blockchain.tweaks.subscribe and blockchain.silentpayments.subscribe stream several lines, so inside a batch they get a -32600 slot; send them alone. |
| blockchain.tweaks.subscribe | done | Stream, not a one-shot result: JSON-RPC result is the first height only; remaining heights are unsolicited notifications; {"message":"done"} ends the chunk (60s wall at a wave boundary, or the requested count if sooner) — Cake electrs clamps count to 1000 instead. Cake Wallet resubscribes from syncHeight+1 (cake_wallet#3574 persist still lags one event). kiss-bdk today treats done as the asked range finished (kiss-bdk#10). Each height carries tweak + txid + taproot output_pubkeys (client scans locally; no block fetch). Naive walk, or --sp-tweaks thin index (len:tweak only; one txout span per wave; the first height shares that span when indexed). Pre-taproot: one notify with ≤1024 empty height keys (Cake fromJson last key is progress); probe [0,1,false] stays {"0": {}}. Param [2] (Cake historicalMode): false cut-through (omit confirmed-spent P2TR outs / txs); true keeps spent outs. Serve-time --sp-tweaks-dust (default 1000) omits P2TR outs with value <= the floor (0 = all; 546 matches Cake electrs sp_min_dust). Sparrow Silent Payments uses Frigate blockchain.silentpayments.subscribe (server-side scan) — not this method. Cake isolate may still hardcode electrs.cakewallet.com. |
| headers / block headers | done | Tip push on subscribe |
| scripthash history / balance / listunspent | done | Unconf when mempool attached; get_history optional BCH-style from_height / exclusive to_height (-1 = tip + mempool); 1-arg = full history; a from_height above 0 scans that block window and is served above --max-sh-creates (open history, and a window that starts at height 0, still refuse above the cap); unconfirmed get_history rows include fee (same as get_mempool); listunspent mempool height is 0 or -1 (unconfirmed parent); subscribe status always full; blockchain.scripthash.unsubscribe returns whether the connection was watching (frees a slot under --electrum-max-subs, default 10000 per connection). listunspent loads txid.body only for unspent creates; one TCP connection reuses the last SH outs+spent join until SH-view hash changes. Confirmed methods stamp live tip: a RAM SH head (pending write-behind) joins with durable SH so mempool can drop confirmed txs without a hole. Durable Class B seed waits until after tip announce. get_history skips mempool rows already in the confirmed list. server.features.chain_tip = true. Trailing asof:<blockhash> after the official args (server.features.asof / asof_protocol = 1.4.2-asof): confirmed rows as of that still-live ancestor at or behind visible SH (durable + pending), no mempool; stamp is the asof block; unknown hash or ahead of visible SH → asof not on chain. Prefix keeps it off the future positional-string landmine. Requires negotiated 1.4.2-asof (first server.version only). Electrum protocol_max is 1.6. |
| scripthash.get_mempool / subscribe | done | Status on mempool announce and when SH applies a height that creates or spends the hash (posting-list probe; no Class A expand on a miss). Headers subscribe still live tip. Reorg (TipNotify.reorg_from_height) restatuses every watch even if the new block misses the script. Status preimage is the protocol's txid:height: for every row: Electrum desktop recomputes it from get_history and drops the history on a mismatch. A same-height replace that keeps every (txid, height) keeps the status; the chain_tip stamp carries that A-B-A. Row order is confirmed height-asc then mempool tail, same as get_history. A script with no history has status null (subscribe reply and pushes), never "": wallets read any non-null status as a used address. unsubscribe is implemented. |
| transaction.get / get_merkle | done | get falls back to mempool unless asof:; confirmed responses stamp chain_tip. Verbose get is electrs-shaped: time/blocktime/confirmations/blockhash from the confirming header (mempool verbose is confirmations: 0 with no block stamp), plus vin/vout/size/version/locktime/hash (wtxid) decoded from the hex already loaded. Trailing asof:<blockhash> (same dialect as scripthash): get returns the tx only if confirmed at or behind that ancestor (no mempool); get_merkle rejects a height above the asof pin (asof not on chain). id_from_pos is a txid string; third arg merkle=true returns {tx_hash, merkle} (same walk as get_merkle). |
| transaction.broadcast | done | Mempool accept + P2P inv. broadcast_package wraps accept_package (Electrum 1.6; P2P BIP331 still Q-48). |
relayfee / estimatefee / histogram / mempool.get_info |
done | minrelaytxfee is the configured minimum. relayfee and mempoolminfee are the enforced floor. incrementalrelayfee stays 100 sat/kvB. Estimates are the 10-minute flow/history product (docs/mempool-fee-estimation.md). mempool.get_info is 1.6 (minrelaytxfee replaces relayfee for 1.6 clients; relayfee stays for 1.4). |
| outpoint.get_status / subscribe / unsubscribe | done | Electrum 1.7 methods; protocol_max stays 1.6 until scriptpubkey.*. Spent = confirmed-strong or mempool. |
| silentpayments.subscribe / unsubscribe | done | Frigate remote-scanner: session-only scan key; historical + tip notifies via tweak index / naive tweaks_for_height. Not Cake tweaks.subscribe. |
| TLS | external | terminate at reverse proxy for clearnet; onion is plain TCP (ADD_ONION to loopback). In-binary 50002 + TLS is parked Q-63. |
features.protocol_max is 1.6 (plus dialect 1.4.2-asof).
Electrum 4.8 wallets speak 1.4–1.6; ElectrumX advertises 1.7. block.headers
is concatenated hex for 1.4.x and a headers list for 1.6.
scriptpubkey.* still missing — do not advertise 1.7 yet.
P2P BIP331 package messages remain Q-48. Local Electrum
broadcast_package uses the same accept_package as RPC/Esplora.
We are not electrs. Cake Wallet getNodeIsElectrs() lowercases
version[0] and requires the substring electrs before it will call
blockchain.tweaks.subscribe. Sparrow sends JSON-RPC batches when the
lowercased version[0] contains electrumx (or starts with electrs/ at
0.9.0 or later); otherwise it sends one request per line. BlueWallet stops
batching when version[0] starts with electrs (case-sensitive) unless
the first word is exactly electrs at 0.9.0 or later. The first element is
therefore rbitcoin-electrs <ver> electrumx (ver from
workspace.package.version): Cake will probe tweaks, Sparrow will batch,
and BlueWallet keeps batching. Other tweaks clients (kiss-bdk) do not need that
substring; they discover the chain via server.features.genesis_hash.
Cake isolate may still hardcode electrs.cakewallet.com after a passing
probe.
blockchain.tweaks.subscribe is a client-side BIP-352 scan: the
server never sees a scan key. A client that treats the JSON-RPC call as
request/response reads one height and stops. Sparrow’s Silent Payments
path talks Frigate blockchain.silentpayments.subscribe (scan key on
the server, RAM-only for the session). That RPC is implemented on the
tweak index (or naive height walk). tweaks.subscribe remains the Cake
client-local scan.
| DoS floor | always on | max conn / line / idle / subs / broadcast hex (ServeLimits); public bind OK behind proxy |
Yuval pointed out that Electrum status and Esplora list envelopes are
A-B-A: a same-height reorg can leave txid:height (and a height-keyed
join cache) unchanged while merkle proofs and confirming block hashes
moved. We researched
mempool/mempool#6584
(tnull: stamp chain tip hash on every API response header so sequential
fetches detect tip movement, including A-B-A) and
spesmilo/electrum-protocol#2
(1.7 chaintip on scriptpubkey.*, reverted in
#17 because ElectrumX
is bitcoind middleware and cannot pin). rbitcoin owns Query+store, so we pin
the published tip and retry if it disconnects
(docs/concurrency.md).
| Surface | Token | Body |
|---|---|---|
| Esplora HTTP | X-Bitcoin-Chain-Tip + X-Bitcoin-Chain-Tip-Height |
Unchanged JSON. Client: if two sequential fetches disagree on the hash, drop the batch and restart. |
| Electrum TCP | JSON-RPC extra members chain_tip / chain_tip_height next to result (ping/version omit). server.features.chain_tip. |
result shape unchanged. Status preimage stays the protocol's txid:height:; a same-height replace shows in chain_tip, not in the status. Notification params stay [scripthash, status]. |
We stamp tip, not only the last relevant history tx hash (empty history and list envelopes still need a token).
As-of (buried ancestor): thanks again to Yuval — the same A-B-A /
bind-confirmations-to-a-chain work implies “wallet as of this block”
while that block is still on the best chain. Esplora ?asof=<hash> and
Electrum trailing asof:<hash> (after official positional args) join
under pin_chain_view_at. Stamp is that hash. If the asof block leaves
the tip chain: 404 / asof not on chain (no retry onto another
block at the same height). We still do not serve a disconnected fork
hash.
Electrum clients that want as-of send server.version(name, "1.4.2-asof")
(or a [min, max] range containing that string). Standard Electrum
"1.4" / ["1.4", "1.4.2"] stays on dotted-int 1.4.x; an asof: tag
without the dialect is an error. server.features.protocol_max stays
"1.6". As-of discovery is asof / asof_protocol (1.4.2-asof), not
protocol_max.
Request and response shapes: docs/esplora-openapi.yaml (OpenAPI 3.1).
Plain HTTP via --esplora-listen / conf esplora_listen (default off). TLS
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 |
|---|---|---|
| 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) (vin from the spent slot; unspent omits it). Below seqsigwit prune: JSON is 200 partial (pruned: true, vout+status, empty vin); stamped txstat fills fee/size/weight. Mempool-only txs (not in Class A) use the wire body from the mempool hub (vin/vout/size/weight/fee/sigops, status.confirmed false) including GET /tx/:txid/status. Live /outspend(s) overlay mempool spends of confirmed coins; ?asof= omits mempool. ?asof=<hash> on /status and /outspend(s): confirmed/spent as of that ancestor; 404 if not on chain. GET /tx/:txid?asof= stays 404. |
| Output by position | done | GET /block-height/:height/tx/:index is the same JSON as GET /tx/:txid for that confirmed tx, including a pruned partial and the genesis coinbase. A missing height or index is 404. A non-integer segment is 400. GET /block-height/:height/tx/:index/out/:vout, GET /tx/:txid/out/:vout, and POST /block-heights/txs/outs return one confirmed output: Esplora vout fields plus boolean spent and status of the creating transaction. spent: true is 200 and still includes the script and value (gettxout returns null once spent). A mempool spend stays spent: false with the script present. Genesis coinbase outputs, a mempool-only tx, a header with no body, and a missing vout are 404 on a GET and JSON null in a batch slot. The batch body is [[height, index, vout], …] (same length and order, duplicates kept). One bad element is 400 for the whole body. More than 300 is 422 body too long. An empty body is 200 []. ?asof=<hash> on all four pins one best-chain ancestor: tip headers and status name that pin, a height above it is 404 or batch null, a later spend stays spent: false, and a dead or disconnected hash is 404 Not Found for the whole GET or POST (no sibling at that height). out is this output. outspend is the spender. No --sh-index. |
| Address / scripthash | done | stats + /utxo + /txs + /txs/mempool + /txs/chain[/:last_seen_txid] + /txs/summary[/:last_seen_txid] (dialect; next row). /utxo matches Electrum listunspent (mempool funding + drop mempool-spent confirmed); /txs and /txs/mempool use full Esplora tx JSON for mempool-only rows (wire from the hub). ?after_txid= on /txs and /txs/summary skips through that tx (mempool then chain); unknown or unparseable → 422 after_txid not found. A mempool-only cursor past the first 50 mempool rows returns the confirmed page. POST /addresses/txs and POST /scripthashes/txs (and /txs/summary) take a JSON array (max 300; over → 422 body too long); unique scripts join once per request and merge newest-first into one page of 25 confirmed rows (mempool rows stay in front on /txs, 50 per script). A later page walks past each script's first 25. Unix/loopback X-Rbitcoin-Client: last-1 GET join + last-bulk POST (16 MiB packed/client), 30s idle, 256 clients, singleflight on the same (client, sh). Public TCP ignores the header (stack-local join). Concurrent different GET scripts replace last-1. Unbounded process LRU stays X-M3. Needs SH finalize. Stamp is visible SH (durable + pending write-behind), matching live tip while jobs sit in RAM. ?asof=<hash> on /, /utxo, /txs, /txs/chain, /txs/summary: confirmed join at that ancestor at or behind visible SH, no mempool; headers are the asof hash; 404 if not on chain or ahead of visible SH. |
/txs/summary |
dialect | Not in Blockstream Esplora API.md. Compact {txid, value, height, time, tx_position} like mempool.space /address/:addr/txs/summary. Confirmed only (25/page, newest first, including a multi-script post of that same merged page); path cursor /:last_seen_txid and mempool.space ?after_txid= (unknown → 422). value is net sats for that script in that tx (funded − spent). time is the confirming header timestamp (0 if the header is missing). tx_position is that tx's index in the block. Mempool rows stay on /txs and /txs/mempool. A page is served above --max-sh-creates; unpaged stats and /utxo still 503. |
| Mempool / fees | done | /mempool, /mempool/txids, /mempool/txids/page[/:last], /mempool/recent (accept-order ring), /fee-estimates (sat/vB at the estimator's 1 sat/kvB resolution; a target without an estimate is left out; when none has one, mainnet is 503 fee estimates unavailable and every other network is 200 {}). mempool.space /api/v1/fees/recommended is its backend's surface, not served here (404) |
POST /tx |
done | broadcast via mempool hub; 503 if hub absent. GET /broadcast is not served (404). |
POST /txs/test |
done | JSON array of hex (max 25) → MempoolHub::test_accept (no admit). ?maxfeerate= is BTC/kvB like electrs (0 unlimited; omitted → 0.1 BTC/kvB). |
POST /txs/package |
done | JSON array of hex txs → accept_package; 503 without hub; max 25 txs |
electrs /internal/* |
done | Unix listen only. TCP GET/POST /internal/… → 404 (same unknown-path fallback). POST /internal/txs (400 on unparseable id; missing omitted); POST /internal/mempool/txs (mempool only); GET /internal/mempool/txs[/all or /:last] (txid-sort pages, default max_txs=10000; /all registered first); GET /internal/block/:hash/txs (full list; public /txs stays 25/page); POST /internal/txs/outspends/by-txid (same-length slots, unknown → []); POST /internal/txs/outspends/by-outpoint (txid:vout; malformed → {"spent":false}). Snapshot JSON is published on the hub (no admit-path build). GET /mempool/txids/page is public electrs (both listeners). |
| Unix listen | done | --esplora-listen filesystem path (mode 0660). Public REST plus /internal/*. TCP --esplora-listen host:port never mounts /internal. |
| Unknown path | 404 | plain body (including /address-prefix) |
GET /block-template |
opt-in | --esplora-block-template (default off → 404). Same JSON as RPC getblocktemplate {"rules":["segwit"]} template mode. 503 without tip. Cache-Control: no-store. 15 s cache, invalidated on tip or mempool template_updates. No proposal/longpoll HTTP (parked Q-64). |
| Non-goal / never | — | Address-prefix search, Liquid/assets. mempool.space /api/v1/ catalogue stays their Node. |
With --sp-tweaks, the Esplora listener also serves the BlindBit oracle
v1 HTTP API under /blindbit/v1/. BlindBit v2 moved to gRPC. The v1 HTTP
clients are Dana's spdk
(backends/blindbit-v1), Bull Bitcoin mobile, and blindbitd. Point the client
at http://host:port/blindbit/v1. Without --sp-tweaks the prefix is 404.
The served height is the lower of the tip and the tweak index's last height.
Until the index passes its origin there is no served height: info and
block-height are 503 no height indexed, and every height route is 404.
tweaks, tweak-index and utxos start at Taproot activation (the tweak
index origin) and are 404 below it. block-hash, filter/* and spent-index
answer any height up to the served height; below the origin both filters and
the spent index are empty and read nothing, since no output there can be a
silent payment.
| Route | Notes |
|---|---|
GET /info |
network is the Core -chain name (main, test, signet, regtest); spdk rejects BlindBit's testnet. height is the served height (503 while there is none, never 0). All three tweak modes are true; tweaks_only is false. |
GET /block-height, GET /block-hash/:h |
{"block_height"}, {"block_hash"} (display hex). |
GET /tweaks/:h?dustLimit= |
Cut-through: tweaks of eligible txs with a P2TR output not yet spent. dustLimit keeps a tx whose largest remaining P2TR output is >= dustLimit (BlindBit's edge; not --sp-tweaks-dust). |
GET /tweak-index/:h?dustLimit= |
Every eligible tx; dustLimit over all its P2TR outputs. |
GET /utxos/:h |
Every P2TR output of every eligible tx, with spent. BlindBit drops spent rows over time; spent rows stay here. |
GET /filter/new-utxos/:h, GET /filter/spent/:h |
BIP158 GCS (P=19, M=784931, keyed by the block hash), filter_type: 4. New-utxos holds the x-only key of every P2TR output in the block. Spent holds the spent-index hashes. Built per request from the block's own txs: one txout span for the keys, and the input edges plus the txid sidefile for the spends. No parent tx and no witness is read, and nothing extra is stored. A key repeated in one block is written once (BlindBit writes it twice); membership is the same. |
GET /spent-index/:h |
First 8 bytes of sha256(txid ‖ vout_le ‖ block_hash) (internal byte order) for every non-coinbase input. BlindBit hashes only P2TR prevouts; the input edge does not carry the parent's script, so every spend is hashed here. spdk tests its own outpoints for membership, which a superset answers the same way. Always {"block_hash","data"}; BlindBit returns a bare [] when empty, which spdk cannot parse. |
POST /forward-tx |
{"data":"<hex>"} → mempool hub. The answer is the txid as a JSON string (spdk parses it; BlindBit sends an empty 200). Errors are {"error"} with 400, or 503 without a hub. |
A bad height is 400 could not parse block height; a height above the
served height is 404 height not indexed. Store errors use the
listener's statuses (404 not found or pruned, 503 rejected or stale)
with an {"error"} body. A reorg of that height between
the reads one answer is built from is 503 chain view moved; ask again.
Eligibility is BIP-352 as for blockchain.tweaks.subscribe.
All /blindbit/v1/ routes share one limit of requests in flight: half the
node's blocking pool (available_parallelism, at least 4). It is taken
before a listener slot, so the ~200 asks spdk keeps open per scan queue there
without holding listener slots. A request keeps its permit until its read
returns, even after a timeout or a client disconnect.
Esplora and electrs have no WebSocket. mempool.space's /api/v1/ws
(want, track-address, track-tx, …) is its backend's surface, so
/ws and /v1/ws are 404 here. Wallets watching live use Electrum
subscriptions, or mempool's backend in front of this Esplora.
Encode/decode uses Core’s V2_MESSAGE_IDS table (crates/rbitcoin-net/src/v2.rs).
Live IBD + tip follow + tip tx relay commands with short IDs:
| Short ID | Command | Role |
|---|---|---|
| 1 | addr | peers |
| 2 | block | IBD / tip body |
| 3 | blocktxn | compact fill |
| 4 | cmpctblock | tip HB |
| 5 | feefilter | tip policy |
| 9–15 | getblocks…mempool | headers/blocks/inv |
| 17–21 | notfound…tx | ping/pong/sendcmpct/tx |
| 22–27 | getcfilters…cfcheckpt | BIP157 basic while --block-filter-index is on. A stop past the filter watermark is silence; an out-of-range request disconnects (Core) |
| 28 | addrv2 | BIP155 |
Long-form (no short ID): version, verack, wtxidrelay, sendheaders,
sendaddrv2, and unknown/extension commands.
Not implemented as product features (short slots 29–36 placeholders, 37
feature): decode may reject unknown short IDs. Slots 22–27 are live for
BIP158 basic filters while --block-filter-index is on
(NODE_COMPACT_FILTERS once filters first reach the tip, getcfilters, getcfheaders,
getcfcheckpt, getblockfilter). A request whose stop height is past the
filter watermark is silence, not an empty filter or a short batch. As in
Core's PrepareBlockFilterRequest, start past stop, 1000+ getcfilters,
or 2000+ getcfheaders heights disconnects the peer.
Core wallet RPC, fee-estimator research quality, BIP331 native wire enum, durable orphans: out of scope for this plan. GBT template RPC is shipped (see above); stratum / pool software is not.
Permanent non-goals for Electrum/Esplora: address-prefix autocomplete,
Liquid/assets, in-binary mempool.space /api/v1/ (MariaDB cubes / mining /
lightning). 0.8 is electrs HTTP drop-in except prefix. Core RPC for that
stack is TCP plus an opt-in Core cookie (--rpc-cookie-file, stock mempool);
the unix-socket patch is optional.