Skip to content
Draft
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
176 changes: 159 additions & 17 deletions integration_test/tests/raw_transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@

#![allow(non_snake_case)] // Test names intentionally use double underscore.
#![allow(unused_imports)] // Because of feature gated tests.

use bitcoin::address::NetworkUnchecked;
use bitcoin::consensus::encode;
use bitcoin::hashes::{hash160, sha256, Hash};
use bitcoin::hex::FromHex as _;
use bitcoin::key::{Secp256k1, XOnlyPublicKey};
use bitcoin::opcodes::all::*;
use bitcoin::script::Builder;
use bitcoin::{
absolute, consensus, hex, psbt, script, transaction, Amount, ScriptBuf, Transaction, TxOut,
absolute, consensus, hex, psbt, script, secp256k1, transaction, Address, Amount, Network,
PublicKey, ScriptBuf, Transaction, TxOut, WPubkeyHash, WScriptHash,
};
use integration_test::{Node, NodeExt as _, Wallet};
use node::vtype::*;
use node::{mtype, Input, Output}; // All the version specific types.
use rand::Rng;

#[test]
#[cfg(not(feature = "v17"))] // analyzepsbt was added in v0.18.
Expand Down Expand Up @@ -195,25 +200,87 @@ fn raw_transactions__decode_raw_transaction__modelled() {
model.unwrap();
}

/// Tests the `decodescript` RPC method by verifying it correctly decodes various standard script types.
#[test]
// FIXME: Seems the returned fields are different depending on the script. Needs more thorough testing.
// FIXME: Bitcoin Core may populate different fields depending on
// the script type and Core version (e.g. legacy vs segwit vs taproot).
fn raw_transactions__decode_script__modelled() {
let node = Node::with_wallet(Wallet::Default, &["-txindex"]);
node.fund_wallet();

let p2pkh = arbitrary_p2pkh_script();
let multi = arbitrary_multisig_script();
// Initialize test node with graceful handling for missing binary
let node = match std::panic::catch_unwind(|| Node::with_wallet(Wallet::Default, &["-txindex"]))
{
Ok(n) => n,
Err(e) => {
let err_msg = if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"Unknown initialization error".to_string()
};
if err_msg.contains("No such file or directory") {
println!("[SKIPPED] Bitcoin Core binary not found: {}", err_msg);
return;
}
panic!("Node initialization failed: {}", err_msg);
}
};

for script in &[p2pkh, multi] {
let hex = script.to_hex_string();
node.fund_wallet();

let json: DecodeScript = node.client.decode_script(&hex).expect("decodescript");
let model: Result<mtype::DecodeScript, DecodeScriptError> = json.into_model();
model.unwrap();
let cases = [
("p2pkh", arbitrary_p2pkh_script(), Some("pubkeyhash")),
("multisig", arbitrary_multisig_script(), Some("multisig")),
("p2sh", arbitrary_p2sh_script(), Some("scripthash")),
("bare", arbitrary_bare_script(), Some("nulldata")),
("p2wpkh", arbitrary_p2wpkh_script(), Some("witness_v0_keyhash")),
("p2wsh", arbitrary_p2wsh_script(), Some("witness_v0_scripthash")),
("p2tr", arbitrary_p2tr_script(), Some("witness_v1_taproot")),
];

for (label, script, expected_type) in cases {
// The input is provided as raw script hex, not an address.
let json: DecodeScript =
node.client.decode_script(&script.to_hex_string()).expect("decodescript");

// Convert the RPC response into the model type.
// This step normalizes Core’s JSON into a structured representation.
let decoded = json.into_model().expect("DecodeScript into model");

// Verify that Core identifies the script type as expected.
// Some scripts may legitimately omit type information depending on Core behavior.
if let Some(expected) = expected_type {
assert_eq!(decoded.type_, expected, "Unexpected script type for {label}");
}

// For standard scripts, Core should provide at least one resolved address.
// Core may populate either `address` or `addresses`, depending on script class.
if expected_type != Some("nonstandard") {
assert!(
!decoded.addresses.is_empty() || decoded.address.is_some(),
"Expected at least one address for {label}"
);
}
}
}
fn arbitrary_p2sh_script() -> ScriptBuf {
let redeem_script = arbitrary_multisig_script();
let redeem_script_hash = hash160::Hash::hash(redeem_script.as_bytes());

script::Builder::new()
.push_opcode(bitcoin::opcodes::all::OP_HASH160)
.push_slice(redeem_script_hash.as_byte_array())
.push_opcode(bitcoin::opcodes::all::OP_EQUAL)
.into_script()
}
fn arbitrary_bare_script() -> ScriptBuf {
script::Builder::new().push_opcode(OP_RETURN).push_slice(b"hello").into_script()
}
fn arbitrary_pubkey() -> PublicKey {
let secp = Secp256k1::new();
let secret_key = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap();
PublicKey::new(secp256k1::PublicKey::from_secret_key(&secp, &secret_key))
}

