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
22 changes: 12 additions & 10 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,13 @@ Per-method notes, auth, and the shindex matrix live in

| 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. |
| 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) → the legacy as-of dialect, which pins 1.4.x shapes; `asof:` does not need it. First call wins. `features.protocol_max` is `1.6`; `features.asof_protocol` names that legacy dialect. `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](https://github.com/cake-tech/cake_wallet/issues/3574) persist still lags one event). [kiss-bdk](https://github.com/kkdao/kiss-bdk) today treats `done` as the asked range finished ([kiss-bdk#10](https://github.com/kkdao/kiss-bdk/issues/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 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, on any negotiated protocol (`server.features.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. 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.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>` (as for scripthash, on any protocol): 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`](./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. |
Expand All @@ -152,7 +152,7 @@ Per-method notes, auth, and the shindex matrix live in

### Protocol versions

`features.protocol_max` is **1.6** (plus dialect `1.4.2-asof`).
`features.protocol_max` is **1.6** (plus the legacy as-of 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.
Expand Down Expand Up @@ -220,12 +220,14 @@ 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`.
Electrum clients that want as-of add a trailing `asof:<blockhash>`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scripthash row still says a trailing asof: "Requires negotiated 1.4.2-asof (first server.version only)", and the transaction.get / get_merkle row still says "same dialect as scripthash". This paragraph says the token works on any negotiated protocol. A client that follows the method table sends server.version with 1.4.2-asof. negotiate_protocol still returns that string, so protocol_ge_1_6 stays false and blockchain.block.headers stays the 1.4 hex blob. Drop the requirement from both rows. Keep asof_protocol as the legacy version that pins 1.4.x shapes; the last sentence here should not tell a client that negotiating it is how they turn the token on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 22cdebb: the scripthash and transaction.get / get_merkle rows no longer require the dialect, the server.version row and the paragraph call 1.4.2-asof the legacy dialect that pins 1.4.x shapes, and discovery is server.features.asof.

argument to a method that takes it, on any negotiated protocol. A 1.6
connection keeps its 1.6 shapes. `server.version(name, "1.4.2-asof")` (or a
`[min, max]` range containing that string) is still accepted for clients
that negotiate it, and pins that connection to 1.4.x shapes.
`server.features.protocol_max` stays `"1.6"`. As-of discovery is
`server.features.asof`, not `protocol_max`; `asof_protocol` names the legacy
dialect and is not needed to send the token.

## Esplora REST surface

Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ A cell that names a crate-local test describes the witness that exists today. It
| `electrum_server_version_history_balance` | Electrum | One mature pad: version (`CARGO_PKG_VERSION`)/history/balance/headers, ping/features (`protocol_min` / `asof_protocol` / `server_version`)/tx/errors, confirmed history omits `fee`, scripthash subscribe notify, skip restatus when a new block misses the SH. TCP `get_history` `from_height` at create / exclusive `to_height` hides spend / `to_height=-1` open; subscribe status stays full; invalid `from_height` type. `get_merkle` wrong height for a known txid. Verbose `transaction.get` stamps `time`/`blocktime`/`confirmations`/`blockhash` and coinbase `vin`/`vout`. `id_from_pos` string vs `merkle=true` `{tx_hash, merkle}`; pos OOB errors. Line at `max_request_bytes` ignored; one past is JSON-RPC `-32600` `request line too long`. Electrum **1.6**: `mempool.get_info`, `blockchain.outpoint.*` (unsubscribe miss is `false`; tip notify while subscribed; missing vout errors), Frigate `silentpayments.subscribe` / unsubscribe / second unsubscribe / start past tip clamps / bad-params error, `block.headers` as a list, `broadcast_package` without a hub errors. With no hub, `estimatefee` is `-1.0`, the histogram is empty, and Cake `tweaks.subscribe [0,1,false]` answers |
| `electrum_scripthash_sub_cap_unsubscribe_frees_slot` | Electrum | Per-connection scripthash cap + unsubscribe frees a slot. Outpoint subscriptions use that same numeric cap on their own set (resubscribe does not consume another slot; a third distinct outpoint is `max 2` until unsubscribe). |
| `electrum_leftover_mempool_does_not_double_count` | Electrum | Relay-off leftover is confirmed, not a second mempool UTXO. With hub attached, `transaction.broadcast` of non-hex and consensus-invalid rejects (not hang / not admit). Same pad: confirmed `outpoint.get_status` (tip height, no `spending_txid`); `broadcast_package` invalid hex / reject / verbose success / non-verbose `"success"`; mempool-spent outpoint is `height=0` with `spending_txid`; mempool verbose `transaction.get` is `confirmations=0` with no `time`/`blocktime`/`blockhash` |
| `electrum_and_esplora_asof_hides_later_spend` | Electrum + Esplora | One pad: TCP `1.4.2-asof` plus HTTP `?asof=` hide a later spend; unknown asof errors; `GET /tx` `v0_p2wpkh` vout. SH-off tip+1: asof at visible SH watermark accepts, asof of the confirmed hash ahead of SH is `asof not on chain` / HTTP 404. Same-height A-B-A keeps the protocol status (no push; `chain_tip` restamps); `asof:` of the loser hash does not retry onto the sibling. Disconnect spend: history/utxo show the create again; live Esplora `/utxo` stamps the create hash (not the fork); loser `?asof=` 404 with no tip header. HTTP `503` `chain view moved` (no tip header) is `http_503_chain_view_moved_omits_tip_header` |
| `electrum_and_esplora_asof_hides_later_spend` | Electrum + Esplora | One pad: TCP `asof:` on a 1.6 connection (1.6 `headers` kept) and on `1.4.2-asof`, plus HTTP `?asof=`, hide a later spend; unknown asof errors; `GET /tx` `v0_p2wpkh` vout. SH-off tip+1: asof at visible SH watermark accepts, asof of the confirmed hash ahead of SH is `asof not on chain` / HTTP 404. Same-height A-B-A keeps the protocol status (no push; `chain_tip` restamps); `asof:` of the loser hash does not retry onto the sibling. Disconnect spend: history/utxo show the create again; live Esplora `/utxo` stamps the create hash (not the fork); loser `?asof=` 404 with no tip header. HTTP `503` `chain view moved` (no tip header) is `http_503_chain_view_moved_omits_tip_header` |
| `electrum_empty_chain_headers_subscribe_and_empty_scripthash` | Electrum | Empty store: `headers.subscribe` errors; scripthash history/balance/unspent/mempool empty |
| `electrum_tweaks_subscribe_streams_then_done` | Electrum | Cake `tweaks.subscribe`: one-height result, per-height notifies, `done`, and BIP352 hash-bind on a P2WPKH→P2TR spend. A zero-chunk subscribe returns wave 0 and then `done` while heights remain, and a resubscribe continues. Pre-taproot empty heights collapse into one notify |
| `electrum_max_connections_rejects_extra_client` | Electrum | TCP cap drops the extra client |
Expand Down
8 changes: 8 additions & 0 deletions changelog.d/electrum-asof-any-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Changed

- **Electrum `asof:` needs no dialect.** A trailing `asof:<blockhash>` on
`get_history`, `get_balance`, `listunspent`, `transaction.get` and
`get_merkle` answers as of that block on any negotiated protocol. It was
refused unless the connection had negotiated `1.4.2-asof`, which also held
that connection to 1.4.x shapes, so a 1.6 client could not use as-of. Clients
that negotiate `1.4.2-asof` keep working unchanged.
24 changes: 9 additions & 15 deletions crates/rbitcoin-electrum/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ use tokio::task::JoinHandle;
pub const PROTOCOL_MIN: &str = "1.4";
#[doc(hidden)]
pub const PROTOCOL_MAX: &str = "1.6";
// Dialect version: trailing `asof:<blockhash>`. Not a dotted-int; `protocol_max` stays 1.6.
// Older as-of clients negotiate this; a trailing `asof:<blockhash>` needs no
// dialect. Not a dotted-int; `protocol_max` stays 1.6.
#[doc(hidden)]
pub const PROTOCOL_ASOF: &str = "1.4.2-asof";
/// First `server.version` element, read by wallets that branch on it.
Expand Down Expand Up @@ -1068,11 +1069,9 @@ fn dispatch_off_worker(
let r = dispatch_pinned(method, params, q, cfg, p, mp, &mut work, None, false);
return (r, work, None);
}
let asof_ok = work.protocol.as_str() == PROTOCOL_ASOF;
let (r, view) =
electrum_at_chain_view(q, method, params, asof_ok, |q, params, view, is_asof| {
dispatch_pinned(method, params, q, cfg, p, mp, &mut work, view, is_asof)
});
let (r, view) = electrum_at_chain_view(q, method, params, |q, params, view, is_asof| {
dispatch_pinned(method, params, q, cfg, p, mp, &mut work, view, is_asof)
});
(r, work, view)
}

Expand Down Expand Up @@ -2036,14 +2035,13 @@ pub fn electrum_at_chain_view<F>(
query: &Query,
method: &str,
params: &Value,
asof_ok: bool,
mut f: F,
) -> (Result<Value, String>, Option<ChainView>)
where
F: FnMut(&Query, &Value, Option<&ChainView>, bool) -> Result<Value, String>,
{
let kind = electrum_pin_kind(method);
match take_trailing_asof(method, params, asof_ok) {
match take_trailing_asof(method, params) {
Ok((stripped, Some(hash))) => match query.pin_view(kind, Some(&hash)) {
Ok(Some(view)) => {
let out = f(query, &stripped, Some(&view), true);
Expand Down Expand Up @@ -2313,7 +2311,7 @@ pub fn dispatch_pinned(
let (params, asof) = if pinned.is_some() {
(params.clone(), None)
} else {
take_trailing_asof(method, params, protocol == PROTOCOL_ASOF)?
take_trailing_asof(method, params)?
};
let sh = param_scripthash(&params, 0)?;
let (filter, mut include_mempool) = parse_get_history_window(&params)?;
Expand Down Expand Up @@ -2356,7 +2354,7 @@ pub fn dispatch_pinned(
let (params, asof) = if pinned.is_some() {
(params.clone(), None)
} else {
take_trailing_asof(method, params, protocol == PROTOCOL_ASOF)?
take_trailing_asof(method, params)?
};
let sh = param_scripthash(&params, 0)?;
let mut b = sh_at_view(
Expand Down Expand Up @@ -2391,7 +2389,7 @@ pub fn dispatch_pinned(
let (params, asof) = if pinned.is_some() {
(params.clone(), None)
} else {
take_trailing_asof(method, params, protocol == PROTOCOL_ASOF)?
take_trailing_asof(method, params)?
};
let sh = param_scripthash(&params, 0)?;
let u = sh_at_view(
Expand Down Expand Up @@ -2809,7 +2807,6 @@ pub fn negotiate_protocol(params: &Value) -> Result<String, String> {
pub fn take_trailing_asof(
method: &str,
params: &Value,
asof_ok: bool,
) -> Result<(Value, Option<[u8; 32]>), String> {
if !method_stamps_chain_tip(method) {
return Ok((params.clone(), None));
Expand All @@ -2823,9 +2820,6 @@ pub fn take_trailing_asof(
let Some(hex) = last.strip_prefix("asof:") else {
return Ok((params.clone(), None));
};
if !asof_ok {
return Err("asof requires protocol 1.4.2-asof".into());
}
let Some(hash) = parse_blockhash32(hex) else {
return Err("asof must be asof:<32-byte hex>".into());
};
Expand Down
Loading
Loading