From 79513ed3628ff457b9c7f731bab7aeb51653df5d Mon Sep 17 00:00:00 2001 From: Naomi-Gift <149405096+Naomi-Gift@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:25:35 +0100 Subject: [PATCH] rpc: scriptPubKey carries Core's inferred desc script_pubkey_json returned hex, asm, type, and address, so a Core client reading scriptPubKey.desc got nothing from getrawtransaction, decoderawtransaction, getblock verbosity 2, decodescript, gettxout, or REST getutxos. It now adds the descriptor Core infers with no keystore: pk, multi, rawtr, addr, else raw, with the BIP380 checksum. Closes #1146 --- changelog.d/scriptpubkey-desc.md | 7 + crates/rbitcoin-rpc/src/methods/descriptor.rs | 151 ++++++++++++++++++ crates/rbitcoin-rpc/src/methods/mine.rs | 2 + crates/rbitcoin-rpc/src/methods/mod.rs | 5 +- crates/rbitcoin-rpc/tests/methods/mod.rs | 84 +++++++++- docs/rpc.md | 8 +- 6 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 changelog.d/scriptpubkey-desc.md create mode 100644 crates/rbitcoin-rpc/src/methods/descriptor.rs diff --git a/changelog.d/scriptpubkey-desc.md b/changelog.d/scriptpubkey-desc.md new file mode 100644 index 0000000000..03e3b0670e --- /dev/null +++ b/changelog.d/scriptpubkey-desc.md @@ -0,0 +1,7 @@ +Added + +- **`scriptPubKey` carries Core's `desc`.** `getrawtransaction`, + `decoderawtransaction`, `getblock` verbosity 2, `decodescript`, + `gettxout`, and REST `getutxos` now return the inferred output + descriptor with its checksum: `pk`, `multi`, `rawtr`, `addr`, or `raw`, + as Core infers it with no keystore. diff --git a/crates/rbitcoin-rpc/src/methods/descriptor.rs b/crates/rbitcoin-rpc/src/methods/descriptor.rs new file mode 100644 index 0000000000..9a3fed3d49 --- /dev/null +++ b/crates/rbitcoin-rpc/src/methods/descriptor.rs @@ -0,0 +1,151 @@ +use bitcoin::key::XOnlyPublicKey; +use bitcoin::opcodes::all::OP_CHECKMULTISIG; +use bitcoin::opcodes::{Class, ClassifyContext}; +use bitcoin::script::Instruction; +use bitcoin::{Address, Network, Script}; +use rbitcoin_primitives::hex_encode; + +const MAX_MULTISIG_KEYS: usize = 20; + +pub fn infer_descriptor(script: &Script, network: Network) -> String { + let body = infer_body(script, network); + let sum = checksum(&body); + format!("{body}#{sum}") +} + +fn infer_body(script: &Script, network: Network) -> String { + if let Some(key) = p2pk_key(script) { + return format!("pk({})", hex_encode(key)); + } + if let Some((required, keys)) = bare_multisig(script) { + let keys: Vec = keys.into_iter().map(hex_encode).collect(); + return format!("multi({required},{})", keys.join(",")); + } + if let Some(key) = taproot_output_key(script) { + return format!("rawtr({})", hex_encode(key.serialize())); + } + if let Ok(addr) = Address::from_script(script, network) { + return format!("addr({addr})"); + } + format!("raw({})", hex_encode(script.as_bytes())) +} + +fn has_key_size(push: &[u8]) -> bool { + let want = match push.first() { + Some(2 | 3) => 33, + Some(4 | 6 | 7) => 65, + _ => return false, + }; + push.len() == want +} + +fn is_hybrid(key: &[u8]) -> bool { + matches!(key.first(), Some(6 | 7)) +} + +fn p2pk_key(script: &Script) -> Option<&[u8]> { + let b = script.as_bytes(); + let (&len, rest) = b.split_first()?; + let (&last, key) = rest.split_last()?; + let sized = (len == 33 || len == 65) && key.len() == usize::from(len); + (sized && last == 0xac && has_key_size(key) && !is_hybrid(key)).then_some(key) +} + +fn bare_multisig(script: &Script) -> Option<(usize, Vec<&[u8]>)> { + let b = script.as_bytes(); + let mut ops = script.instruction_indices(); + let (at, first) = ops.next()?.ok()?; + let required = multisig_count(b, at, first, 1)?; + let mut keys = Vec::new(); + let (at, count) = loop { + let (at, ins) = ops.next()?.ok()?; + match ins { + Instruction::PushBytes(p) if has_key_size(p.as_bytes()) => keys.push(p.as_bytes()), + other => break (at, other), + } + }; + if multisig_count(b, at, count, required)? != keys.len() { + return None; + } + let (_, last) = ops.next()?.ok()?; + if last != Instruction::Op(OP_CHECKMULTISIG) || ops.next().is_some() { + return None; + } + if keys.iter().any(|k| is_hybrid(k)) { + return None; + } + Some((required, keys)) +} + +fn multisig_count(script: &[u8], at: usize, ins: Instruction<'_>, min: usize) -> Option { + let n = match ins { + Instruction::Op(op) => match op.classify(ClassifyContext::Legacy) { + Class::PushNum(n) => usize::try_from(n).ok()?, + _ => return None, + }, + Instruction::PushBytes(p) => match (script[at], p.as_bytes()) { + (1, &[n]) if n > 16 => usize::from(n), + _ => return None, + }, + }; + (min..=MAX_MULTISIG_KEYS).contains(&n).then_some(n) +} + +fn taproot_output_key(script: &Script) -> Option { + if !script.is_p2tr() { + return None; + } + XOnlyPublicKey::from_slice(&script.as_bytes()[2..]).ok() +} + +const INPUT_CHARSET: &str = "0123456789()[],'/*abcdefgh@:$%{}\ +IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~\ +ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "; +const CHECKSUM_CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +fn polymod(c: u64, val: u64) -> u64 { + const GENERATOR: [u64; 5] = [ + 0xf5dee51989, + 0xa9fdca3312, + 0x1bab10e32d, + 0x3706b1677a, + 0x644d626ffd, + ]; + let c0 = c >> 35; + let mut c = ((c & 0x7_ffff_ffff) << 5) ^ val; + for (i, g) in GENERATOR.iter().enumerate() { + if c0 & (1 << i) != 0 { + c ^= g; + } + } + c +} + +fn checksum(body: &str) -> String { + let mut c = 1u64; + let mut class = 0u64; + let mut class_count = 0; + for ch in body.chars() { + let pos = INPUT_CHARSET + .find(ch) + .expect("inferred descriptor uses only the BIP380 charset") as u64; + c = polymod(c, pos & 31); + class = class * 3 + (pos >> 5); + class_count += 1; + if class_count == 3 { + c = polymod(c, class); + class = 0; + class_count = 0; + } + } + if class_count > 0 { + c = polymod(c, class); + } + for _ in 0..8 { + c = polymod(c, 0); + } + c ^= 1; + (0..8) + .map(|i| char::from(CHECKSUM_CHARSET[((c >> (5 * (7 - i))) & 31) as usize])) + .collect() +} diff --git a/crates/rbitcoin-rpc/src/methods/mine.rs b/crates/rbitcoin-rpc/src/methods/mine.rs index 646e74f52a..6af9657079 100644 --- a/crates/rbitcoin-rpc/src/methods/mine.rs +++ b/crates/rbitcoin-rpc/src/methods/mine.rs @@ -1,3 +1,4 @@ +use super::descriptor::infer_descriptor; use super::*; use bitcoin::consensus::{deserialize, encode::serialize_hex}; use bitcoin::hashes::Hash; @@ -60,6 +61,7 @@ pub fn script_pubkey_json(script: &ScriptBuf, network: BtcNetwork) -> Value { "hex": rbitcoin_primitives::hex_encode(script.as_bytes() ), "asm": script.to_asm_string(), "type": script_core_type(script), + "desc": infer_descriptor(script, network), }); if let Ok(addr) = Address::from_script(script, network) { if let Some(m) = obj.as_object_mut() { diff --git a/crates/rbitcoin-rpc/src/methods/mod.rs b/crates/rbitcoin-rpc/src/methods/mod.rs index 4134c0f248..7e5e672ed8 100644 --- a/crates/rbitcoin-rpc/src/methods/mod.rs +++ b/crates/rbitcoin-rpc/src/methods/mod.rs @@ -60,6 +60,7 @@ pub fn genesis_coinbase_txid(ctx: &RpcContext) -> [u8; 32] { pub mod chain; #[doc(hidden)] pub mod decode; +mod descriptor; #[doc(hidden)] pub mod mempool; #[doc(hidden)] @@ -832,8 +833,8 @@ pub const NAMED_HELP: &[(&str, &str)] = &[ ( "decodescript", "decodescript hexstring\n\ - asm, type, hex, and address when standard. No p2sh wrap, segwit wrap, \ - or descriptor inference.", + asm, type, hex, address when standard, and desc. No p2sh wrap, \ + segwit wrap, or miniscript.", ), ( "validateaddress", diff --git a/crates/rbitcoin-rpc/tests/methods/mod.rs b/crates/rbitcoin-rpc/tests/methods/mod.rs index 85fd90bf3d..3d95e58bd5 100644 --- a/crates/rbitcoin-rpc/tests/methods/mod.rs +++ b/crates/rbitcoin-rpc/tests/methods/mod.rs @@ -258,7 +258,6 @@ fn decode_rpc_subset() { assert_eq!(script["hex"], json!(spk_hex)); assert!(script.get("p2sh").is_none()); assert!(script.get("segwit").is_none()); - assert!(script.get("desc").is_none()); let op_true = dispatch(&ctx, "decodescript", vec![json!("51")]).unwrap(); assert_eq!(op_true["type"], json!("nonstandard")); @@ -320,6 +319,89 @@ fn decode_rpc_subset() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn decodescript_desc_matches_core() { + const G_X: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const G_Y: &str = "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"; + let ff = "ff".repeat(32); + let key = format!("02{G_X}"); + let push_key = format!("21{key}"); + let hybrid = format!("06{G_X}{G_Y}"); + let rows: Vec<(String, String)> = vec![ + ( + "0014751e76e8199196d454941c45d1b3a323f1433bd6".into(), + "addr(bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080)#8pk5s7ya".into(), + ), + ( + "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac".into(), + "addr(mrCDrCybB6J1vRfbwM5hemdJz73FwDBC8r)#wdmryvnu".into(), + ), + ( + "a914751e76e8199196d454941c45d1b3a323f1433bd687".into(), + "addr(2N3vVYSK5XRgVSGWy21PnsRmBUywSQNdCsf)#zknva3nk".into(), + ), + ( + format!("0020{G_X}"), + "addr(bcrt1q0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqjsgj9x)#7tlcs788" + .into(), + ), + (format!("5120{G_X}"), format!("rawtr({G_X})#xsjqcczm")), + ( + format!("5120{ff}"), + "addr(bcrt1plllllllllllllllllllllllllllllllllllllllllllllllllllse0lp4e)#f6jd6wfe" + .into(), + ), + (format!("{push_key}ac"), format!("pk({key})#gn28ywm7")), + ( + format!("4104{G_X}{G_Y}ac"), + format!("pk(04{G_X}{G_Y})#fsjyjr2x"), + ), + (format!("2103{ff}ac"), format!("pk(03{ff})#3se6jdc6")), + ( + format!("41{hybrid}ac"), + format!("raw(41{hybrid}ac)#qhj9gd8d"), + ), + ( + format!("52{push_key}2103{G_X}52ae"), + format!("multi(2,{key},03{G_X})#lex50jse"), + ), + ( + format!("51{}0111ae", push_key.repeat(17)), + format!("multi(1,{})#58a7llg3", vec![key.as_str(); 17].join(",")), + ), + ( + format!("5141{hybrid}51ae"), + format!("raw(5141{hybrid}51ae)#q4j8lzl0"), + ), + ( + format!("53{push_key}{push_key}52ae"), + format!("raw(53{push_key}{push_key}52ae)#lgpsxqz8"), + ), + ("51024e73".into(), "addr(bcrt1pfeesnyr2tx)#swxgse0y".into()), + ("5202abcd".into(), "addr(bcrt1z40xsz44l6p)#pj7n4prx".into()), + ("51".into(), "raw(51)#8lvh9jxk".into()), + ("6a04deadbeef".into(), "raw(6a04deadbeef)#uxzjket7".into()), + ]; + let (mut ctx, dir) = ctx_empty(); + for (hex, desc) in &rows { + let script = dispatch(&ctx, "decodescript", vec![json!(hex)]).unwrap(); + assert_eq!(script["desc"], json!(desc), "{hex}"); + } + + ctx.network = Network::Mainnet; + let mainnet = dispatch( + &ctx, + "decodescript", + vec![json!("0014751e76e8199196d454941c45d1b3a323f1433bd6")], + ) + .unwrap(); + assert_eq!( + mainnet["desc"], + json!("addr(bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4)#uyjndxcw") + ); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn help_and_getrpcinfo_list_methods() { let (ctx, dir) = ctx_empty(); diff --git a/docs/rpc.md b/docs/rpc.md index 02a50557a2..ea4a6e9ea6 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -140,9 +140,9 @@ still wait for durable SH when shindex is on. | `addnode` / `disconnectnode` / `addconnection` | All networks. Hostnames resolve at dial (network default P2P port if omitted). `addnode add` and `--connect` retry until a live session. `addnode onetry` dials once and errors if DNS fails. `disconnectnode` by `nodeid` or address. With `--trust-peer`, `addnode` / `addconnection` of any other address fails and names the trusted peer | | `getmempoolinfo` / `getrawmempool` / `getmempoolentry` | MempoolHub. `getrawmempool` lists an empty pool while relay is off, and lists under `-blocksonly`. A nonempty pool during catch-up returns `-10` because confirmed transactions may still be in it. `generate` on `-blocksonly` mines that pool; during catch-up it mines the coinbase only. `maxmempool` is the virtual-size byte cap (`--mempool-size-mb` weight budget / 4), the same unit as `bytes`. The `rbitcoin_mempool_max_weight` metric stays weight. `fullrbf` is `true` (Libre always replaces). `maxdatacarriersize` is JSON null (no datacarrier cap). `limitclustercount` / `limitclustersize` are the live cluster caps (count and vbytes). Fee amounts are 8-decimal BTC. `ancestorcount` / `descendantcount` (and size/fee sums) walk the cluster graph. `vsize`, `ancestorsize`, `descendantsize` and `chunkweight` are sigop-adjusted (Core `GetAdjustedWeight`, `--bytes-per-sigop`); `weight` is raw. Verbose `fees.{base,modified,ancestor,descendant,chunk}` and `chunkweight` include `prioritisetransaction` deltas; top-level `ancestorfees` / `descendantfees` stay base satoshis. `unbroadcastcount` / `unbroadcast` track `sendrawtransaction` txs until a peer getdata's them. `orphanage.{size,bytes}` is the parked missing-parent side pool (vsize). `permitbaremultisig` is always `true` (Libre has no Core `IsStandard` bare-multisig gate; `--permitbaremultisig` is not a node flag). | | `getorphantxs` | Hidden operator dump. Verbosity 0 txids, 1 details + `from` peer ids, 2 + hex. Not listed by `help` / `getrpcinfo`. Counts-only `getmempoolinfo.orphanage` is not a substitute. | -| `getrawtransaction` | Class A + mempool. Optional Core `blockhash` arg is accepted and ignored. Verbose objects share `tx_to_json` with `decoderawtransaction` / `getblock` verbosity 2 (`scriptSig`, `scriptPubKey.type`). A confirmed verbose result also carries Core's `confirmations`, `blockhash`, `blocktime`, and `time`. An unknown txid is `-5` `No such mempool or blockchain transaction` (Core). Below `pruneheight`: `-8` `Transaction not available (pruned data)` (not `-5`). The genesis coinbase txid is `-5` `The genesis block coinbase is not considered an ordinary transaction and cannot be retrieved` (Core); REST `/rest/tx/` answers `404` ` not found` for it, as Core (its txindex skips height 0). | +| `getrawtransaction` | Class A + mempool. Optional Core `blockhash` arg is accepted and ignored. Verbose objects share `tx_to_json` with `decoderawtransaction` / `getblock` verbosity 2 (`scriptSig`, `scriptPubKey.type`, `scriptPubKey.desc`). A confirmed verbose result also carries Core's `confirmations`, `blockhash`, `blocktime`, and `time`. An unknown txid is `-5` `No such mempool or blockchain transaction` (Core). Below `pruneheight`: `-8` `Transaction not available (pruned data)` (not `-5`). The genesis coinbase txid is `-5` `The genesis block coinbase is not considered an ordinary transaction and cannot be retrieved` (Core); REST `/rest/tx/` answers `404` ` not found` for it, as Core (its txindex skips height 0). | | `decoderawtransaction` | All networks. Decode hex. Optional `iswitness`: `false` refuses a BIP141 marker (`-22 TX decode failed`). Extra trailing bytes also `-22`. `scriptSig.asm` is rust-bitcoin, not Core `ScriptToAsmStr` sighash suffixes. Coinbase vin is Core-shaped `{coinbase, sequence}` (plus rbitcoin's `n`), with no `txid`/`vout`. | -| `decodescript` | All networks. `asm`, Core-style `type`, `hex`, and `address` when `Address::from_script` succeeds. No `p2sh` wrap, `segwit` wrap, or `desc` / miniscript. | +| `decodescript` | All networks. `asm`, Core-style `type`, `hex`, `address` when `Address::from_script` succeeds, and `desc`. `desc` is Core's inference with no keystore: `pk`, `multi`, `rawtr`, `addr`, else `raw`, with the BIP380 checksum; `pkh` / `wpkh` / `sh` / `wsh` / `tr` never appear. No `p2sh` wrap, `segwit` wrap, or miniscript. | | `validateaddress` | All networks. Valid: `isvalid`, `address`, `scriptPubKey`, `isscript`, `iswitness`, plus `witness_version` / `witness_program` when segwit except pay-to-anchor. P2TR and P2A set `isscript` true. Invalid (parse fail or wrong chain): `{isvalid: false}` only — no `error` / `error_locations`. | | `sendrawtransaction` / `testmempoolaccept` | `sendrawtransaction` is live accept (RPC still admits under `-blocksonly`; serving-only refuse is IBD / tip-not-ready `relay disabled`). `testmempoolaccept` / `submitpackage` `vsize` is sigop-adjusted. `testmempoolaccept` is dry-run (`MempoolHub::test_accept`: prepare + scripts + RBF/cluster checks, no commit / announce / RBF eviction / orphan park). Invalid hex / decode is `-22 TX decode failed` (same as `decoderawtransaction`). Confirmed on the active chain is `txn-already-known` (tip + confirmed-strong); live mempool duplicate is `txn-already-in-mempool`; archive-only after invalidate is not already-known. RPC-submit only: `maxfeerate` is **sat/vB** (default **10000**; `0` unlimited; `>= 100000` is `-8`). sendraw over-cap is `-25` configured-max text; `testmempoolaccept` reject-reason stays `max-fee-exceeded`. `maxburnamount` default **0** (valued unspendable / OP_RETURN outs). nVersion outside 1/2 is `"version"`. P2P `accept_tx` does not apply these caps. Multi-tx `testmempoolaccept` is sequential: earlier `allowed: true` rows stay (no abort-class blanking). Core functional tests still speak BTC/kvB via `scripts/core-functional/rpc_proxy.py`. | | `estimatesmartfee` | **10-minute inclusion frontier** — not Core historical multi-horizon. Core's result shape: `{feerate, blocks}`, or `{errors, blocks}` with no `feerate` when there is no estimate. Like Core, `feerate` is at least `mempoolminfee`. See [`mempool-fee-estimation.md`](./mempool-fee-estimation.md). | @@ -158,7 +158,7 @@ still wait for durable SH when shindex is on. | `gettxspendingprevout` | All networks. Live mempool spender of each `{txid,vout}`. | | `submitblock` | All networks. Like Core, a coinbase with a witness commitment and no witness first gets the 32-byte zero reserved value once segwit is active. The parent's height comes from the connected chain, a header tip, or a walk of at most 10,000 stored headers. A parent past that walk is treated as before segwit, so the block is left as sent. Then the same `ChainHub::accept_received_block` as a P2P `block` message: tip-extend, or hold by hash + most-work `accept_branch`. A consensus reject is a BIP22 reason string. A local store fault, including one reading the parent or tip header or a spent output, is RPC error `-25` (Core `state.IsError()`). A cancelled connect is `inconclusive`. Neither caches the block as invalid. | | `getblockfilter` | All networks. Requires `--block-filter-index`. `filtertype` `basic` only (anything else is `-5`). Returns `filter` and `header` hex. A sealed best-chain height uses the index. One block whose parent filter header is already sealed (genesis uses the zero prev-header) is built from the stored body. A stale branch is rebuilt back to that sealed fork point. An unsealed best-chain gap is “Filter not found. Block filters are still in the process of being indexed.” Unknown hash is `-5` “Block not found”. Flag off is “Index is not enabled”. REST `/rest/blockfilter/` stays watermark-only (“still in the process of being indexed” past the watermark). | -| `gettxout` | All networks. Connected Class A + mempool. `scriptPubKey` is the `getrawtransaction` object (`hex`, `asm`, Core `type`, and `address` for a standard script). Default `include_mempool=true` returns `null` for a confirmed out spent by a live mempool tx. `include_mempool=false` still returns the confirmed coin. A leftover still live in the hub (IBD / `-blocksonly`) uses the connected path, not `confirmations: 0`. A disconnected archive row is `null` (not tip+1 confirmations). A vout at or past the output count is `null`. The genesis coinbase output is `null` (Core never adds it to the coins view). So is a confirmed unspendable output: a leading `OP_RETURN` (including the witness commitment) or a script over 10,000 bytes. A mempool tx's outputs are all listed, as in Core. REST `getutxos` follows. | +| `gettxout` | All networks. Connected Class A + mempool. `scriptPubKey` is the `getrawtransaction` object (`hex`, `asm`, Core `type`, `address` for a standard script, and `desc`). Default `include_mempool=true` returns `null` for a confirmed out spent by a live mempool tx. `include_mempool=false` still returns the confirmed coin. A leftover still live in the hub (IBD / `-blocksonly`) uses the connected path, not `confirmations: 0`. A disconnected archive row is `null` (not tip+1 confirmations). A vout at or past the output count is `null`. The genesis coinbase output is `null` (Core never adds it to the coins view). So is a confirmed unspendable output: a leading `OP_RETURN` (including the witness commitment) or a script over 10,000 bytes. A mempool tx's outputs are all listed, as in Core. REST `getutxos` follows. | | `getindexinfo` | All networks. Reports `txindex` synced at tip — we reconstruct by txid from Class A (no separate index flag). | | `getchaintips` | All networks. Active + archive `valid-fork` + held `valid-headers` + header-only (`submitheader` / P2P headers). Invalid body after a known header marks that branch `invalid`. | | `getdeploymentinfo` | All networks. Buried deployments from `ChainParams` including `--test-activation-height`. `active` follows Core `DeploymentActiveAfter` (true for the *next* block). No BIP9 / testdummy. | @@ -176,7 +176,7 @@ still wait for durable SH when shindex is on. | Stratum / pool / BIP9 testdummy | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` are a cluster-chunk **selector** ([`COMPAT.md`](../COMPAT.md)). No stratum, no testdummy, no wallet keys | | Core `generate*` as a mining product | **Regtest harness only.** `submitblock` is the same receive path as P2P | | `combinerawtransaction` / `createrawtransaction` / `signrawtransactionwithkey` / `createmultisig` / `deriveaddresses` | Not implemented (harness proxy only) | -| Decode Core dialect | Node `decodescript` omits wrap/`desc`; `validateaddress` omits `error_locations`; `decoderawtransaction` asm is rust-bitcoin. Official scripts stay `rpc-dialect`. | +| Decode Core dialect | Node `decodescript` omits the `p2sh` / `segwit` wraps; `validateaddress` omits `error_locations`; `decoderawtransaction` asm is rust-bitcoin. Official scripts stay `rpc-dialect`. | | `scantxoutset` / `gettxoutsetinfo` | No UTXO set and no descriptor scan. Not implemented. Address history is Electrum/Esplora. | | Address history via Core method names | Use Electrum/Esplora with `--sh-index` | | Exact Core JSON field-for-field | Best-effort |