diff --git a/bin/floresta-cli/src/main.rs b/bin/floresta-cli/src/main.rs index 498bcf0e8..71aa50fe9 100644 --- a/bin/floresta-cli/src/main.rs +++ b/bin/floresta-cli/src/main.rs @@ -68,8 +68,8 @@ fn do_request(cmd: &Cli, client: Client) -> anyhow::Result { Methods::GetTxOutProof { txids, blockhash } => { serde_json::to_string_pretty(&client.get_txout_proof(txids, blockhash))? } - Methods::GetTransaction { txid, .. } => { - serde_json::to_string_pretty(&client.get_transaction(txid, Some(true))?)? + Methods::GetRawTransaction { txid, verbose } => { + serde_json::to_string_pretty(&client.get_raw_transaction(txid, verbose)?)? } Methods::RescanBlockchain { start_block, @@ -213,8 +213,14 @@ pub enum Methods { }, /// Returns the transaction, assuming it is cached by our watch only wallet - #[command(name = "gettransaction")] - GetTransaction { txid: Txid, verbose: Option }, + #[doc = include_str!("../../../doc/rpc/getrawtransaction.md")] + #[command( + name = "getrawtransaction", + about = "Returns raw transaction data for a given txid from wallet cache (controlled by verbosity level)", + long_about = Some(include_str!("../../../doc/rpc/getrawtransaction.md")), + disable_help_subcommand = true + )] + GetRawTransaction { txid: Txid, verbose: Option }, #[doc = include_str!("../../../doc/rpc/rescanblockchain.md")] #[command( diff --git a/crates/floresta-node/src/json_rpc/blockchain.rs b/crates/floresta-node/src/json_rpc/blockchain.rs index 8254e2f90..1cf676729 100644 --- a/crates/floresta-node/src/json_rpc/blockchain.rs +++ b/crates/floresta-node/src/json_rpc/blockchain.rs @@ -33,6 +33,7 @@ use super::server::RpcChain; use super::server::RpcImpl; use crate::json_rpc::res::GetBlockRes; use crate::json_rpc::res::RescanConfidence; +use crate::json_rpc::server::to_core_asm_string; impl RpcImpl { async fn get_block_inner(&self, hash: BlockHash) -> Result { @@ -375,7 +376,7 @@ impl RpcImpl { /// Returns a label about the scriptPubKey type /// (pubkey, pubkeyhash, multisig, nulldata, scripthash, witness_v0_keyhash, witness_v0_scripthash, witness_v1_taproot, anchor, nonstandard) - fn get_script_type_label(script: &Script) -> &'static str { + pub(super) fn get_script_type_label(script: &Script) -> &'static str { if script.is_p2pk() { return "pubkey"; } @@ -415,20 +416,15 @@ impl RpcImpl { "nonstandard" } - fn get_script_type_descriptor(script: &Script, address: &Option
) -> String { - let get_addr_str = || { - address - .as_ref() - .expect("address should be Some") - .to_string() - }; - - if script.is_p2pk() { - let addr = get_addr_str(); - return format!("pk({addr}"); - } - + /// TODO: This function is not compliant with Bitcoin Core. + /// See: + pub(super) fn get_script_type_descriptor(script: &Script, address: &Option
) -> String { + // Try script from the address if let Some(addr) = address { + if script.is_p2pk() { + return format!("pk({addr}"); + } + return format!("addr({addr})"); } @@ -437,85 +433,10 @@ impl RpcImpl { return format!("raw({hex})"); } - if Self::is_anchor_type(script) { - let addr = get_addr_str(); - return format!("addr({addr})"); - } - let hex = script.to_hex_string(); format!("raw({hex})") } - /// Parses the serialized opcodes in a [ScriptBuf] as numbers and it's hashes. - /// This differs from `ScriptBuf::to_asm_string` in that, `rust-bitcoin` will - /// show the the human representation of the opcode. It does not omit the number representations of - /// `OP_PUSHDATA_` and `OP_PUSHBYTE`. This method do the opposite: it not show the human - /// representation and omit the last opcodes, so it can be compliant with bitcoin-core. - /// For reference see - fn to_core_asm_string(script: &ScriptBuf) -> Result { - let mut asm = vec![]; - let bytes = script.as_bytes(); - let mut i = 0usize; - - // little reused helper to hex string - let to_hex_string = |r: &[u8]| r.iter().map(|b| format!("{b:02x}")).collect::(); - - while i < bytes.len() { - let byte = bytes[i]; - i += 1; - - match byte { - // OP_0 - 0x00 => asm.push(format!("{}", 0)), - // OP_PUSHDATA_: The next N bytes is data to be pushed onto the stack - 0x01..=0x4b => { - let pushed_bytes = byte as usize; - let hex = to_hex_string(&bytes[i..i + pushed_bytes]); - asm.push(hex); - i += pushed_bytes; - } - // OP_PUSHBYTE1: the next byte contains the number of bytes to be pushed onto the stack. - 0x4c => { - let pushed_bytes = bytes[i] as usize; - i += 1; - let hex = to_hex_string(&bytes[i..i + pushed_bytes]); - asm.push(hex); - i += pushed_bytes; - } - // OP_PUSHBYTE2: the next two bytes contain the number of bytes to be pushed onto the stack in little endian order. - 0x4d => { - let pushed_bytes = u16::from_le_bytes([bytes[i], bytes[i + 1]]) as usize; - i += 2; - let hex = to_hex_string(&bytes[i..i + pushed_bytes]); - asm.push(hex); - i += pushed_bytes; - } - // OP_PUSHBYTE4: the next four bytes contain the number of bytes to be pushed onto the stack in little endian order. - 0x4e => { - let pushed_bytes = - u32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) - as usize; - i += 4; - let hex = to_hex_string(&bytes[i..i + pushed_bytes]); - asm.push(hex); - i += pushed_bytes; - } - // OP_1 to OP_16 - 0x51..=0x60 => { - // 0x50 is OP_RESERVED - let reserved = 0x50; - asm.push(format!("{}", byte - reserved)); - } - // Any other opcode that should be pushed - another_one => { - asm.push(format!("{another_one:02x}")); - } - } - } - - Ok(asm.join(" ")) - } - /// gettxout: returns details about an unspent transaction output. pub(super) fn get_tx_out( &self, @@ -547,7 +468,7 @@ impl RpcImpl { Err(_) => None, }; - let asm = Self::to_core_asm_string(&txout.script_pubkey)?; + let asm = to_core_asm_string(&txout.script_pubkey, false); let script_pubkey = ScriptPubkey { asm, hex: txout.script_pubkey.to_hex_string(), diff --git a/crates/floresta-node/src/json_rpc/res.rs b/crates/floresta-node/src/json_rpc/res.rs index 5710cc1d2..80b14f8ae 100644 --- a/crates/floresta-node/src/json_rpc/res.rs +++ b/crates/floresta-node/src/json_rpc/res.rs @@ -76,54 +76,152 @@ impl RescanConfidence { } } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum GetRawTransactionRes { + Zero(String), + + One(Box), +} + +#[derive(Debug, Deserialize, Serialize)] +/// The information returned by a get_raw_tx pub struct RawTxJson { + /// Whether this tx is in our best known chain pub in_active_chain: bool, + + /// The hex-encoded tx pub hex: String, + + /// The sha256d of the serialized transaction without witness pub txid: String, + + /// The sha256d of the serialized transaction including witness pub hash: String, + + /// The size this transaction occupies on disk pub size: u32, + + /// The virtual size of this transaction, as define by the segwit soft-fork pub vsize: u32, + + /// The weight of this transaction, as defined by the segwit soft-fork pub weight: u32, + + /// This transaction's version. The current bigger version is 2 pub version: u32, + + /// This transaction's locktime pub locktime: u32, + + /// A list of inputs being spent by this transaction + /// + /// See [TxInJson] for more information about the contents of this pub vin: Vec, + + /// A list of outputs being created by this tx + /// + /// See [TxOutJson] for more information pub vout: Vec, - pub blockhash: String, - pub confirmations: u32, - pub blocktime: u32, - pub time: u32, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The hash of the block that included this tx, if any + pub blockhash: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// How many blocks have been mined after this transaction's confirmation + /// including the block that confirms it. A zero value means this tx is unconfirmed + pub confirmations: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The timestamp for the block confirming this tx, if confirmed + pub blocktime: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// Same as blocktime + pub time: Option, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] +/// A transaction output returned by some RPCs like gettransaction and getblock pub struct TxOutJson { - pub value: u64, + /// The amount in btc locked in this UTXO + pub value: f64, + + /// This utxo's index inside the transaction pub n: u32, + + #[serde(rename = "scriptPubKey")] + /// The locking script of this utxo pub script_pub_key: ScriptPubKeyJson, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] +/// The locking script inside a txout pub struct ScriptPubKeyJson { + /// A ASM representation for this script + /// + /// Assembly is a high-level representation of a lower level code. Instructions + /// are turned into OP_XXXXX and data is hex-encoded. + /// E.g: OP_DUP OP_HASH160 <0000000000000000000000000000000000000000> OP_EQUALVERIFY OP_CHECKSIG pub asm: String, + + /// The hex-encoded raw script pub hex: String, - pub req_sigs: u32, + #[serde(rename = "type")] + /// The type of this spk. E.g: PKH, SH, WSH, WPKH, TR, non-standard... pub type_: String, - pub address: String, + + #[serde(skip_serializing_if = "Option::is_none")] + /// Encode this script using one of the standard address types, if possible + pub address: Option, + + #[serde(rename = "desc")] + /// Inferred descriptor for the output + pub descriptor: String, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Default)] +/// A transaction input returned by some rpcs, like gettransaction and getblock pub struct TxInJson { - pub txid: String, - pub vout: u32, - pub script_sig: ScriptSigJson, + #[serde(skip_serializing_if = "Option::is_none")] + /// The coinbase field is only set for coinbase transactions, and contains the hex-encoded coinbase script + pub coinbase: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The txid that created this UTXO. Not set for coinbase transactions + pub txid: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The index of this UTXO inside the tx that created it. Not set for coinbase transactions + pub vout: Option, + + #[serde(rename = "scriptSig", skip_serializing_if = "Option::is_none")] + /// Unlocking script that should solve the challenge and prove ownership over + /// that UTXO. Not set for coinbase transactions + pub script_sig: Option, + + /// The nSequence field, used in relative and absolute lock-times pub sequence: u32, - pub witness: Vec, + + #[serde(rename = "txinwitness", skip_serializing_if = "Option::is_none")] + /// A vector of witness elements for this input + pub witness: Option>, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] +/// A representation for the transaction ScriptSig, returned by some rpcs +/// like gettransaction and getblock pub struct ScriptSigJson { + /// A ASM representation for this scriptSig + /// + /// Assembly is a high-level representation of a lower level code. Instructions + /// are turned into OP_XXXXX and data is hex-encoded. + /// E.g: OP_PUSHBYTES32 <000000000000000000000000000000000000000000000000000000000000000000> pub asm: String, + + /// The hex-encoded script sig pub hex: String, } diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 2fd8ed4c6..f26aeadc7 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -16,11 +16,11 @@ use axum::Json; use axum::Router; use bitcoin::consensus::deserialize; use bitcoin::consensus::encode::serialize_hex; +use bitcoin::ecdsa::Signature as EcdsaSignature; use bitcoin::hashes::hex::FromHex; -use bitcoin::hashes::Hash; use bitcoin::hex::DisplayHex; +use bitcoin::taproot::Signature as TaprootSignature; use bitcoin::Address; -use bitcoin::BlockHash; use bitcoin::Network; use bitcoin::ScriptBuf; use bitcoin::Transaction; @@ -42,6 +42,7 @@ use tracing::debug; use tracing::error; use tracing::info; +use super::res::GetRawTransactionRes; use super::res::JsonRpcError; use super::res::RawTxJson; use super::res::RpcError; @@ -86,19 +87,23 @@ pub struct RpcImpl { type Result = std::result::Result; impl RpcImpl { - fn get_transaction(&self, tx_id: Txid, verbosity: Option) -> Result { - if verbosity == Some(true) { - let tx = self - .wallet - .get_transaction(&tx_id) - .ok_or(JsonRpcError::TxNotFound); - return tx.map(|tx| serde_json::to_value(self.make_raw_transaction(tx)).unwrap()); + fn get_raw_transaction(&self, tx_id: Txid, verbosity: u8) -> Result { + if verbosity > 1 { + return Err(JsonRpcError::InvalidVerbosityLevel); } - self.wallet + let tx = self + .wallet .get_transaction(&tx_id) - .and_then(|tx| serde_json::to_value(self.make_raw_transaction(tx)).ok()) - .ok_or(JsonRpcError::TxNotFound) + .ok_or(JsonRpcError::TxNotFound)?; + + match verbosity { + 0 => Ok(GetRawTransactionRes::Zero(serialize_hex(&tx.tx))), + 1 => Ok(GetRawTransactionRes::One(Box::new( + self.make_raw_transaction(tx), + ))), + _ => Err(JsonRpcError::InvalidVerbosityLevel), + } } fn load_descriptor(&self, descriptor: String) -> Result { @@ -290,10 +295,10 @@ async fn handle_json_rpc_request( "getrawtransaction" => { let txid = get_hash(¶ms, 0, "txid")?; - let verbosity = get_optional_field(¶ms, 1, "verbosity", get_bool)?; + let verbosity = get_optional_field(¶ms, 1, "verbosity", get_numeric)?.unwrap_or(0); state - .get_transaction(txid, verbosity) + .get_raw_transaction(txid, verbosity) .map(|v| serde_json::to_value(v).unwrap()) } @@ -606,57 +611,58 @@ impl RpcImpl { Ok(()) } - fn make_vin(&self, input: TxIn) -> TxInJson { - let txid = serialize_hex(&input.previous_output.txid); - let vout = input.previous_output.vout; + fn make_vin(&self, input: TxIn, is_coinbase: bool) -> TxInJson { let sequence = input.sequence.0; - TxInJson { - txid, - vout, - script_sig: ScriptSigJson { - asm: input.script_sig.to_asm_string(), - hex: input.script_sig.to_hex_string(), - }, - witness: input + let witness = (!input.witness.is_empty()).then_some( + input .witness .iter() - .map(|w| w.to_hex_string(bitcoin::hex::Case::Upper)) + .map(|w| w.to_hex_string(bitcoin::hex::Case::Lower)) .collect(), - sequence, + ); + + if is_coinbase { + return TxInJson { + coinbase: Some(input.script_sig.to_hex_string()), + sequence, + witness, + ..Default::default() + }; } - } - fn get_script_type(script: ScriptBuf) -> Option<&'static str> { - if script.is_p2pkh() { - return Some("p2pkh"); - } - if script.is_p2sh() { - return Some("p2sh"); - } - if script.is_p2wpkh() { - return Some("v0_p2wpkh"); - } - if script.is_p2wsh() { - return Some("v0_p2wsh"); + let txid = Some(input.previous_output.txid.to_string()); + let vout = Some(input.previous_output.vout); + let script_sig = ScriptSigJson { + asm: to_core_asm_string(&input.script_sig, true), + hex: input.script_sig.to_hex_string(), + }; + + TxInJson { + coinbase: None, + txid, + vout, + script_sig: Some(script_sig), + witness, + sequence, } - None } fn make_vout(&self, output: TxOut, n: u32) -> TxOutJson { let value = output.value; TxOutJson { - value: value.to_sat(), + value: value.to_btc(), n, script_pub_key: ScriptPubKeyJson { - asm: output.script_pubkey.to_asm_string(), + asm: to_core_asm_string(&output.script_pubkey, false), hex: output.script_pubkey.to_hex_string(), - req_sigs: 0, // This field is deprecated address: Address::from_script(&output.script_pubkey, self.network) .map(|a| a.to_string()) - .unwrap(), - type_: Self::get_script_type(output.script_pubkey) - .unwrap_or("nonstandard") - .to_string(), + .ok(), + type_: Self::get_script_type_label(&output.script_pubkey).to_string(), + descriptor: Self::get_script_type_descriptor( + &output.script_pubkey, + &Address::from_script(&output.script_pubkey, self.network).ok(), + ), }, } } @@ -665,23 +671,35 @@ impl RpcImpl { let raw_tx = tx.tx; let in_active_chain = tx.height != 0; let hex = serialize_hex(&raw_tx); - let txid = serialize_hex(&raw_tx.compute_txid()); - let block_hash = self - .chain - .get_block_hash(tx.height) - .unwrap_or(BlockHash::all_zeros()); - let tip = self.chain.get_height().unwrap(); - let confirmations = if in_active_chain { - tip - tx.height + 1 - } else { - 0 - }; + let txid = raw_tx.compute_txid().to_string(); + + let mut blockhash = None; + let mut blocktime = None; + let mut time = None; + let mut confirmations = Some(0); + if in_active_chain { + confirmations = self.chain.get_height().ok().and_then(|tip| { + if tip >= tx.height { + Some(tip - tx.height + 1) + } else { + None + } + }); + + if let Ok(hash) = self.chain.get_block_hash(tx.height) { + if let Ok(header) = self.chain.get_block_header(&hash) { + blockhash = Some(header.block_hash().to_string()); + blocktime = Some(header.time); + time = Some(header.time); + } + } + } RawTxJson { in_active_chain, hex, txid, - hash: serialize_hex(&raw_tx.compute_wtxid()), + hash: raw_tx.compute_wtxid().to_string(), size: raw_tx.total_size() as u32, vsize: raw_tx.vsize() as u32, weight: raw_tx.weight().to_wu() as u32, @@ -690,26 +708,18 @@ impl RpcImpl { vin: raw_tx .input .iter() - .map(|input| self.make_vin(input.clone())) + .map(|input| self.make_vin(input.clone(), raw_tx.is_coinbase())) .collect(), vout: raw_tx .output .into_iter() .enumerate() .map(|(i, output)| self.make_vout(output, i as u32)) - .collect(), - blockhash: serialize_hex(&block_hash), + .collect::>(), + blockhash, confirmations, - blocktime: self - .chain - .get_block_header(&block_hash) - .map(|h| h.time) - .unwrap_or(0), - time: self - .chain - .get_block_header(&block_hash) - .map(|h| h.time) - .unwrap_or(0), + blocktime, + time, } } @@ -782,3 +792,166 @@ impl RpcImpl { .expect("failed to start rpc server"); } } + +/// Converts a script to ASM (assembly) format, displaying the script's operations +/// in a format similar to Bitcoin Core. +/// +/// This function performs the following transformations: +/// 1. Removes OP_PUSHBYTES and OP_PUSHDATA opcodes (these are unnecessary in ASM output) +/// 2. Converts leading OP_0 to "0" and OP_PUSHNUM_1 to "1" (these represent witness versions) +/// 3. If `attempt_sighash_decode` is true, attempts to decode hexadecimal data as signatures +/// and appends their sighash type (useful for analyzing scripts in scriptSig) +/// +/// # Arguments +/// * `script` - The script buffer to convert +/// * `attempt_sighash_decode` - If true, tries to parse data elements as signatures and format them +pub(super) fn to_core_asm_string(script: &ScriptBuf, attempt_sighash_decode: bool) -> String { + let mut script_asm = script.to_asm_string(); + if !script_asm.contains(' ') { + return script_asm; + } + + // Remove OP_PUSHBYTES_X opcodes (these are only metadata for script serialization) + for i in 0..=75 { + script_asm = script_asm.replace(&format!("OP_PUSHBYTES_{} ", i), ""); + } + + // Remove OP_PUSHDATA1/2/4 opcodes (these are only metadata for script serialization) + for i in 1..=4 { + script_asm = script_asm.replace(&format!("OP_PUSHDATA{} ", i), ""); + } + + let mut array_script_asm: Vec = script_asm.split(' ').map(String::from).collect(); + + // Convert leading OP_0 to "0" - represents witness version 0 + if array_script_asm[0] == "OP_0" { + array_script_asm[0] = "0".to_string(); + } + + // Convert leading OP_PUSHNUM_1 to "1" - represents witness version 1 (Taproot) + if array_script_asm[0] == "OP_PUSHNUM_1" { + array_script_asm[0] = "1".to_string(); + } + + // If enabled, attempt to decode data elements as signatures and format them + // This is particularly useful for scriptSig analysis, where signatures are wrapped with their sighash type + if attempt_sighash_decode { + for word in array_script_asm.iter_mut() { + // Skip OP codes and small words that are unlikely to be signatures + if word.contains("OP") || word.len() <= 8 { + continue; + } + + if let Some(decoded) = + try_parse_and_format_signature(&Vec::from_hex(word).unwrap_or_default()) + { + *word = decoded; + } + } + } + + array_script_asm.join(" ") +} + +/// Attempts to decode a byte slice as a valid signature (ECDSA or Taproot). +/// If the bytes represent a valid signature, returns the signature with the sighash type appended. +fn try_parse_and_format_signature(signature_bytes: &[u8]) -> Option { + macro_rules! try_decode_signature { + ($sig_type:ty) => { + if let Ok(signature) = <$sig_type>::from_slice(signature_bytes) { + // Extract the sighash type and remove the "SIGHASH_" prefix + // The rust-bitcoin library prefixes "SIGHASH_" to the type name, but Bitcoin Core + // does not include this prefix in the output + let label = signature.sighash_type.to_string().replace("SIGHASH_", ""); + return Some(format!("{}[{}]", signature.signature, label)); + } + }; + } + + // Attempt to parse as ECDSA signature + try_decode_signature!(EcdsaSignature); + + // Attempt to parse as Taproot signature + try_decode_signature!(TaprootSignature); + + // If the bytes don't match any known signature format, return None + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_converter_script_into_asm_not_attempt_sighash_decode() { + let test_cases = [ + // P2WPKH + ( + "0014aabc2cd363103811113b040c541afe3759489c96", + "0 aabc2cd363103811113b040c541afe3759489c96", + ), + // BECH32 + ( + "0014251619c32f6500664e71a6d0393ec4b5f6da549c", + "0 251619c32f6500664e71a6d0393ec4b5f6da549c", + ), + ( + "0014aa138477d24cb7b7a84160ef55af14b7bfb98143", + "0 aa138477d24cb7b7a84160ef55af14b7bfb98143", + ), + // P2PKH + ( + "76a914e7d68c17e6275b2e5c1da053ef648c676c38962488ac", + "OP_DUP OP_HASH160 e7d68c17e6275b2e5c1da053ef648c676c389624 OP_EQUALVERIFY OP_CHECKSIG", + ), + ( + "76a9144eb2df72d9befff81b6dd985044d2d1b3ed4de4188ac", + "OP_DUP OP_HASH160 4eb2df72d9befff81b6dd985044d2d1b3ed4de41 OP_EQUALVERIFY OP_CHECKSIG", + ), + // P2SH + ( + "a914fae946075d1f629d35ed4067eca928c1632f4fef87", + "OP_HASH160 fae946075d1f629d35ed4067eca928c1632f4fef OP_EQUAL", + ), + // P2TR (Taproot) + ( + "51209ec7be23a1ec17cd9c4b621d899eec02bacde1d754ab080f9e1ac8445820014e", + "1 9ec7be23a1ec17cd9c4b621d899eec02bacde1d754ab080f9e1ac8445820014e", + ), + ]; + + for (script_hex, expected_asm) in test_cases.iter() { + let script = ScriptBuf::from_hex(script_hex).unwrap(); + let asm = to_core_asm_string(&script, false); + + assert_eq!(asm, *expected_asm); + } + } + + #[test] + fn test_converter_script_into_asm_attempt_sighash_decode() { + let test_cases = [ + // scriptSig with ECDSA signature and pubkey + ( + "47304402205a9b7c4432f9d895cbf4ac78519ae4e9776d47776078521b93e06beda560dd9a02202b1afbda3c917c2698b38f78203e03d2743069939e3ce2b6a3a153e148502f19012103fde976887234670c672e33a4707356997df737f3e7ac6de809164b5a606b8bad", + "304402205a9b7c4432f9d895cbf4ac78519ae4e9776d47776078521b93e06beda560dd9a02202b1afbda3c917c2698b38f78203e03d2743069939e3ce2b6a3a153e148502f19[ALL] 03fde976887234670c672e33a4707356997df737f3e7ac6de809164b5a606b8bad", + ), + ( + "47304402204ab6753b249205b01d938826189cefaa4176e32ca5aa64fc6fd51891fb78fed2022065b7ba08d8739884ba232f5f7bf6efbb36b2cf98917630c64343cad2fe9db3a2012102ecf8dfb67cae8fe66d700cb13c458e5cc59be2a1c5f3ca3c5a54745259cbe45c", + "304402204ab6753b249205b01d938826189cefaa4176e32ca5aa64fc6fd51891fb78fed2022065b7ba08d8739884ba232f5f7bf6efbb36b2cf98917630c64343cad2fe9db3a2[ALL] 02ecf8dfb67cae8fe66d700cb13c458e5cc59be2a1c5f3ca3c5a54745259cbe45c", + ), + // P2WPKH + ( + "160014bb180b7bf33f066f7b557c09a0bd3b6accc84fcf", + "0014bb180b7bf33f066f7b557c09a0bd3b6accc84fcf", + ), + ]; + + for (script_hex, expected_asm) in test_cases.iter() { + let script = ScriptBuf::from_hex(script_hex).unwrap(); + let asm = to_core_asm_string(&script, true); + + assert_eq!(asm, *expected_asm); + } + } +} diff --git a/crates/floresta-rpc/src/rpc.rs b/crates/floresta-rpc/src/rpc.rs index 64d18b149..bd5034d23 100644 --- a/crates/floresta-rpc/src/rpc.rs +++ b/crates/floresta-rpc/src/rpc.rs @@ -51,9 +51,13 @@ pub trait FlorestaRPC { /// Gets a transaction from the blockchain /// /// This method returns a transaction that's cached in our wallet. If the verbosity flag is - /// set to false, the transaction is returned as a hexadecimal string. If the verbosity - /// flag is set to true, the transaction is returned as a json object. - fn get_transaction(&self, tx_id: Txid, verbosity: Option) -> Result; + /// set to 0, the transaction is returned as a hexadecimal string. If the verbosity + /// flag is set to 1, the transaction is returned as a json object. + fn get_raw_transaction( + &self, + tx_id: Txid, + verbosity: Option, + ) -> Result; /// Returns the proof that one or more transactions were included in a block /// /// This method returns the Merkle proof, showing that a transaction was included in a block. @@ -304,12 +308,18 @@ impl FlorestaRPC for T { self.call("getblockhash", &[Value::Number(Number::from(height))]) } - fn get_transaction(&self, tx_id: Txid, verbosity: Option) -> Result { - let verbosity = verbosity.unwrap_or(false); - self.call( - "gettransaction", - &[Value::String(tx_id.to_string()), Value::Bool(verbosity)], - ) + fn get_raw_transaction( + &self, + tx_id: Txid, + verbosity: Option, + ) -> Result { + let mut params = vec![Value::String(tx_id.to_string())]; + + if let Some(verbosity) = verbosity { + params.push(Value::Number(Number::from(verbosity))); + } + + self.call("getrawtransaction", ¶ms) } fn load_descriptor(&self, descriptor: String) -> Result { diff --git a/crates/floresta-rpc/src/rpc_types.rs b/crates/floresta-rpc/src/rpc_types.rs index 02377eb1b..2a4389be7 100644 --- a/crates/floresta-rpc/src/rpc_types.rs +++ b/crates/floresta-rpc/src/rpc_types.rs @@ -58,59 +58,88 @@ pub struct GetBlockchainInfoRes { pub difficulty: u64, } +#[derive(Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum GetRawTransactionRes { + Zero(String), + + One(Box), +} + +#[derive(Debug, Deserialize, Serialize)] /// The information returned by a get_raw_tx -#[derive(Deserialize, Serialize)] pub struct RawTx { /// Whether this tx is in our best known chain pub in_active_chain: bool, + /// The hex-encoded tx pub hex: String, + /// The sha256d of the serialized transaction without witness pub txid: String, + /// The sha256d of the serialized transaction including witness pub hash: String, + /// The size this transaction occupies on disk pub size: u32, + /// The virtual size of this transaction, as define by the segwit soft-fork pub vsize: u32, + /// The weight of this transaction, as defined by the segwit soft-fork pub weight: u32, + /// This transaction's version. The current bigger version is 2 pub version: u32, + /// This transaction's locktime pub locktime: u32, + /// A list of inputs being spent by this transaction /// /// See [TxIn] for more information about the contents of this pub vin: Vec, + /// A list of outputs being created by this tx /// /// Se [TxOut] for more information pub vout: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] /// The hash of the block that included this tx, if any - pub blockhash: String, + pub blockhash: Option, + + #[serde(skip_serializing_if = "Option::is_none")] /// How many blocks have been mined after this transaction's confirmation /// including the block that confirms it. A zero value means this tx is unconfirmed - pub confirmations: u32, + pub confirmations: Option, + + #[serde(skip_serializing_if = "Option::is_none")] /// The timestamp for the block confirming this tx, if confirmed - pub blocktime: u32, + pub blocktime: Option, + + #[serde(skip_serializing_if = "Option::is_none")] /// Same as blocktime - pub time: u32, + pub time: Option, } +#[derive(Debug, Deserialize, Serialize)] /// A transaction output returned by some RPCs like gettransaction and getblock -#[derive(Deserialize, Serialize)] pub struct TxOut { - /// The amount in sats locked in this UTXO - pub value: u64, + /// The amount in btc locked in this UTXO + pub value: f64, + /// This utxo's index inside the transaction pub n: u32, + + #[serde(rename = "scriptPubKey")] /// The locking script of this utxo pub script_pub_key: ScriptPubKey, } +#[derive(Debug, Deserialize, Serialize)] /// The locking script inside a txout -#[derive(Deserialize, Serialize)] pub struct ScriptPubKey { /// A ASM representation for this script /// @@ -118,38 +147,54 @@ pub struct ScriptPubKey { /// are turned into OP_XXXXX and data is hex-encoded. /// E.g: OP_DUP OP_HASH160 <0000000000000000000000000000000000000000> OP_EQUALVERIFY OP_CHECKSIG pub asm: String, + /// The hex-encoded raw script pub hex: String, - /// How many signatures are required to spend this UTXO. - /// - /// This field is deprecated and is here for compatibility with Core - pub req_sigs: u32, + #[serde(rename = "type")] /// The type of this spk. E.g: PKH, SH, WSH, WPKH, TR, non-standard... pub type_: String, + + #[serde(skip_serializing_if = "Option::is_none")] /// Encode this script using one of the standard address types, if possible - pub address: String, + pub address: Option, + + #[serde(rename = "desc")] + /// Inferred descriptor for the output + pub descriptor: String, } +#[derive(Debug, Deserialize, Serialize, Default)] /// A transaction input returned by some rpcs, like gettransaction and getblock -#[derive(Deserialize, Serialize)] pub struct TxIn { - /// The txid that created this UTXO - pub txid: String, - /// The index of this UTXO inside the tx that created it - pub vout: u32, + #[serde(skip_serializing_if = "Option::is_none")] + /// The coinbase field is only set for coinbase transactions, and contains the hex-encoded coinbase script + pub coinbase: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The txid that created this UTXO. Not set for coinbase transactions + pub txid: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The index of this UTXO inside the tx that created it. Not set for coinbase transactions + pub vout: Option, + + #[serde(rename = "scriptSig", skip_serializing_if = "Option::is_none")] /// Unlocking script that should solve the challenge and prove ownership over - /// that UTXO - pub script_sig: ScriptSigJson, + /// that UTXO. Not set for coinbase transactions + pub script_sig: Option, + /// The nSequence field, used in relative and absolute lock-times pub sequence: u32, + + #[serde(rename = "txinwitness", skip_serializing_if = "Option::is_none")] /// A vector of witness elements for this input - pub witness: Vec, + pub witness: Option>, } +#[derive(Debug, Deserialize, Serialize)] /// A representation for the transaction ScriptSig, returned by some rpcs /// like gettransaction and getblock -#[derive(Deserialize, Serialize)] pub struct ScriptSigJson { /// A ASM representation for this scriptSig /// @@ -157,6 +202,7 @@ pub struct ScriptSigJson { /// are turned into OP_XXXXX and data is hex-encoded. /// E.g: OP_PUSHBYTES32 <000000000000000000000000000000000000000000000000000000000000000000> pub asm: String, + /// The hex-encoded script sig pub hex: String, } diff --git a/doc/rpc/getrawtransaction.md b/doc/rpc/getrawtransaction.md new file mode 100644 index 000000000..c498a7d55 --- /dev/null +++ b/doc/rpc/getrawtransaction.md @@ -0,0 +1,80 @@ +# `getrawtransaction` + +Returns a transaction that is cached in the wallet. This method retrieves transactions +that have been tracked and indexed by the wallet during blockchain synchronization or +through explicit tracking. The response format can be controlled via the verbosity parameter. + +## Usage + +### Synopsis + +``` +floresta-cli getrawtransaction [] +``` + +### Examples + +```bash +# Get transaction as serialized hex string +floresta-cli getrawtransaction "d7d7558342e3aff8fc4ae0a84bc7d19c0add4dfa1a3c0fa56cf66dc4f2400145" + +# Get transaction as detailed JSON object +floresta-cli getrawtransaction "d7d7558342e3aff8fc4ae0a84bc7d19c0add4dfa1a3c0fa56cf66dc4f2400145" 1 + +# Default behavior (hex format) +floresta-cli getrawtransaction "d7d7558342e3aff8fc4ae0a84bc7d19c0add4dfa1a3c0fa56cf66dc4f2400145" 0 +``` + +## Arguments + +-`txid` - (string, required) The transaction ID to retrieve. +- `verbosity` - (numeric, optional, default=1) + * `0`: Returns transaction as a raw serialized hexadecimal string. + * `1`: Returns transaction as a detailed JSON object with decoded fields. + +## Returns + +### Ok Response (verbosity = 0) + +- `"hex"` - (string) A serialized, hex-encoded string of the transaction data. + +### Ok Response (verbosity = 1) + +- `in_active_chain` - (boolean) Whether the transaction is in the active blockchain +- `blockhash` - (string, optional) The block hash containing this transaction +- `confirmations` - (numeric, optional) Number of confirmations (0 if in mempool) +- `blocktime` - (numeric, optional) Block creation time as Unix timestamp +- `time` - (numeric, optional) Transaction time as Unix timestamp +- `hex` - (string) The serialized, hex-encoded data for 'txid' +- `txid` - (string) The transaction id (same as provided) +- `hash` - (string) The transaction hash (differs from txid for witness transactions) +- `size` - (numeric) The transaction size in bytes +- `vsize` - (numeric) The virtual transaction size (differs from size for witness transactions) +- `weight` - (numeric) The transaction's weight (between vsize*4-3 and vsize*4) +- `version` - (numeric) The transaction version +- `locktime` - (numeric) The block height or timestamp at which transaction is final +- `vin` - (array) Array of transaction inputs + * `coinbase` - (string) The hex-encoded coinbase script data. **Only if coinbase transaction** + * `txid` - (string) The previous transaction ID. **Only if non-coinbase transaction** + * `vout` - (numeric) The output index in the previous transaction. **Only if non-coinbase transaction** + * `script_sig` - (object) Contains the scriptSig. **Only if non-coinbase transaction** + - `hex` - (string) The script hex + * `sequence` - (numeric) The sequence number + * `txinwitness` - (array, optional) Witness stack + - `hex` - (string) hex-encoded witness data (if any) +- `vout` - (array) Array of transaction outputs + * `value` - (numeric) Amount in btc + * `n` - (numeric) The output index + * `script_pub_key` - (object) Contains the scriptPubKey + - `asm` - (string) Disassembly of the output script + - `hex` - (string) The raw output script bytes, hex-encoded + - `type` - (string) The type of script (pubkey, pubkeyhash, multisig, etc.) + - `address` - (string, optional) The Bitcoin address (if applicable) + +### Error Enum `JsonRpcError` + +- `TxNotFound` - The requested transaction is not found in the wallet cache +- `InvalidVerbosityLevel` - Verbosity parameter is not 0 or 1 +- `MissingParameter` - The txid parameter was not provided +- `InvalidParameterType` - The txid is not a valid hex string +- `Decode` - Error during transaction decoding or serialization diff --git a/tests/floresta-cli/getrawtransaction.py b/tests/floresta-cli/getrawtransaction.py new file mode 100644 index 000000000..db758ed0a --- /dev/null +++ b/tests/floresta-cli/getrawtransaction.py @@ -0,0 +1,245 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 + +""" +floresta_cli_getrawtransaction.py + +Integrate test for the CLI utility that interacts with a Floresta node +using the `getrawtransaction` RPC method. +""" + +import time +import os +import pytest +from test_framework.node import NodeType +from requests.exceptions import HTTPError + +ADDRESS_COINBASE = "bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y" +ADDRESS_LEGACY = "n2eoQNSGg7ZWjnbXzdnGDMHZShn3MjaEfR" +ADDRESS_P2PKH = "mnh5HKWqsYdRo8ChUc2rN9bDcMRj4HopFw" +ADDRESS_P2PWH = "2NG7vNNXVRMihLD14WBbxhMxEQ1qKBkepq1" +ADDRESS_BECH32 = "bcrt1q427ze5mrzqupzyfmqsx9gxh7xav538yk2j4cft" +ADDRESS_BECH32M = "bcrt1p929uxzkp0lnh3smkkcvdqerj7ejhhac6vsc2lr0gqc4l092w8yjq64dhfy" +ADDRESS_TAPROOT = "bcrt1pnmrmugapastum8ztvgwcn8hvq2avmcwh2j4ssru7rtyygkpqq98q4wyd6s" + +WALLET_CONFIG = "\n".join( + [ + "[wallet]", + ( + f'addresses = [ "{ADDRESS_COINBASE}", ' + f'"{ADDRESS_LEGACY}", "{ADDRESS_P2PKH}", ' + f'"{ADDRESS_P2PWH}", "{ADDRESS_BECH32}", ' + f'"{ADDRESS_BECH32M}", "{ADDRESS_TAPROOT}" ]' + ), + ] +) + +MINED_BLOCKS = 101 +COINBASE_BLOCKS = 6 + + +class TestGetRawTransaction: + """ + Test `getrawtransaction` RPC method of Floresta node by comparing + its response with Bitcoin Core's response for the same transaction. + """ + + log = None + node_manager = None + florestad = None + bitcoind = None + utreexod = None + + # pylint: disable=too-many-statements,too-many-locals + @pytest.mark.rpc + def test_get_raw_transaction( + self, setup_logging, node_manager, add_node_with_extra_args, utreexod_node + ): + """ + Run the test by sending transactions, mining blocks, and + comparing `getrawtransaction` responses between Floresta + and Bitcoin Core. + """ + self.log = setup_logging + self.node_manager = node_manager + + self.florestad = node_manager.add_node_default_args(variant=NodeType.FLORESTAD) + config_dir = os.path.join(self.florestad.daemon.data_dir, "config.toml") + with open(config_dir, "w", encoding="utf-8") as f: + f.write(WALLET_CONFIG) + self.florestad.set_extra_args([f"--config-file={config_dir}"]) + + node_manager.run_node(self.florestad) + + self.log.info("Test getrawtransaction with a non existing txid") + + with pytest.raises(HTTPError): + self.florestad.rpc.get_raw_transaction("nonexistingtxid") + + with pytest.raises(HTTPError): + self.florestad.rpc.get_raw_transaction("nonexistingtxid", verbose=0) + + with pytest.raises(HTTPError): + self.florestad.rpc.get_raw_transaction("nonexistingtxid", verbose=1) + + self.log.info( + "Test getrawtransaction with a non existing txid and " + "invalid verbose level" + ) + with pytest.raises(HTTPError): + self.florestad.rpc.get_raw_transaction("nonexistingtxid", verbose=2) + + self.log.info("Creating and funding transactions in Bitcoin Core") + self.bitcoind = add_node_with_extra_args( + variant=NodeType.BITCOIND, + extra_args=["-txindex", "-fallbackfee=0.00000001"], + ) + self.bitcoind.rpc.create_wallet("testwallet") + + self.bitcoind.rpc.generate_block_to_wallet(MINED_BLOCKS) + + txids = [] + value = 6.4242521 + for address in [ + ADDRESS_BECH32, + ADDRESS_LEGACY, + ADDRESS_BECH32M, + ADDRESS_P2PWH, + ADDRESS_P2PKH, + ADDRESS_TAPROOT, + ]: + txid = self.bitcoind.rpc.send_to_address(address, value) + self.log.info( + f"Sent transaction to {address} and value {value} " f"with txid: {txid}" + ) + txids.append(txid) + + txids.append(self.bitcoind.rpc.send_to_address(ADDRESS_BECH32, 5.5)) + self.log.info(f"Sent transaction with txid: {txid}") + + self.bitcoind.rpc.generate_block_to_address(COINBASE_BLOCKS, ADDRESS_COINBASE) + bestblockhash = self.bitcoind.rpc.get_bestblockhash() + block = self.bitcoind.rpc.get_block(bestblockhash) + txids.append(block["tx"][0]) # Get the coinbase transaction txid + + self.utreexod = utreexod_node + + self.node_manager.connect_nodes(self.bitcoind, self.utreexod) + time.sleep(5) + + self.node_manager.connect_nodes(self.florestad, self.utreexod) + time.sleep(5) + + self.node_manager.connect_nodes(self.florestad, self.bitcoind) + + self.log.info("Waiting for Florestad to sync with Bitcoin Core") + start = time.time() + blocks = self.bitcoind.rpc.get_block_count() + while time.time() - start < 30: + block_chain_info = self.florestad.rpc.get_blockchain_info() + if block_chain_info["height"] == blocks and not block_chain_info["ibd"]: + break + + time.sleep(1) + + elapsed_time = time.time() - start + self.log.info(f"=== time for nodes to sync: {elapsed_time:.2f} seconds ===") + assert self.florestad.rpc.get_block_count() == blocks + for txid in txids: + verbose = 2 + self.log.info( + f"Testing getrawtransaction for txid: {txid} " + f"with invalid verbose level {verbose}" + ) + with pytest.raises(HTTPError): + self.florestad.rpc.get_raw_transaction(txid, verbose=verbose) + + self.compare_getrawtransaction(txid) + + block_range = ( + f"Testing getrawtransaction for coinbase transactions " + f"in the block range {blocks - COINBASE_BLOCKS} to {blocks}" + ) + self.log.info(block_range) + for height in range(blocks - (COINBASE_BLOCKS - 1), blocks): + block_hash = self.bitcoind.rpc.get_blockhash(height) + + block = self.bitcoind.rpc.get_block(block_hash, verbosity=1) + coinbase_tx = block["tx"][0] # Get the coinbase transaction txid + + self.compare_getrawtransaction(coinbase_tx) + + self.compare_getrawtransaction(coinbase_tx) + + def compare_getrawtransaction(self, txid): + """Compare getrawtransaction output between Floresta and Bitcoin Core.""" + self.log.info( + f"Comparing getrawtransaction for txid: {txid} " f"with verbose level 0" + ) + get_raw_tx = self.florestad.rpc.get_raw_transaction(txid, verbose=0) + get_raw_tx_bitcoind = self.bitcoind.rpc.get_raw_transaction(txid, verbose=0) + assert get_raw_tx == get_raw_tx_bitcoind + + self.log.info( + f"Comparing getrawtransaction for txid: {txid} " f"with verbose level 1" + ) + get_raw_tx = self.florestad.rpc.get_raw_transaction(txid, verbose=1) + get_raw_tx_bitcoind = self.bitcoind.rpc.get_raw_transaction(txid, verbose=1) + + self.compare_transaction_data(get_raw_tx, get_raw_tx_bitcoind) + + def compare_transaction_data(self, tx_floresta, tx_bitcoind): + """Compare transaction data between Floresta and Bitcoin Core.""" + for key in tx_bitcoind.keys(): + self.log.info(f"Comparing key: {key}") + + if key == "vin": + self.compare_inputs(tx_floresta[key], tx_bitcoind[key]) + elif key == "vout": + self.compare_outputs(tx_floresta[key], tx_bitcoind[key]) + else: + assert tx_floresta[key] == tx_bitcoind[key] + + def compare_inputs(self, vin_floresta, vin_bitcoind): + """Compare transaction inputs (vin) field by field.""" + for i, vin in enumerate(vin_bitcoind): + self.log.info(f"Comparing vin index: {i}") + + for vin_key in vin.keys(): + self.log.info(f"Comparing vin key: {vin_key}") + if vin_key == "scriptSig": + self.compare_script_sig(vin_floresta[i][vin_key], vin[vin_key]) + else: + assert vin_floresta[i][vin_key] == vin[vin_key] + + def compare_script_sig(self, script_sig_floresta, script_sig_bitcoind): + """Compare scriptSig fields.""" + for script_key in script_sig_bitcoind.keys(): + self.log.info(f"Comparing scriptSig key: {script_key}") + assert script_sig_floresta[script_key] == script_sig_bitcoind[script_key] + + def compare_outputs(self, vout_floresta, vout_bitcoind): + """Compare transaction outputs (vout) field by field.""" + for i, vout in enumerate(vout_bitcoind): + self.log.info(f"Comparing vout index: {i}") + + for vout_key in vout.keys(): + self.log.info(f"Comparing vout key: {vout_key}") + + if vout_key == "scriptPubKey": + self.compare_script_pubkey( + vout_floresta[i][vout_key], vout[vout_key] + ) + else: + assert vout_floresta[i][vout_key] == vout[vout_key] + + def compare_script_pubkey(self, spk_floresta, spk_bitcoind): + """Skip Bitcoin Core specific fields like 'desc'.""" + for spk_key in spk_bitcoind.keys(): + self.log.info(f"Comparing scriptPubKey key: {spk_key}") + + # Skip fields that only Bitcoin Core returns + # (It's not implemented in Floresta yet) + if spk_key == "desc": + continue + + assert spk_floresta[spk_key] == spk_bitcoind[spk_key] diff --git a/tests/test_framework/rpc/base.py b/tests/test_framework/rpc/base.py index 4be48cdab..c39281596 100644 --- a/tests/test_framework/rpc/base.py +++ b/tests/test_framework/rpc/base.py @@ -39,7 +39,7 @@ class BaseRPC(ABC): Subclasses should use `perform_request` to implement RPC calls. """ - TIMEOUT: int = 15 # seconds + TIMEOUT: int = 30 # seconds def __init__(self, config: ConfigRPC, log): self._config = config @@ -150,6 +150,7 @@ def perform_request( # If response isnt 200, raise an HTTPError if response.status_code != 200: + self.log.error(f"HTTP error {response.status_code}: {response.text}") raise HTTPError result = response.json() @@ -344,3 +345,13 @@ def list_descriptors(self): List all loaded descriptors """ return self.perform_request("listdescriptors") + + def get_raw_transaction(self, txid: str, verbose: int | None = None): + """ + Returns the raw transaction data for a given transaction ID. + """ + params = [txid] + if verbose is not None: + params.append(int(verbose)) + + return self.perform_request("getrawtransaction", params=params) diff --git a/tests/test_framework/rpc/bitcoin.py b/tests/test_framework/rpc/bitcoin.py index 085b3061c..fd7de7569 100644 --- a/tests/test_framework/rpc/bitcoin.py +++ b/tests/test_framework/rpc/bitcoin.py @@ -46,3 +46,28 @@ def generate_block(self, nblocks: int) -> list: """ address = "bcrt1q3ml87jemlfvk7lq8gfs7pthvj5678ndnxnw9ch" return self.generate_block_to_address(nblocks, address) + + def get_new_address(self) -> str: + """ + Get a new address from the wallet. + """ + return self.perform_request("getnewaddress", params=[]) + + def generate_block_to_wallet(self, nblocks: int) -> list: + """ + Mine blocks immediately, with coinbase reward sent to a new wallet address + """ + address = self.get_new_address() + return self.generate_block_to_address(nblocks, address) + + def create_wallet(self, wallet_name: str): + """ + Create a new wallet with the given name. + """ + return self.perform_request("createwallet", params=[wallet_name]) + + def send_to_address(self, address: str, amount: float) -> str: + """ + Send a specified amount to a given address. + """ + return self.perform_request("sendtoaddress", params=[address, amount])