diff --git a/zebra-chain/src/block/height.rs b/zebra-chain/src/block/height.rs index 71f664c75a9..3504c252fc3 100644 --- a/zebra-chain/src/block/height.rs +++ b/zebra-chain/src/block/height.rs @@ -77,6 +77,10 @@ impl Height { /// height and above. pub const MAX_EXPIRY_HEIGHT: Height = Height(499_999_999); + /// The number of blocks within expiry height when a tx is considered + /// to be expiring soon . + pub const BLOCK_EXPIRY_HEIGHT_THRESHOLD: u32 = 3; + /// Returns the next [`Height`]. /// /// # Panics diff --git a/zebra-chain/src/transparent.rs b/zebra-chain/src/transparent.rs index 55f45beb92d..18141880c8c 100644 --- a/zebra-chain/src/transparent.rs +++ b/zebra-chain/src/transparent.rs @@ -12,7 +12,8 @@ use crate::{ mod address; mod keys; -mod opcodes; + +pub mod opcodes; mod script; mod serialize; mod utxo; diff --git a/zebra-chain/src/transparent/opcodes.rs b/zebra-chain/src/transparent/opcodes.rs index aa6366e2628..f79cbd391af 100644 --- a/zebra-chain/src/transparent/opcodes.rs +++ b/zebra-chain/src/transparent/opcodes.rs @@ -3,6 +3,8 @@ /// Supported opcodes /// /// + +#[allow(missing_docs)] pub enum OpCode { // Opcodes used to generate P2SH scripts. Equal = 0x87, diff --git a/zebra-rpc/src/methods.rs b/zebra-rpc/src/methods.rs index 736021c7668..a1b085d34ca 100644 --- a/zebra-rpc/src/methods.rs +++ b/zebra-rpc/src/methods.rs @@ -6,7 +6,12 @@ //! Some parts of the `zcashd` RPC documentation are outdated. //! So this implementation follows the `zcashd` server and `lightwalletd` client implementations. -use std::{collections::HashSet, fmt::Debug, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + fmt::Debug, + hash::Hash, + sync::Arc, +}; use chrono::Utc; use futures::{stream::FuturesOrdered, FutureExt, StreamExt, TryFutureExt}; @@ -20,14 +25,16 @@ use tracing::Instrument; use zcash_primitives::consensus::Parameters; use zebra_chain::{ + amount::{Amount, NonNegative, COIN}, block::{self, Height, SerializedBlock}, chain_tip::{ChainTip, NetworkChainTipHeightEstimator}, parameters::{ConsensusBranchId, Network, NetworkUpgrade}, - serialization::ZcashDeserialize, + serialization::{ZcashDeserialize, ZcashSerialize}, subtree::NoteCommitmentSubtreeIndex, - transaction::{self, SerializedTransaction, Transaction, UnminedTx}, - transparent::{self, Address}, + transaction::{self, LockTime, SerializedTransaction, Transaction, UnminedTx}, + transparent::{self, opcodes::OpCode, Address, Input, OutPoint, Output, Script}, }; + use zebra_node_services::mempool; use zebra_state::{HashOrHeight, MinedTx, OutputIndex, OutputLocation, TransactionLocation}; @@ -235,6 +242,35 @@ pub trait Rpc { limit: Option, ) -> BoxFuture>; + /// Returns the hex string of the raw transaction based on the given inputs `transactions`, `addresses`, + /// `locktime` and `expiryheight`. + /// + /// zcashd reference: [`z_create_raw_transaction`](https://zcash.github.io/rpc/createrawtransaction.html) + /// method: post + /// tags: blockchain + /// + /// # Parameters + /// + /// - `transactions`: (string, required) A json array of json objects + /// - `addresses`: (string, required) A json object object with addresses as keys and amounts as values. + /// ["address": x.xxx] (numeric, required) They key is the Zcash address, the value is the ZEC amount + /// - `locktime`: (numeric, optional, default=0) Raw locktime. Non-0 value also locktime-actives inputs + /// - `expiryheight`: (numeric, optional, default=nextblockheight+20 (pre-Blossom) or nextblockheight+40 (post-Blossom)) Expiry height of + /// transaction (if Overwinter is active) + /// + /// # Notes + /// The transaction's inputs are not signed, and it is not stored in the wallet or transmitted + /// to the network. + /// Transaction IDs for inputs are in hex format + #[rpc(name = "createrawtransaction")] + fn create_raw_transaction( + &self, + transactions: Vec, + addresses: HashMap, + locktime: Option, + expiryheight: Option, + ) -> BoxFuture>; + /// Returns the raw transaction data, as a [`GetRawTransaction`] JSON string or structure. /// /// zcashd reference: [`getrawtransaction`](https://zcash.github.io/rpc/getrawtransaction.html) @@ -1005,6 +1041,167 @@ where .boxed() } + fn create_raw_transaction( + &self, + transactions: Vec, + addresses: HashMap, + locktime: Option, + expiryheight: Option, + ) -> BoxFuture> { + let network = self.network.clone(); + let latest_chain_tip = self.latest_chain_tip.clone(); + + async move { + let tip_height = best_chain_tip_height(&latest_chain_tip).map_server_error()?; + + let lock_time = if let Some(lock_time) = locktime { + LockTime::Height(block::Height(lock_time)) + } else { + LockTime::Height(block::Height(0)) + }; + + // Use next block height if exceeds MAX_EXPIRY_HEIGHT or is beyond tip + let next_block_height = tip_height.0 + 1; + let current_upgrade = NetworkUpgrade::current(&network, tip_height); + let next_network_upgrade = NetworkUpgrade::current(&network, Height(next_block_height)); + + let expiry_height_as_u32 = if let Some(expiry_height) = expiryheight { + if next_network_upgrade < NetworkUpgrade::Overwinter { + return Err(Error::invalid_params( + "invalid parameter, expiryheight can only be used if Overwinter is active when the transaction is mined" + )).map_server_error(); + } + + if block::Height(expiry_height) >= block::Height::MAX_EXPIRY_HEIGHT { + return Err(Error::invalid_params( + format!("Invalid parameter, expiryheight must be nonnegative and less than {:?}.", + block::Height::MAX_EXPIRY_HEIGHT) + )); + } + + // DoS mitigation: reject transactions expiring soon (as is done in zcashd) + if expiry_height != 0 && next_block_height + block::Height::BLOCK_EXPIRY_HEIGHT_THRESHOLD > expiry_height { + return Err(Error::invalid_params( + format!("invalid parameter, expiryheight should be at least {} to avoid transaction expiring soon", + next_block_height + block::Height::BLOCK_EXPIRY_HEIGHT_THRESHOLD) + )); + } + + expiry_height + } else { + next_block_height + }; + // Set a default sequence based on the lock time + let default_tx_input_sequence = if lock_time == LockTime::Height(block::Height(0)) { + u32::MAX + } else { + u32::MAX - 1 + }; + + // Handle tx inputs + let tx_inputs: Vec = transactions + .iter() + .map(|input| { + Ok(Input::PrevOut { + outpoint: OutPoint { + hash: transaction::Hash::from_hex(&input.txid).map_err(|_| { + Error::invalid_params(format!("invalid parameter, transaction id {} is an invalid hex string", input.txid)) + })?, + index: input.vout, + }, + unlock_script: Script::new(&[]), + sequence: input.sequence.unwrap_or(default_tx_input_sequence), + }) + }) + .collect::>>()?; + + // Handle tx outputs + let mut tx_outputs: Vec = Vec::new(); + + // Check if addresses contained in params are valid + let address_strings = AddressStrings { addresses: addresses.clone().keys().cloned().collect() }; + let _result = address_strings.valid_addresses().map_err(|e| e.to_string()).map_server_error()?; + + for (address, amount) in addresses { + let address = address.parse().map_server_error()?; + + let lock_script = match address { + Address::PayToScriptHash { network_kind: _, script_hash } => { + let mut script_bytes = vec![]; + script_bytes.push(OpCode::Hash160 as u8); + script_bytes.extend_from_slice(&script_hash); + script_bytes.push(OpCode::Equal as u8); + Script::new(&script_bytes) + } + Address::PayToPublicKeyHash { network_kind: _, pub_key_hash } => { + let mut script_bytes = vec![]; + script_bytes.push(OpCode::Dup as u8); + script_bytes.push(OpCode::Hash160 as u8); + script_bytes.extend_from_slice(&pub_key_hash); + script_bytes.push(OpCode::EqualVerify as u8); + script_bytes.push(OpCode::CheckSig as u8); + Script::new(&script_bytes) + } + }; + + let zatoshi_amount = (amount * COIN as f64) as i64; + let value = Amount::::try_from(zatoshi_amount) + .map_err(|e| Error::invalid_params(format!("invalid amount: {}", e)))?; + + let tx_out = Output { + value, + lock_script + }; + tx_outputs.push(tx_out); + } + + let tx = match current_upgrade { + NetworkUpgrade::Genesis | NetworkUpgrade::BeforeOverwinter => Transaction::V1 { + inputs: tx_inputs, + outputs: tx_outputs, + lock_time, + }, + NetworkUpgrade::Overwinter => Transaction::V3 { + inputs: tx_inputs, + outputs: tx_outputs, + lock_time, + expiry_height: block::Height(expiry_height_as_u32 + 20), + joinsplit_data: None, + }, + NetworkUpgrade::Sapling => Transaction::V4 { + inputs: tx_inputs, + outputs: tx_outputs, + lock_time, + expiry_height: block::Height(expiry_height_as_u32 + 20), + joinsplit_data: None, + sapling_shielded_data: None, + }, + NetworkUpgrade::Blossom | NetworkUpgrade::Heartwood | NetworkUpgrade::Canopy => { + Transaction::V4 { + inputs: tx_inputs, + outputs: tx_outputs, + lock_time, + expiry_height: block::Height(expiry_height_as_u32 + 40), + joinsplit_data: None, + sapling_shielded_data: None, + } + } + NetworkUpgrade::Nu5 | NetworkUpgrade::Nu6 => Transaction::V5 { + network_upgrade: current_upgrade, + lock_time, + expiry_height: block::Height(expiry_height_as_u32 + 40), + inputs: tx_inputs, + outputs: tx_outputs, + sapling_shielded_data: None, + orchard_shielded_data: None, + }, + }; + + Ok(hex::encode(tx.zcash_serialize_to_vec().map_server_error()?)) + } + .boxed() + } + // TODO: use HexData or SentTransactionHash to handle the transaction ID fn get_raw_transaction( &self, @@ -1703,6 +1900,15 @@ impl Default for GetBlockHash { } } +/// A struct used for the `transactions` parameter for +/// [`Rpc::create_raw_transaction` method]. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct TxInput { + txid: String, + vout: u32, + sequence: Option, +} + /// Response to a `getrawtransaction` RPC request. /// /// See the notes for the [`Rpc::get_raw_transaction` method]. diff --git a/zebra-rpc/src/methods/tests/vectors.rs b/zebra-rpc/src/methods/tests/vectors.rs index 5b5a21e23d0..eff1861a0b8 100644 --- a/zebra-rpc/src/methods/tests/vectors.rs +++ b/zebra-rpc/src/methods/tests/vectors.rs @@ -423,6 +423,340 @@ async fn rpc_getbestblockhash() { assert!(rpc_tx_queue_task_result.is_none()); } +#[tokio::test(flavor = "multi_thread")] +async fn rpc_createrawtransaction() { + use zebra_chain::block::{Hash, Height}; + + let _init_guard = zebra_test::init(); + + let mut mempool: MockService<_, _, _, BoxError> = MockService::build().for_unit_tests(); + let state: MockService<_, _, _, BoxError> = MockService::build().for_unit_tests(); + + // nu5 block height + let fake_tip_height = NetworkUpgrade::Nu5.activation_height(&Mainnet).unwrap(); + // nu5 block hash + let fake_tip_hash = + Hash::from_hex("0000000000d723156d9b65ffcf4984da7a19675ed7e2f06d9e5d5188af087bf8").unwrap(); + + let (mock_chain_tip, mock_chain_tip_sender) = MockChainTip::new(); + mock_chain_tip_sender.send_best_tip_height(fake_tip_height); + mock_chain_tip_sender.send_best_tip_hash(fake_tip_hash); + mock_chain_tip_sender.send_estimated_distance_to_network_chain_tip(Some(0)); + + let (rpc, rpc_tx_queue_task_handle) = RpcImpl::new( + "RPC test", + "RPC test", + Mainnet, + false, + true, + Buffer::new(mempool.clone(), 1), + Buffer::new(state.clone(), 1), + mock_chain_tip.clone(), + ); + + // Test basic transaction creation + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), 1.0); + addresses + }, + None, + None, + ); + + let response = tx_future.await; + + assert!( + response.is_ok(), + "Basic transaction creation should succeed" + ); + + // Test transaction with invalid input transaction id + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "00000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), 1.0); + addresses + }, + None, + None, + ); + + let response = tx_future.await; + + assert!( + response.is_err(), + "RPC should not return transaction hash with invalid transaction id in input" + ); + assert!(response + .unwrap_err() + .message + .contains("invalid parameter, transaction id")); + + // Test invalid inputs: invalid transparent address in HashMap + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz2".to_string(), 1.0); + addresses + }, + None, + None, + ); + + let response = tx_future.await; + + assert!( + response.is_err(), + "RPC should err if address to value mapping contains invalid address" + ); + assert!(response + .unwrap_err() + .message + .contains("t-addr decoding error")); + + // Test inputs containing a valid locktime + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), 1.0); + addresses + }, + Some(100), + None, + ); + + let response = tx_future.await; + + assert!( + response.is_ok(), + "RPC with valid locktime input should succeed" + ); + + // Test inputs containing a valid expiry_height + + let expiry_height = fake_tip_height.0 + 40; + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), 1.0); + addresses + }, + None, + Some(expiry_height), + ); + + let response = tx_future.await; + + assert!( + response.is_ok(), + "RPC with valid expiry_height should succeed" + ); + + // Test inputs containing an invalid expiry_height: expiry_height is not more than block::Height::BLOCK_EXPIRY_HEIGHT_THRESHOLD + + let expiry_height = fake_tip_height.0 + 1; + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), 1.0); + addresses + }, + None, + Some(expiry_height), + ); + + let response = tx_future.await; + + assert!( + response.is_err(), + "RPC should err if expiry_height is within range of block expiry threshold" + ); + assert!(response + .unwrap_err() + .message + .contains("invalid parameter, expiryheight should be at least")); + + // Test inputs containing an invalid expiry_height: expiry_height greater than block::Height::MAX_EXPIRY_HEIGHT + + let expiry_height = Height::MAX_EXPIRY_HEIGHT.0; + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), 1.0); + addresses + }, + None, + Some(expiry_height), + ); + + let response = tx_future.await; + + assert!( + response.is_err(), + "RPC should err if expiry_height is greater than the MAX_EXPIRY_HEIGHT" + ); + assert!(response + .unwrap_err() + .message + .contains("Invalid parameter, expiryheight must be nonnegative and less than")); + + // Test inputs containing an invalid expiry_height: expiry_height can only be used if NetworkUpgrade::Overwinter is active + { + let (mock_chain_tip, mock_chain_tip_sender) = MockChainTip::new(); + mock_chain_tip_sender.send_best_tip_height(Height( + NetworkUpgrade::BeforeOverwinter + .activation_height(&Mainnet) + .unwrap() + .0, + )); + mock_chain_tip_sender.send_best_tip_hash(fake_tip_hash); + mock_chain_tip_sender.send_estimated_distance_to_network_chain_tip(Some(0)); + + let (rpc, _) = RpcImpl::new( + "RPC test", + "RPC test", + Mainnet, + false, + true, + Buffer::new(mempool.clone(), 1), + Buffer::new(state.clone(), 1), + mock_chain_tip.clone(), + ); + + let expiry_height = fake_tip_height.0 + 40; + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses: HashMap = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), 1.0); + addresses + }, + None, + Some(expiry_height), + ); + + let response = tx_future.await; + + assert!( + response.is_err(), + "RPC should err if NetworkUpgrade::Overwinter is not active and expiryheight is used." + ); + assert!(response + .unwrap_err() + .message + .contains("invalid parameter, expiryheight can only be used if Overwinter is active when the transaction is mined")); + } + + // Test transaction creation with invalid ZEC value: more than 21 million + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses: HashMap = HashMap::new(); + addresses.insert( + "t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), + 21_000_001.0, + ); + addresses + }, + None, + None, + ); + + let response = tx_future.await; + + assert!( + response.is_err(), + "RPC should err if inputs contains an invalid ZEC value" + ); + assert!(response.unwrap_err().message.contains("invalid amount")); + + // Test transaction creation with invalid ZEC value: negative amount + + let tx_future = rpc.create_raw_transaction( + vec![TxInput { + txid: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + vout: 0, + sequence: None, + }], + { + let mut addresses = HashMap::new(); + addresses.insert("t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string(), -4.0); + addresses + }, + None, + None, + ); + + let response = tx_future.await; + + assert!( + response.is_err(), + "RPC should err if inputs contains an invalid ZEC value" + ); + assert!(response.unwrap_err().message.contains("invalid amount")); + + mempool.expect_no_requests().await; + + // The queue task should continue without errors or panics + let rpc_tx_queue_task_result = rpc_tx_queue_task_handle.now_or_never(); + assert!(rpc_tx_queue_task_result.is_none()); +} + #[tokio::test(flavor = "multi_thread")] async fn rpc_getrawtransaction() { let _init_guard = zebra_test::init();