diff --git a/COMPAT.md b/COMPAT.md index 95a0190d99..d326a3d082 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -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 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 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:`** 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:`** 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:` (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:` (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. | @@ -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. @@ -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:` +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 diff --git a/TESTING.md b/TESTING.md index e5dec4ea53..91b2ce24fc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 | diff --git a/changelog.d/electrum-asof-any-protocol.md b/changelog.d/electrum-asof-any-protocol.md new file mode 100644 index 0000000000..acbdf98f07 --- /dev/null +++ b/changelog.d/electrum-asof-any-protocol.md @@ -0,0 +1,8 @@ +Changed + +- **Electrum `asof:` needs no dialect.** A trailing `asof:` 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. diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index b7db6139d8..dec92ce973 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -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:`. Not a dotted-int; `protocol_max` stays 1.6. +// Older as-of clients negotiate this; a trailing `asof:` 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. @@ -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) } @@ -2036,14 +2035,13 @@ pub fn electrum_at_chain_view( query: &Query, method: &str, params: &Value, - asof_ok: bool, mut f: F, ) -> (Result, Option) where F: FnMut(&Query, &Value, Option<&ChainView>, bool) -> Result, { 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); @@ -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(¶ms, 0)?; let (filter, mut include_mempool) = parse_get_history_window(¶ms)?; @@ -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(¶ms, 0)?; let mut b = sh_at_view( @@ -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(¶ms, 0)?; let u = sh_at_view( @@ -2809,7 +2807,6 @@ pub fn negotiate_protocol(params: &Value) -> Result { 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)); @@ -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()); }; diff --git a/crates/rbitcoin-electrum/tests/electrum_sh_journey.rs b/crates/rbitcoin-electrum/tests/electrum_sh_journey.rs index cd92aa6e47..a3fec16a7c 100644 --- a/crates/rbitcoin-electrum/tests/electrum_sh_journey.rs +++ b/crates/rbitcoin-electrum/tests/electrum_sh_journey.rs @@ -104,7 +104,6 @@ pub fn view_hash(q: &Query) -> ([u8; 32], Height) { q, "blockchain.scripthash.get_balance", &json!([sh]), - false, |q, rpc_params, view, is_asof| { let mut pin_conn = ElectrumConn::new(); dispatch_pinned( diff --git a/crates/rbitcoin-electrum/tests/server/mod.rs b/crates/rbitcoin-electrum/tests/server/mod.rs index f7a3bebc54..2c99479722 100644 --- a/crates/rbitcoin-electrum/tests/server/mod.rs +++ b/crates/rbitcoin-electrum/tests/server/mod.rs @@ -127,7 +127,6 @@ fn config_helpers_and_param_parsers() { let (rest, h) = take_trailing_asof( "blockchain.scripthash.get_balance", &json!([sh_hex, tagged]), - true, ) .unwrap(); assert!(h.is_some()); @@ -135,30 +134,27 @@ fn config_helpers_and_param_parsers() { let (rest_win, h_win) = take_trailing_asof( "blockchain.scripthash.get_history", &json!([sh_hex, 1, 10, tagged]), - true, ) .unwrap(); assert!(h_win.is_some()); assert_eq!(rest_win, json!([sh_hex, 1, 10])); let (rest_tx, h_tx) = - take_trailing_asof("blockchain.transaction.get", &json!([sh_hex, tagged]), true).unwrap(); + take_trailing_asof("blockchain.transaction.get", &json!([sh_hex, tagged])).unwrap(); assert!(h_tx.is_some()); assert_eq!(rest_tx, json!([sh_hex])); let (rest_merkle, h_merkle) = take_trailing_asof( "blockchain.transaction.get_merkle", &json!([sh_hex, 0, tagged]), - true, ) .unwrap(); assert!(h_merkle.is_some()); assert_eq!(rest_merkle, json!([sh_hex, 0])); let (_, none) = - take_trailing_asof("blockchain.scripthash.get_balance", &json!([sh_hex]), true).unwrap(); + take_trailing_asof("blockchain.scripthash.get_balance", &json!([sh_hex])).unwrap(); assert!(none.is_none()); let (_, not_hex) = take_trailing_asof( "blockchain.scripthash.get_balance", &json!([sh_hex, asof_hex]), - true, ) .unwrap(); assert!( @@ -168,30 +164,18 @@ fn config_helpers_and_param_parsers() { let (_, leftover_obj) = take_trailing_asof( "blockchain.scripthash.get_balance", &json!([sh_hex, { "other": true }]), - true, ) .unwrap(); assert!(leftover_obj.is_none()); - let denied = take_trailing_asof( - "blockchain.scripthash.get_balance", - &json!([sh_hex, tagged]), - false, - ) - .unwrap_err(); - assert!( - denied.contains("1.4.2-asof"), - "asof tag without dialect: {denied}" - ); assert!(take_trailing_asof( "blockchain.scripthash.get_balance", - &json!([sh_hex, "asof:zz"]), - true, + &json!([sh_hex, "asof:zz"]) ) .unwrap_err() .contains("asof:<32-byte hex>")); let asof_tag = format!("asof:{}", "ab".repeat(32)); let (_, ignored) = - take_trailing_asof("blockchain.block.header", &json!([0, asof_tag]), true).unwrap(); + take_trailing_asof("blockchain.block.header", &json!([0, asof_tag])).unwrap(); assert!( ignored.is_none(), "asof tag on a method that does not accept asof is leftover params, not a view" @@ -2192,7 +2176,10 @@ fn dispatch_param_type_edges_and_subscribe_cap() { &mut sh_subs, ) .unwrap_err(); - assert!(err.contains("1.4.2-asof"), "asof without dialect: {err}"); + assert!( + err.contains("asof not on chain"), + "unknown asof hash: {err}" + ); dispatch( "blockchain.scripthash.subscribe", diff --git a/crates/rbitcoin-test/tests/electrum_protocol.rs b/crates/rbitcoin-test/tests/electrum_protocol.rs index d5183a4731..471063f1d1 100644 --- a/crates/rbitcoin-test/tests/electrum_protocol.rs +++ b/crates/rbitcoin-test/tests/electrum_protocol.rs @@ -2034,21 +2034,24 @@ async fn electrum_and_esplora_asof_hides_later_spend() { let handle = run_electrum(cfg, Arc::clone(&q), params.clone(), tip_tx.clone(), None) .await .expect("electrum listen"); - let mut stream = TcpStream::connect(handle.local_addr).await.unwrap(); - - let denied = rpc( - &mut stream, - 1, + // The trailing tag is the opt-in: a standard 1.6 connection gets the + // as-of answer and keeps its 1.6 `headers` array. + let mut plain = TcpStream::connect(handle.local_addr).await.unwrap(); + let ver = rpc(&mut plain, 1, "server.version", json!(["test", "1.6"])).await; + assert_eq!(ver["result"][1], "1.6", "{ver}"); + let bal = rpc( + &mut plain, + 2, "blockchain.scripthash.get_balance", json!([sh.clone(), tag_create.clone()]), ) .await; - let denied_msg = denied["error"]["message"].as_str().unwrap_or(""); - assert!( - denied_msg.contains("1.4.2-asof"), - "asof tag before handshake: {denied}" - ); + assert_eq!(bal["result"]["confirmed"], value, "{bal}"); + assert_eq!(bal["chain_tip"], asof_create, "{bal}"); + let hdrs = rpc(&mut plain, 3, "blockchain.block.headers", json!([0, 1])).await; + assert!(hdrs["result"]["headers"].is_array(), "{hdrs}"); + let mut stream = TcpStream::connect(handle.local_addr).await.unwrap(); let ver = rpc(&mut stream, 2, "server.version", json!(["test", ASOF])).await; assert_eq!(ver["result"][1], ASOF, "{ver}"); let locked = rpc(&mut stream, 3, "server.version", json!(["test", "1.4"])).await; diff --git a/docs/concurrency.md b/docs/concurrency.md index 1895120bb7..b7dda1302d 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -114,7 +114,7 @@ cannot pin bitcoind RPC — we can). | Rule | Detail | |------|--------| | Pin | `Query::pin_chain_view` captures `{height, hash, header_fk}` of published tip | -| Buried / as-of | `pin_chain_view_at(hash)` for a still-live ancestor. Esplora `?asof=`; Electrum trailing `asof:` after `server.version` dialect `1.4.2-asof`. Stamp is that hash. If it leaves the tip chain: 404 / `asof not on chain` — **do not** retry onto another block at the same height | +| Buried / as-of | `pin_chain_view_at(hash)` for a still-live ancestor. Esplora `?asof=`; Electrum trailing `asof:` on any protocol. Stamp is that hash. If it leaves the tip chain: 404 / `asof not on chain` — **do not** retry onto another block at the same height | | Filter | SH join uses `is_confirmed_strong_at(fk, view.height)`; slot keys on **hash** | | Live-check | `ChainView::still_live` ⇔ `confirmed[height] == header_fk` | | Extension | Prefix pin stays live; creates above the pin are filtered | diff --git a/docs/esplora-openapi.yaml b/docs/esplora-openapi.yaml index 54a2ce3c3a..d2d06832ec 100644 --- a/docs/esplora-openapi.yaml +++ b/docs/esplora-openapi.yaml @@ -58,8 +58,8 @@ info: - If the as-of block is reorged out while the request runs, the response is **404** rather than 503. - The Electrum counterpart is a trailing `asof:` argument after - negotiating `server.version(name, "1.4.2-asof")`; see COMPAT.md. + The Electrum counterpart is a trailing `asof:` argument, on + any negotiated protocol; see COMPAT.md. ## Scripthash index Address and scripthash routes need `--sh-index`. Without it they return