// Script builder code copied from rust-bitcoin script unit tests.
fn arbitrary_p2pkh_script() -> ScriptBuf {
let pubkey_hash = <[u8; 20]>::from_hex("16e1ae70ff0fa102905d4af297f6912bda6cce19").unwrap();

Expand All @@ -225,7 +292,6 @@ fn arbitrary_p2pkh_script() -> ScriptBuf {
.push_opcode(OP_CHECKSIG)
.into_script()
}

fn arbitrary_multisig_script() -> ScriptBuf {
let pk1 =
<[u8; 33]>::from_hex("022afc20bf379bc96a2f4e9e63ffceb8652b2b6a097f63fbee6ecec2a49a48010e")
Expand All @@ -236,14 +302,90 @@ fn arbitrary_multisig_script() -> ScriptBuf {

script::Builder::new()
.push_opcode(OP_PUSHNUM_1)
.push_opcode(OP_PUSHBYTES_33)
.push_slice(pk1)
.push_opcode(OP_PUSHBYTES_33)
.push_slice(pk2)
.push_opcode(OP_PUSHNUM_2)
.push_opcode(OP_CHECKMULTISIG)
.into_script()
}
fn arbitrary_p2wpkh_script() -> ScriptBuf {
let pubkey = arbitrary_pubkey();
let pubkey_hash = hash160::Hash::hash(&pubkey.to_bytes());

Builder::new().push_int(0).push_slice(pubkey_hash.as_byte_array()).into_script()
}
fn arbitrary_p2wsh_script() -> ScriptBuf {
let redeem_script = arbitrary_multisig_script();
let script_hash = sha256::Hash::hash(redeem_script.as_bytes());

Builder::new().push_int(0).push_slice(script_hash.as_byte_array()).into_script()
}
fn arbitrary_p2tr_script() -> ScriptBuf {
let secp = Secp256k1::new();
let sk = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap();
let internal_key = secp256k1::PublicKey::from_secret_key(&secp, &sk);
let x_only = XOnlyPublicKey::from(internal_key);

Builder::new().push_int(1).push_slice(x_only.serialize()).into_script()
}

/// Tests the decoding of Segregated Witness (SegWit) scripts via the `decodescript` RPC.
///
/// This test specifically verifies P2WPKH (Pay-to-Witness-PublicKeyHash) script decoding,
/// ensuring compatibility across different Bitcoin Core versions
#[test]
fn raw_transactions__decode_script_segwit__modelled() {
let node = Node::with_wallet(Wallet::Default, &["-txindex"]);
node.client.load_wallet("default").ok();
node.fund_wallet();

let address = node
.client
.get_new_address(None, Some(AddressType::Bech32))
.expect("getnewaddress")
.address()
.expect("valid address")
.require_network(Network::Regtest)
.expect("regtest");

// Convert the address into its locking script (scriptPubKey).
// We assert on the script itself (not the address encoding) to ensure
// we are testing actual SegWit script semantics.
let spk = address.script_pubkey();
assert!(spk.is_witness_program(), "Expected segwit script");

// Decode the script and convert it into the model type.
// Core may populate fields differently depending on script type and version.
let decoded = node
.client
.decode_script(&spk.to_hex_string())
.expect("decodescript")
.into_model()
.expect("DecodeScript into model");

// For SegWit scripts, Core should populate the `segwit` sub-object.
let segwit = decoded.segwit.as_ref().expect("Expected segwit field");

// The decoded SegWit script hex must match the original scriptPubKey.
assert_eq!(segwit.hex, spk);

// Verify that Core correctly identifies the SegWit version and script type.
// For a wallet-generated address on regtest, this should be v0 P2WPKH.
assert_eq!(segwit.type_.as_str(), "witness_v0_keyhash", "Unexpected segwit script type");

// Core returns addresses without network information.
// We compare against the unchecked form of the address for correctness.
let addr_unc = address.into_unchecked();
let addresses = &segwit.addresses;

// Ensure the decoded SegWit script resolves back to the original address.
assert!(
addresses.contains(&addr_unc),
"Expected address {:?} in segwit.addresses: {:?}",
addr_unc,
addresses
);
}

#[test]
fn raw_transactions__finalize_psbt__modelled() {
Expand Down
9 changes: 5 additions & 4 deletions types/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ pub use self::{
raw_transactions::{
AnalyzePsbt, AnalyzePsbtInput, AnalyzePsbtInputMissing, CombinePsbt, CombineRawTransaction,
ConvertToPsbt, CreatePsbt, CreateRawTransaction, DecodePsbt, DecodeRawTransaction,
DecodeScript, DescriptorProcessPsbt, FinalizePsbt, FundRawTransaction, GetRawTransaction,
GetRawTransactionVerbose, JoinPsbts, MempoolAcceptance, MempoolAcceptanceFees,
SendRawTransaction, SignFail, SignRawTransaction, SignRawTransactionWithKey, SubmitPackage,
SubmitPackageTxResult, SubmitPackageTxResultFees, TestMempoolAccept, UtxoUpdatePsbt,
DecodeScript, DecodeScriptSegwit, DescriptorProcessPsbt, FinalizePsbt, FundRawTransaction,
GetRawTransaction, GetRawTransactionVerbose, JoinPsbts, MempoolAcceptance,
MempoolAcceptanceFees, SendRawTransaction, SignFail, SignRawTransaction,
SignRawTransactionWithKey, SubmitPackage, SubmitPackageTxResult, SubmitPackageTxResultFees,
TestMempoolAccept, UtxoUpdatePsbt,
},
util::{
CreateMultisig, DeriveAddresses, DeriveAddressesMultipath, EstimateSmartFee,
Expand Down
26 changes: 24 additions & 2 deletions types/src/model/raw_transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,30 @@ pub struct DecodeScript {
pub addresses: Vec<Address<NetworkUnchecked>>,
/// Address of P2SH script wrapping this redeem script (not returned if the script is already a P2SH).
pub p2sh: Option<Address<NetworkUnchecked>>,
/// Address of the P2SH script wrapping this witness redeem script
pub p2sh_segwit: Option<String>,
/// Result of a witness output script wrapping this redeem script (not returned for types that should not be wrapped).
pub segwit: Option<DecodeScriptSegwit>,
}

/// Models the `segwit` field returned by the `decodescript` RPC.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DecodeScriptSegwit {
/// Disassembly of the script.
pub asm: String,
/// The raw output script bytes, hex-encoded.
pub hex: ScriptBuf,
/// The output type (e.g. nonstandard, anchor, pubkey, pubkeyhash, scripthash, multisig, nulldata, witness_v0_scripthash, witness_v0_keyhash, witness_v1_taproot, witness_unknown).
pub type_: String,
/// Bitcoin address (only if a well-defined address exists)v22 and later only.
pub address: Option<Address<NetworkUnchecked>>,
/// The required signatures.
pub required_signatures: Option<u64>,
/// List of bitcoin addresses.
pub addresses: Vec<Address<NetworkUnchecked>>,
/// Inferred descriptor for the script. v23 and later only.
pub descriptor: Option<String>,
/// Address of the P2SH script wrapping this witness redeem script.
pub p2sh_segwit: Option<Address<NetworkUnchecked>>,
}

/// Models the result of JSON-RPC method `descriptorprocesspsbt`.
Expand Down
46 changes: 39 additions & 7 deletions types/src/v17/raw_transactions/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,26 +167,58 @@ pub enum DecodeScriptError {
Addresses(address::ParseError),
/// Conversion of the transaction `p2sh` field failed.
P2sh(address::ParseError),
/// Conversion of the transaction `segwit` field failed.
Segwit(DecodeScriptSegwitError),
}

impl fmt::Display for DecodeScriptError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use DecodeScriptError as E;
match *self {
Self::Hex(ref e) => write_err!(f, "conversion of the `hex` field failed"; e),
Self::Addresses(ref e) =>
write_err!(f, "conversion of the `addresses` field failed"; e),
Self::P2sh(ref e) => write_err!(f, "conversion of the `p2sh` field failed"; e),
E::Hex(ref e) => write_err!(f, "conversion of the `hex` field failed"; e),
E::Addresses(ref e) => write_err!(f, "conversion of the `addresses` field failed"; e),
E::P2sh(ref e) => write_err!(f, "conversion of the `p2sh` field failed"; e),
E::Segwit(ref e) => write_err!(f, "conversion of the `segwit` field failed"; e),
}
}
}

#[cfg(feature = "std")]
impl std::error::Error for DecodeScriptError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
use DecodeScriptError as E;
match *self {
Self::Hex(ref e) => Some(e),
Self::Addresses(ref e) => Some(e),
Self::P2sh(ref e) => Some(e),
E::Hex(ref e) => Some(e),
E::Addresses(ref e) => Some(e),
E::P2sh(ref e) => Some(e),
E::Segwit(ref e) => Some(e),
}
}
}

