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
7 changes: 7 additions & 0 deletions changelog.d/scriptpubkey-desc.md
Original file line number Diff line number Diff line change
@@ -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.
151 changes: 151 additions & 0 deletions crates/rbitcoin-rpc/src/methods/descriptor.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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) {

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.

Nit: script_pubkey_json calls Address::from_script again to fill address. Outputs that are not pk, multi, or rawtr parse the script twice, including every standard output in getblock verbosity 2. The address string is the same either way.

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<usize> {
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<XOnlyPublicKey> {
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()
}
2 changes: 2 additions & 0 deletions crates/rbitcoin-rpc/src/methods/mine.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::descriptor::infer_descriptor;
use super::*;
use bitcoin::consensus::{deserialize, encode::serialize_hex};
use bitcoin::hashes::Hash;
Expand Down Expand Up @@ -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() {
Expand Down
5 changes: 3 additions & 2 deletions crates/rbitcoin-rpc/src/methods/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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",
Expand Down
84 changes: 83 additions & 1 deletion crates/rbitcoin-rpc/tests/methods/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading