feat(rpc): add getrawtransaction (#3908)

* feat(rpc): add getrawtransaction

* Apply suggestions from code review

Co-authored-by: teor <teor@riseup.net>
Co-authored-by: Alfredo Garcia <oxarbitrage@gmail.com>

* Apply suggestions from code review

Co-authored-by: Alfredo Garcia <oxarbitrage@gmail.com>

* address review comments

* move SerializedTransaction to the right module

* Use try_into() instead of 'as'

* add proptests

Co-authored-by: teor <teor@riseup.net>
Co-authored-by: Alfredo Garcia <oxarbitrage@gmail.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
Conrado Gouvea 2022-03-24 06:45:37 -03:00 committed by GitHub
parent 5d7f986183
commit e7c0a78d4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 425 additions and 19 deletions

1
Cargo.lock generated
View File

@ -5754,6 +5754,7 @@ dependencies = [
"jsonrpc-http-server",
"proptest",
"serde",
"serde_json",
"thiserror",
"tokio",
"tower",

View File

@ -25,6 +25,7 @@ pub use joinsplit::JoinSplitData;
pub use lock_time::LockTime;
pub use memo::Memo;
pub use sapling::FieldNotPresent;
pub use serialize::SerializedTransaction;
pub use sighash::{HashType, SigHash};
pub use unmined::{UnminedTx, UnminedTxId, VerifiedUnminedTx};

View File

@ -1,7 +1,7 @@
//! Contains impls of `ZcashSerialize`, `ZcashDeserialize` for all of the
//! transaction types, so that all of the serialization logic is in one place.
use std::{convert::TryInto, io, sync::Arc};
use std::{borrow::Borrow, convert::TryInto, io, sync::Arc};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use halo2::{arithmetic::FieldExt, pasta::pallas};
@ -977,3 +977,36 @@ impl TrustedPreallocate for transparent::Output {
MAX_BLOCK_BYTES / MIN_TRANSPARENT_OUTPUT_SIZE
}
}
/// A serialized transaction.
///
/// Stores bytes that are guaranteed to be deserializable into a [`Transaction`].
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct SerializedTransaction {
bytes: Vec<u8>,
}
/// Build a [`SerializedTransaction`] by serializing a block.
impl<B: Borrow<Transaction>> From<B> for SerializedTransaction {
fn from(tx: B) -> Self {
SerializedTransaction {
bytes: tx
.borrow()
.zcash_serialize_to_vec()
.expect("Writing to a `Vec` should never fail"),
}
}
}
/// Access the serialized bytes of a [`SerializedTransaction`].
impl AsRef<[u8]> for SerializedTransaction {
fn as_ref(&self) -> &[u8] {
self.bytes.as_ref()
}
}
impl From<Vec<u8>> for SerializedTransaction {
fn from(bytes: Vec<u8>) -> Self {
Self { bytes }
}
}

View File

@ -33,6 +33,7 @@ serde = { version = "1.0.136", features = ["serde_derive"] }
[dev-dependencies]
proptest = "0.10.1"
serde_json = "1.0.79"
thiserror = "1.0.30"
tokio = { version = "1.16.1", features = ["full", "test-util"] }

View File

@ -7,3 +7,5 @@
pub mod config;
pub mod methods;
pub mod server;
#[cfg(test)]
mod tests;

View File

@ -6,6 +6,8 @@
//! Some parts of the `zcashd` RPC documentation are outdated.
//! So this implementation follows the `lightwalletd` client implementation.
use std::{collections::HashSet, io, sync::Arc};
use futures::{FutureExt, TryFutureExt};
use hex::{FromHex, ToHex};
use jsonrpc_core::{self, BoxFuture, Error, ErrorCode, Result};
@ -17,7 +19,7 @@ use zebra_chain::{
chain_tip::ChainTip,
parameters::Network,
serialization::{SerializationError, ZcashDeserialize},
transaction::{self, Transaction},
transaction::{self, SerializedTransaction, Transaction},
};
use zebra_network::constants::USER_AGENT;
use zebra_node_services::{mempool, BoxError};
@ -103,6 +105,30 @@ pub trait Rpc {
/// zcashd reference: [`getrawmempool`](https://zcash.github.io/rpc/getrawmempool.html)
#[rpc(name = "getrawmempool")]
fn get_raw_mempool(&self) -> BoxFuture<Result<Vec<String>>>;
/// Returns the raw transaction data, as a [`GetRawTransaction`] JSON string or structure.
///
/// zcashd reference: [`getrawtransaction`](https://zcash.github.io/rpc/getrawtransaction.html)
///
/// # Parameters
///
/// - `txid`: (string, required) The transaction ID of the transaction to be returned.
/// - `verbose`: (numeric, optional, default=0) If 0, return a string of hex-encoded data, otherwise return a JSON object.
///
/// # Notes
///
/// We don't currently support the `blockhash` parameter since lightwalletd does not
/// use it.
///
/// In verbose mode, we only expose the `hex` and `height` fields since
/// lightwalletd uses only those:
/// <https://github.com/zcash/lightwalletd/blob/631bb16404e3d8b045e74a7c5489db626790b2f6/common/common.go#L119>
#[rpc(name = "getrawtransaction")]
fn get_raw_transaction(
&self,
txid_hex: String,
verbose: u8,
) -> BoxFuture<Result<GetRawTransaction>>;
}
/// RPC method implementations.
@ -321,6 +347,89 @@ where
}
.boxed()
}
fn get_raw_transaction(
&self,
txid_hex: String,
verbose: u8,
) -> BoxFuture<Result<GetRawTransaction>> {
let mut state = self.state.clone();
let mut mempool = self.mempool.clone();
async move {
let txid = transaction::Hash::from_hex(txid_hex).map_err(|_| {
Error::invalid_params("transaction ID is not specified as a hex string")
})?;
// Check the mempool first.
//
// # Correctness
//
// Transactions are removed from the mempool after they are mined into blocks,
// so the transaction could be just in the mempool, just in the state, or in both.
// (And the mempool and state transactions could have different authorising data.)
// But it doesn't matter which transaction we choose, because the effects are the same.
let mut txid_set = HashSet::new();
txid_set.insert(txid);
let request = mempool::Request::TransactionsByMinedId(txid_set);
let response = mempool
.ready()
.and_then(|service| service.call(request))
.await
.map_err(|error| Error {
code: ErrorCode::ServerError(0),
message: error.to_string(),
data: None,
})?;
match response {
mempool::Response::Transactions(unmined_transactions) => {
if !unmined_transactions.is_empty() {
let tx = unmined_transactions[0].transaction.clone();
return GetRawTransaction::from_transaction(tx, None, verbose != 0)
.map_err(|error| Error {
code: ErrorCode::ServerError(0),
message: error.to_string(),
data: None,
});
}
}
_ => unreachable!("unmatched response to a transactionids request"),
};
// Now check the state
let request = zebra_state::ReadRequest::Transaction(txid);
let response = state
.ready()
.and_then(|service| service.call(request))
.await
.map_err(|error| Error {
code: ErrorCode::ServerError(0),
message: error.to_string(),
data: None,
})?;
match response {
zebra_state::ReadResponse::Transaction(Some((tx, height))) => Ok(
GetRawTransaction::from_transaction(tx, Some(height), verbose != 0).map_err(
|error| Error {
code: ErrorCode::ServerError(0),
message: error.to_string(),
data: None,
},
)?,
),
zebra_state::ReadResponse::Transaction(None) => Err(Error {
code: ErrorCode::ServerError(0),
message: "Transaction not found".to_string(),
data: None,
}),
_ => unreachable!("unmatched response to a transaction request"),
}
}
.boxed()
}
}
#[derive(serde::Serialize, serde::Deserialize)]
@ -362,3 +471,45 @@ pub struct GetBlock(#[serde(with = "hex")] SerializedBlock);
///
/// Also see the notes for the [`Rpc::get_best_block_hash` method].
pub struct GetBestBlockHash(#[serde(with = "hex")] block::Hash);
/// Response to a `getrawtransaction` RPC request.
///
/// See the notes for the [`Rpc::get_raw_transaction` method].
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(untagged)]
pub enum GetRawTransaction {
/// The raw transaction, encoded as hex bytes.
Raw(#[serde(with = "hex")] SerializedTransaction),
/// The transaction object.
Object {
/// The raw transaction, encoded as hex bytes.
#[serde(with = "hex")]
hex: SerializedTransaction,
/// The height of the block that contains the transaction, or -1 if
/// not applicable.
height: i32,
},
}
impl GetRawTransaction {
fn from_transaction(
tx: Arc<Transaction>,
height: Option<block::Height>,
verbose: bool,
) -> std::result::Result<Self, io::Error> {
if verbose {
Ok(GetRawTransaction::Object {
hex: tx.into(),
height: match height {
Some(height) => height
.0
.try_into()
.expect("valid block heights are limited to i32::MAX"),
None => -1,
},
})
} else {
Ok(GetRawTransaction::Raw(tx.into()))
}
}
}

View File

@ -12,7 +12,7 @@ use zebra_chain::{
chain_tip::NoChainTip,
parameters::Network::*,
serialization::{ZcashDeserialize, ZcashSerialize},
transaction::{Transaction, UnminedTx, UnminedTxId},
transaction::{self, Transaction, UnminedTx, UnminedTxId},
};
use zebra_node_services::mempool;
use zebra_state::BoxError;
@ -322,6 +322,104 @@ proptest! {
Ok::<_, TestCaseError>(())
})?;
}
/// Test that the method rejects non-hexadecimal characters.
///
/// Try to call `get_raw_transaction` using a string parameter that has at least one
/// non-hexadecimal character, and check that it fails with an expected error.
#[test]
fn get_raw_transaction_non_hexadecimal_string_results_in_an_error(non_hex_string in ".*[^0-9A-Fa-f].*") {
let runtime = zebra_test::init_async();
let _guard = runtime.enter();
// CORRECTNESS: Nothing in this test depends on real time, so we can speed it up.
tokio::time::pause();
runtime.block_on(async move {
let mut mempool = MockService::build().for_prop_tests();
let mut state: MockService<_, _, _, BoxError> = MockService::build().for_prop_tests();
let rpc = RpcImpl::new(
"RPC test",
Buffer::new(mempool.clone(), 1),
Buffer::new(state.clone(), 1),
NoChainTip,
Mainnet,
);
let send_task = tokio::spawn(rpc.get_raw_transaction(non_hex_string, 0));
mempool.expect_no_requests().await?;
state.expect_no_requests().await?;
let result = send_task
.await
.expect("Sending raw transactions should not panic");
prop_assert!(
matches!(
result,
Err(Error {
code: ErrorCode::InvalidParams,
..
})
),
"Result is not an invalid parameters error: {result:?}"
);
Ok::<_, TestCaseError>(())
})?;
}
/// Test that the method rejects an input that's not a transaction.
///
/// Try to call `get_raw_transaction` using random bytes that fail to deserialize as a
/// transaction, and check that it fails with an expected error.
#[test]
fn get_raw_transaction_invalid_transaction_results_in_an_error(random_bytes in any::<Vec<u8>>()) {
let runtime = zebra_test::init_async();
let _guard = runtime.enter();
// CORRECTNESS: Nothing in this test depends on real time, so we can speed it up.
tokio::time::pause();
prop_assume!(transaction::Hash::zcash_deserialize(&*random_bytes).is_err());
runtime.block_on(async move {
let mut mempool = MockService::build().for_prop_tests();
let mut state: MockService<_, _, _, BoxError> = MockService::build().for_prop_tests();
let rpc = RpcImpl::new(
"RPC test",
Buffer::new(mempool.clone(), 1),
Buffer::new(state.clone(), 1),
NoChainTip,
Mainnet,
);
let send_task = tokio::spawn(rpc.get_raw_transaction(hex::encode(random_bytes), 0));
mempool.expect_no_requests().await?;
state.expect_no_requests().await?;
let result = send_task
.await
.expect("Sending raw transactions should not panic");
prop_assert!(
matches!(
result,
Err(Error {
code: ErrorCode::InvalidParams,
..
})
),
"Result is not an invalid parameters error: {result:?}"
);
Ok::<_, TestCaseError>(())
})?;
}
}
#[derive(Clone, Copy, Debug, Error)]

View File

@ -5,8 +5,11 @@ use std::sync::Arc;
use tower::buffer::Buffer;
use zebra_chain::{
block::Block, chain_tip::NoChainTip, parameters::Network::*,
serialization::ZcashDeserializeInto,
block::Block,
chain_tip::NoChainTip,
parameters::Network::*,
serialization::{ZcashDeserializeInto, ZcashSerialize},
transaction::{UnminedTx, UnminedTxId},
};
use zebra_network::constants::USER_AGENT;
use zebra_node_services::BoxError;
@ -148,3 +151,84 @@ async fn rpc_getbestblockhash() {
mempool.expect_no_requests().await;
}
#[tokio::test]
async fn rpc_getrawtransaction() {
zebra_test::init();
// Create a continuous chain of mainnet blocks from genesis
let blocks: Vec<Arc<Block>> = zebra_test::vectors::CONTINUOUS_MAINNET_BLOCKS
.iter()
.map(|(_height, block_bytes)| block_bytes.zcash_deserialize_into().unwrap())
.collect();
let mut mempool: MockService<_, _, _, BoxError> = MockService::build().for_unit_tests();
// Create a populated state service
let (_state, read_state, latest_chain_tip, _chain_tip_change) =
zebra_state::populated_state(blocks.clone(), Mainnet).await;
// Init RPC
let rpc = RpcImpl::new(
"RPC test",
Buffer::new(mempool.clone(), 1),
read_state,
latest_chain_tip,
Mainnet,
);
// Test case where transaction is in mempool.
// Skip genesis because its tx is not indexed.
for block in blocks.iter().skip(1) {
for tx in block.transactions.iter() {
let mempool_req = mempool
.expect_request_that(|request| {
if let mempool::Request::TransactionsByMinedId(ids) = request {
ids.len() == 1 && ids.contains(&tx.hash())
} else {
false
}
})
.map(|responder| {
responder.respond(mempool::Response::Transactions(vec![UnminedTx {
id: UnminedTxId::Legacy(tx.hash()),
transaction: tx.clone(),
size: 0,
}]));
});
let get_tx_req = rpc.get_raw_transaction(tx.hash().encode_hex(), 0u8);
let (response, _) = futures::join!(get_tx_req, mempool_req);
let get_tx = response.expect("We should have a GetRawTransaction struct");
if let GetRawTransaction::Raw(raw_tx) = get_tx {
assert_eq!(raw_tx.as_ref(), tx.zcash_serialize_to_vec().unwrap());
} else {
unreachable!("Should return a Raw enum")
}
}
}
// Test case where transaction is _not_ in mempool.
// Skip genesis because its tx is not indexed.
for block in blocks.iter().skip(1) {
for tx in block.transactions.iter() {
let mempool_req = mempool
.expect_request_that(|request| {
if let mempool::Request::TransactionsByMinedId(ids) = request {
ids.len() == 1 && ids.contains(&tx.hash())
} else {
false
}
})
.map(|responder| {
responder.respond(mempool::Response::Transactions(vec![]));
});
let get_tx_req = rpc.get_raw_transaction(tx.hash().encode_hex(), 0u8);
let (response, _) = futures::join!(get_tx_req, mempool_req);
let get_tx = response.expect("We should have a GetRawTransaction struct");
if let GetRawTransaction::Raw(raw_tx) = get_tx {
assert_eq!(raw_tx.as_ref(), tx.zcash_serialize_to_vec().unwrap());
} else {
unreachable!("Should return a Raw enum")
}
}
}
}

1
zebra-rpc/src/tests.rs Normal file
View File

@ -0,0 +1 @@
mod vectors;

View File

@ -0,0 +1,19 @@
use crate::methods::GetRawTransaction;
#[test]
pub fn test_transaction_serialization() {
let expected_tx = GetRawTransaction::Raw(vec![0x42].into());
let expected_json = r#""42""#;
let j = serde_json::to_string(&expected_tx).unwrap();
assert_eq!(j, expected_json);
let expected_tx = GetRawTransaction::Object {
hex: vec![0x42].into(),
height: 1,
};
let expected_json = r#"{"hex":"42","height":1}"#;
let j = serde_json::to_string(&expected_tx).unwrap();
assert_eq!(j, expected_json);
}

View File

@ -52,5 +52,5 @@ pub enum ReadResponse {
Block(Option<Arc<Block>>),
/// Response to [`ReadRequest::Transaction`] with the specified transaction.
Transaction(Option<Arc<Transaction>>),
Transaction(Option<(Arc<Transaction>, block::Height)>),
}

View File

@ -467,7 +467,7 @@ impl StateService {
/// Returns the [`Transaction`] with [`transaction::Hash`],
/// if it exists in the current best chain.
pub fn best_transaction(&self, hash: transaction::Hash) -> Option<Arc<Transaction>> {
read::transaction(self.mem.best_chain(), self.disk.db(), hash)
read::transaction(self.mem.best_chain(), self.disk.db(), hash).map(|(tx, _height)| tx)
}
/// Return the hash for the block at `height` in the current best chain.
@ -957,11 +957,12 @@ impl Service<ReadRequest> for ReadStateService {
let state = self.clone();
async move {
let transaction = state.best_chain_receiver.with_watch_data(|best_chain| {
read::transaction(best_chain, &state.db, hash)
});
let transaction_and_height =
state.best_chain_receiver.with_watch_data(|best_chain| {
read::transaction(best_chain, &state.db, hash)
});
Ok(ReadResponse::Transaction(transaction))
Ok(ReadResponse::Transaction(transaction_and_height))
}
.boxed()
}

View File

@ -111,7 +111,10 @@ impl ZebraDb {
/// Returns the [`Transaction`] with [`transaction::Hash`],
/// if it exists in the finalized chain.
pub fn transaction(&self, hash: transaction::Hash) -> Option<Arc<Transaction>> {
pub fn transaction(
&self,
hash: transaction::Hash,
) -> Option<(Arc<Transaction>, block::Height)> {
self.transaction_location(hash)
.map(|TransactionLocation { index, height }| {
let block = self
@ -119,7 +122,7 @@ impl ZebraDb {
.expect("block will exist if TransactionLocation does");
// TODO: store transactions in a separate database index (#3151)
block.transactions[index.as_usize()].clone()
(block.transactions[index.as_usize()].clone(), height)
})
}

View File

@ -330,10 +330,13 @@ impl Chain {
}
/// Returns the [`Transaction`] with [`transaction::Hash`], if it exists in this chain.
pub fn transaction(&self, hash: transaction::Hash) -> Option<&Arc<Transaction>> {
pub fn transaction(
&self,
hash: transaction::Hash,
) -> Option<(&Arc<Transaction>, block::Height)> {
self.tx_by_hash
.get(&hash)
.map(|(height, index)| &self.blocks[height].block.transactions[*index])
.map(|(height, index)| (&self.blocks[height].block.transactions[*index], *height))
}
/// Returns the block hash of the tip block.

View File

@ -7,7 +7,7 @@
use std::sync::Arc;
use zebra_chain::{
block::Block,
block::{self, Block},
transaction::{self, Transaction},
};
@ -51,7 +51,7 @@ pub(crate) fn transaction<C>(
chain: Option<C>,
db: &ZebraDb,
hash: transaction::Hash,
) -> Option<Arc<Transaction>>
) -> Option<(Arc<Transaction>, block::Height)>
where
C: AsRef<Chain>,
{
@ -65,6 +65,11 @@ where
// (`chain` is always in memory, but `db` stores transactions on disk, with a memory cache.)
chain
.as_ref()
.and_then(|chain| chain.as_ref().transaction(hash).cloned())
.and_then(|chain| {
chain
.as_ref()
.transaction(hash)
.map(|(tx, height)| (tx.clone(), height))
})
.or_else(|| db.transaction(hash))
}

View File

@ -68,7 +68,10 @@ async fn populated_read_state_responds_correctly() -> Result<()> {
for transaction in &block.transactions {
let transaction_cases = vec![(
ReadRequest::Transaction(transaction.hash()),
Ok(ReadResponse::Transaction(Some(transaction.clone()))),
Ok(ReadResponse::Transaction(Some((
transaction.clone(),
block.coinbase_height().unwrap(),
)))),
)];
let transaction_cases = Transcript::from(transaction_cases);