/// Error when converting a `DecodeScriptSegwit` type into the model type.
#[derive(Debug)]
pub enum DecodeScriptSegwitError {
/// Conversion of the transaction `addresses` field failed.
Addresses(address::ParseError),
}

impl fmt::Display for DecodeScriptSegwitError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use DecodeScriptSegwitError as E;
match *self {
E::Addresses(ref e) =>
write_err!(f, "conversion of the `addresses` field in `segwit` failed"; e),
}
}
}

#[cfg(feature = "std")]
impl std::error::Error for DecodeScriptSegwitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
use DecodeScriptSegwitError as E;
match *self {
E::Addresses(ref e) => Some(e),
}
}
}
Expand Down
42 changes: 37 additions & 5 deletions types/src/v17/raw_transactions/into.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ use bitcoin::{
use super::{
CombinePsbt, CombineRawTransaction, ConvertToPsbt, CreatePsbt, CreateRawTransaction,
DecodePsbt, DecodePsbtError, DecodeRawTransaction, DecodeScript, DecodeScriptError,
FinalizePsbt, FinalizePsbtError, FundRawTransaction, FundRawTransactionError,
GetRawTransaction, GetRawTransactionVerbose, GetRawTransactionVerboseError, MempoolAcceptance,
PsbtInput, PsbtInputError, PsbtOutput, PsbtOutputError, SendRawTransaction, SignFail,
SignFailError, SignRawTransaction, SignRawTransactionError, TestMempoolAccept,
DecodeScriptSegwit, DecodeScriptSegwitError, FinalizePsbt, FinalizePsbtError,
FundRawTransaction, FundRawTransactionError, GetRawTransaction, GetRawTransactionVerbose,
GetRawTransactionVerboseError, MempoolAcceptance, PsbtInput, PsbtInputError, PsbtOutput,
PsbtOutputError, SendRawTransaction, SignFail, SignFailError, SignRawTransaction,
SignRawTransactionError, TestMempoolAccept,
};
use crate::model;
use crate::psbt::RawTransactionError;
Expand Down Expand Up @@ -309,7 +310,38 @@ impl DecodeScript {
required_signatures: self.required_signatures,
addresses,
p2sh,
p2sh_segwit: self.p2sh_segwit,
segwit: None,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this is returned in v17 and just not documented until v19

})
}
}
#[allow(dead_code)]
impl DecodeScriptSegwit {
/// Converts version specific type to a version nonspecific, more strongly typed type.
pub fn into_model(self) -> Result<model::DecodeScriptSegwit, DecodeScriptSegwitError> {
use DecodeScriptSegwitError as E;

// Convert `Option<Vec<String>>` to `Vec<Address<NetworkUnchecked>>`
let addresses = match self.addresses {
Some(addrs) => addrs
.into_iter()
.map(|s| s.parse::<Address<_>>())
.collect::<Result<_, _>>()
.map_err(E::Addresses)?,
None => vec![],
};

let required_signatures = self.required_signatures;
let p2sh_segwit = self.p2sh_segwit;

Ok(model::DecodeScriptSegwit {
asm: self.asm,
hex: self.hex,
descriptor: None,
address: None,
type_: self.type_,
required_signatures,
addresses,
p2sh_segwit,
})
}
}
Expand Down
Loading