diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index 2d493753e5..7cb96487a5 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -148,6 +148,7 @@ jobs: - tests::nakamoto_integrations::sip029_coinbase_change - tests::nakamoto_integrations::clarity_cost_spend_down - tests::nakamoto_integrations::v3_blockbyheight_api_endpoint + - tests::nakamoto_integrations::v3_transactions_api_endpoint # TODO: enable these once v1 signer is supported by a new nakamoto epoch # - tests::signer::v1::dkg # - tests::signer::v1::sign_request_rejected diff --git a/CHANGELOG.md b/CHANGELOG.md index f85ed6526b..98d74858b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE ## [Unreleased] ### Added +- Add `/v3/transactions/:txid` rpc endpoint ### Changed @@ -38,6 +39,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE ### Changed - Use the same burn view loader in both block validation and block processing +>>>>>>> develop ## [3.0.0.0.3] diff --git a/docs/rpc-endpoints.md b/docs/rpc-endpoints.md index e029a8b113..435c2d29fe 100644 --- a/docs/rpc-endpoints.md +++ b/docs/rpc-endpoints.md @@ -578,3 +578,24 @@ tenure, `tip_block_id` identifies the highest-known block in this tenure, and Get number of blocks signed by signer during a given reward cycle Returns a non-negative integer + +### GET /v3/transactions/[Transaction ID] + +Returns the index_block_hash and the transaction body (as hex) given the TXID. + +```json +{ + "index_block_hash": "...", + "tx": "..." +} +``` + +This feature requires enabling of transaction log by setting the STACKS_TRANSACTION_LOG +environment variable to "1" + +This will return 404 if the transaction does not exist and 501 (Not Implemented) if +transaction log is not enabled. + +This endpoint also accepts a query string parameter `?tip=` which when supplied will ensure +the returned transaction is an a block relative to the specified tip (as the transaction log +could store even non canonical tip transactions). diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index d82494ca36..ec2002c22b 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -866,3 +866,40 @@ paths: schema: type: integer example: 7 + + /v3/transactions/{txid}: + post: + summary: Returns index_block_hash and Transaction body given a TXID + tags: + - Transactions + description: Get a JSON with the index_block_hash and the Transaction body as hex. + operationId: get_transaction + parameters: + - name: txid + in: path + required: true + description: Transaction ID + schema: + type: string + - name: tip + in: query + schema: + type: string + description: The Stacks chain tip to check for the block containing the transaction. + If tip == latest (the default), the latest known tip will be used. + responses: + "200": + description: Transaction ID of successful post of a raw tx to the node's mempool + content: + text/plain: + schema: + type: string + example: '"e161978626f216b2141b156ade10501207ae535fa365a13ef5d7a7c9310a09f2"' + "400": + description: Rejections result in a 400 error + content: + application/json: + schema: + $ref: ./api/transaction/post-core-node-transactions-error.schema.json + example: + $ref: ./api/transaction/post-core-node-transactions-error.example.json diff --git a/stackslib/src/chainstate/burn/db/sortdb.rs b/stackslib/src/chainstate/burn/db/sortdb.rs index e399121e07..f7a9f682ac 100644 --- a/stackslib/src/chainstate/burn/db/sortdb.rs +++ b/stackslib/src/chainstate/burn/db/sortdb.rs @@ -107,6 +107,12 @@ impl FromRow for ConsensusHash { } } +impl FromRow for StacksBlockId { + fn from_row<'a>(row: &'a Row) -> Result { + StacksBlockId::from_column(row, "index_block_hash") + } +} + impl FromRow for BurnchainHeaderHash { fn from_row<'a>(row: &'a Row) -> Result { BurnchainHeaderHash::from_column(row, "burn_header_hash") diff --git a/stackslib/src/chainstate/nakamoto/mod.rs b/stackslib/src/chainstate/nakamoto/mod.rs index 35f6e5d1e1..4661f4d205 100644 --- a/stackslib/src/chainstate/nakamoto/mod.rs +++ b/stackslib/src/chainstate/nakamoto/mod.rs @@ -92,7 +92,8 @@ use crate::chainstate::stacks::address::PoxAddress; use crate::chainstate::stacks::boot::{POX_4_NAME, SIGNERS_UPDATE_STATE}; use crate::chainstate::stacks::db::blocks::DummyEventDispatcher; use crate::chainstate::stacks::db::{ - DBConfig as ChainstateConfig, StacksChainState, StacksDBConn, StacksDBTx, + is_transaction_log_enabled, DBConfig as ChainstateConfig, StacksChainState, StacksDBConn, + StacksDBTx, }; use crate::chainstate::stacks::index::marf::MarfConnection; use crate::chainstate::stacks::{ @@ -4885,6 +4886,26 @@ impl NakamotoChainState { clarity.save_analysis(&contract_id, &analysis).unwrap(); }) } + + /// Get transactions by txid from the transaction log + /// NOTE: multiple rows could match as the transaction log contains unconfirmed/orphaned + /// transactions too + pub fn get_index_block_hashes_from_txid( + conn: &Connection, + txid: Txid, + ) -> Result, chainstate::stacks::Error> { + if is_transaction_log_enabled() { + let args = params![txid]; + query_rows( + conn, + "SELECT index_block_hash FROM transactions WHERE txid = ?", + args, + ) + .map_err(|e| e.into()) + } else { + Err(chainstate::stacks::Error::NoTransactionLog) + } + } } impl StacksMessageCodec for NakamotoBlock { diff --git a/stackslib/src/chainstate/stacks/db/mod.rs b/stackslib/src/chainstate/stacks/db/mod.rs index 42f72d5165..eb877096e7 100644 --- a/stackslib/src/chainstate/stacks/db/mod.rs +++ b/stackslib/src/chainstate/stacks/db/mod.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::cell::RefCell; use std::collections::btree_map::Entry; use std::collections::{BTreeMap, HashSet}; use std::io::prelude::*; @@ -97,10 +98,24 @@ pub mod headers; pub mod transactions; pub mod unconfirmed; +#[cfg(not(test))] lazy_static! { pub static ref TRANSACTION_LOG: bool = std::env::var("STACKS_TRANSACTION_LOG") == Ok("1".into()); } +#[cfg(not(test))] +pub fn is_transaction_log_enabled() -> bool { + *TRANSACTION_LOG +} + +#[cfg(test)] +thread_local! { + pub static TRANSACTION_LOG: RefCell = RefCell::new(false); +} +#[cfg(test)] +pub fn is_transaction_log_enabled() -> bool { + TRANSACTION_LOG.with(|v| *v.borrow()) +} /// Fault injection struct for various kinds of faults we'd like to introduce into the system pub struct StacksChainStateFaults { @@ -646,7 +661,7 @@ impl<'a> ChainstateTx<'a> { block_id: &StacksBlockId, events: &[StacksTransactionReceipt], ) { - if *TRANSACTION_LOG { + if is_transaction_log_enabled() { let insert = "INSERT INTO transactions (txid, index_block_hash, tx_hex, result) VALUES (?, ?, ?, ?)"; for tx_event in events.iter() { diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs index fd370a8b12..1ba1cfb24f 100644 --- a/stackslib/src/chainstate/stacks/mod.rs +++ b/stackslib/src/chainstate/stacks/mod.rs @@ -131,6 +131,7 @@ pub enum Error { /// This error indicates a Epoch2 block attempted to build off of a Nakamoto block. InvalidChildOfNakomotoBlock, NoRegisteredSigners(u64), + NoTransactionLog, } impl From for Error { @@ -232,6 +233,9 @@ impl fmt::Display for Error { Error::NotInSameFork => { write!(f, "The supplied block identifiers are not in the same fork") } + Error::NoTransactionLog => { + write!(f, "TransactionLog is not enabled") + } } } } @@ -277,6 +281,7 @@ impl error::Error for Error { Error::ExpectedTenureChange => None, Error::NoRegisteredSigners(_) => None, Error::NotInSameFork => None, + Error::NoTransactionLog => None, } } } @@ -322,6 +327,7 @@ impl Error { Error::ExpectedTenureChange => "ExpectedTenureChange", Error::NoRegisteredSigners(_) => "NoRegisteredSigners", Error::NotInSameFork => "NotInSameFork", + Error::NoTransactionLog => "NoTransactionLog", } } diff --git a/stackslib/src/net/api/gettransaction.rs b/stackslib/src/net/api/gettransaction.rs new file mode 100644 index 0000000000..2c2588e283 --- /dev/null +++ b/stackslib/src/net/api/gettransaction.rs @@ -0,0 +1,253 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020-2024 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::borrow::BorrowMut; +use std::io::{Read, Write}; + +use regex::{Captures, Regex}; +use stacks_common::codec::StacksMessageCodec; +use stacks_common::types::chainstate::{ + BlockHeaderHash, ConsensusHash, StacksBlockId, StacksPublicKey, +}; +use stacks_common::types::net::PeerHost; +use stacks_common::types::StacksPublicKeyBuffer; +use stacks_common::util::hash::{to_hex, Hash160, Sha256Sum}; + +use crate::burnchains::affirmation::AffirmationMap; +use crate::burnchains::Txid; +use crate::chainstate::burn::db::sortdb::SortitionDB; +use crate::chainstate::nakamoto::NakamotoChainState; +use crate::chainstate::stacks::db::{is_transaction_log_enabled, StacksChainState}; +use crate::core::mempool::MemPoolDB; +use crate::net::http::{ + parse_json, Error, HttpNotFound, HttpNotImplemented, HttpRequest, HttpRequestContents, + HttpRequestPreamble, HttpResponse, HttpResponseContents, HttpResponsePayload, + HttpResponsePreamble, HttpServerError, +}; +use crate::net::httpcore::{ + request, HttpPreambleExtensions, HttpRequestContentsExtensions, RPCRequestHandler, + StacksHttpRequest, StacksHttpResponse, +}; +use crate::net::p2p::PeerNetwork; +use crate::net::{Error as NetError, StacksNodeState, TipRequest}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TransactionResponse { + pub index_block_hash: StacksBlockId, + pub tx: String, +} + +#[derive(Clone)] +pub struct RPCGetTransactionRequestHandler { + pub txid: Option, +} +impl RPCGetTransactionRequestHandler { + pub fn new() -> Self { + Self { txid: None } + } +} + +/// Decode the HTTP request +impl HttpRequest for RPCGetTransactionRequestHandler { + fn verb(&self) -> &'static str { + "GET" + } + + fn path_regex(&self) -> Regex { + Regex::new(r#"^/v3/transactions/(?P[0-9a-f]{64})$"#).unwrap() + } + + fn metrics_identifier(&self) -> &str { + "/v3/transactions/:txid" + } + + /// Try to decode this request. + /// There's nothing to load here, so just make sure the request is well-formed. + fn try_parse_request( + &mut self, + preamble: &HttpRequestPreamble, + captures: &Captures, + query: Option<&str>, + _body: &[u8], + ) -> Result { + if preamble.get_content_length() != 0 { + return Err(Error::DecodeError( + "Invalid Http request: expected 0-length body for GetTransaction".to_string(), + )); + } + + let txid = request::get_txid(captures, "txid")?; + self.txid = Some(txid); + + Ok(HttpRequestContents::new().query_string(query)) + } +} + +impl RPCRequestHandler for RPCGetTransactionRequestHandler { + /// Reset internal state + fn restart(&mut self) { + self.txid = None; + } + + /// Make the response + fn try_handle_request( + &mut self, + preamble: HttpRequestPreamble, + contents: HttpRequestContents, + node: &mut StacksNodeState, + ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { + if !is_transaction_log_enabled() { + return StacksHttpResponse::new_error( + &preamble, + &HttpNotImplemented::new("Transaction log is not enabled".into()), + ) + .try_into_contents() + .map_err(NetError::from); + } + + let tip = match node.load_stacks_chain_tip(&preamble, &contents) { + Ok(tip) => tip, + Err(error_resp) => { + return error_resp.try_into_contents().map_err(NetError::from); + } + }; + + let txid = self + .txid + .take() + .ok_or(NetError::SendError("`txid` no set".into()))?; + + node.with_node_state(|_network, _sortdb, chainstate, _mempool, _rpc_args| { + let index_block_hashes = match NakamotoChainState::get_index_block_hashes_from_txid( + chainstate.index_conn().conn(), + txid, + ) { + Ok(index_block_hashes) => index_block_hashes, + Err(e) => { + // nope -- error trying to check + let msg = format!("Failed to load transaction: {:?}\n", &e); + warn!("{}", &msg); + return StacksHttpResponse::new_error(&preamble, &HttpServerError::new(msg)) + .try_into_contents() + .map_err(NetError::from); + } + }; + + // search for the first matching tx with valid index_block_hash + for index_block_hash in index_block_hashes { + match chainstate + .nakamoto_blocks_db() + .get_nakamoto_block(&index_block_hash) + { + Ok(nakamoto_block) => match nakamoto_block { + Some(block) => { + for tx in block.0.txs { + if tx.txid() == txid { + // if the tx matches, check if the block is an ancestor of the specified tip + let found_block = match chainstate + .index_conn() + .get_ancestor_block_height(&index_block_hash, &tip) + { + Ok(found_block) => found_block, + Err(e) => { + // nope -- error trying to check + let msg = + format!("Failed to check block tip: {:?}\n", &e); + warn!("{}", &msg); + return StacksHttpResponse::new_error( + &preamble, + &HttpServerError::new(msg), + ) + .try_into_contents() + .map_err(NetError::from); + } + }; + + if found_block.is_some() { + let preamble = HttpResponsePreamble::ok_json(&preamble); + let body = HttpResponseContents::try_from_json( + &TransactionResponse { + index_block_hash, + tx: to_hex(&tx.serialize_to_vec()), + }, + )?; + return Ok((preamble, body)); + } + } + } + } + // nakamoto block not found + None => (), + }, + Err(e) => { + // nope -- error trying to check + let msg = format!("Failed to load transaction: {:?}\n", &e); + warn!("{}", &msg); + return StacksHttpResponse::new_error( + &preamble, + &HttpServerError::new(msg), + ) + .try_into_contents() + .map_err(NetError::from); + } + }; + } + + // txid not found + return StacksHttpResponse::new_error( + &preamble, + &HttpNotFound::new(format!("No such transaction {:?}\n", &txid)), + ) + .try_into_contents() + .map_err(NetError::from); + }) + } +} + +/// Decode the HTTP response +impl HttpResponse for RPCGetTransactionRequestHandler { + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + let txinfo: TransactionResponse = parse_json(preamble, body)?; + Ok(HttpResponsePayload::try_from_json(txinfo)?) + } +} + +impl StacksHttpRequest { + /// Make a new get-unconfirmed-tx request + pub fn new_gettransaction(host: PeerHost, txid: Txid, tip: TipRequest) -> StacksHttpRequest { + StacksHttpRequest::new_for_peer( + host, + "GET".into(), + format!("/v3/transactions/{}", &txid), + HttpRequestContents::new().for_tip(tip), + ) + .expect("FATAL: failed to construct request from infallible data") + } +} + +impl StacksHttpResponse { + pub fn decode_gettransaction(self) -> Result { + let contents = self.get_http_payload_ok()?; + let response_json: serde_json::Value = contents.try_into()?; + let txinfo: TransactionResponse = serde_json::from_value(response_json) + .map_err(|_e| Error::DecodeError("Failed to decode JSON".to_string()))?; + Ok(txinfo) + } +} diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index 8d32308d9d..3cbad7c44e 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -67,6 +67,7 @@ pub mod getstxtransfercost; pub mod gettenure; pub mod gettenureinfo; pub mod gettenuretip; +pub mod gettransaction; pub mod gettransaction_unconfirmed; pub mod liststackerdbreplicas; pub mod postblock; @@ -133,6 +134,7 @@ impl StacksHttp { self.register_rpc_endpoint( gettransaction_unconfirmed::RPCGetTransactionUnconfirmedRequestHandler::new(), ); + self.register_rpc_endpoint(gettransaction::RPCGetTransactionRequestHandler::new()); self.register_rpc_endpoint(getsigner::GetSignerRequestHandler::default()); self.register_rpc_endpoint( liststackerdbreplicas::RPCListStackerDBReplicasRequestHandler::new(), diff --git a/stackslib/src/net/api/tests/gettransaction.rs b/stackslib/src/net/api/tests/gettransaction.rs new file mode 100644 index 0000000000..23afcd787d --- /dev/null +++ b/stackslib/src/net/api/tests/gettransaction.rs @@ -0,0 +1,253 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020-2024 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::borrow::{Borrow, BorrowMut}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::ops::Deref; +use std::thread::LocalKey; + +use clarity::util::hash::hex_bytes; +use clarity::vm::types::{QualifiedContractIdentifier, StacksAddressExtensions}; +use clarity::vm::{ClarityName, ContractName}; +use stacks_common::codec::StacksMessageCodec; +use stacks_common::types::chainstate::{ + BlockHeaderHash, ConsensusHash, StacksAddress, StacksBlockId, StacksPrivateKey, +}; +use stacks_common::types::net::PeerHost; +use stacks_common::types::Address; + +use super::TestRPC; +use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandle}; +use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState}; +use crate::chainstate::stacks::db::blocks::test::*; +use crate::chainstate::stacks::db::{StacksChainState, TRANSACTION_LOG}; +use crate::chainstate::stacks::{ + Error as chainstate_error, StacksBlock, StacksBlockHeader, StacksMicroblock, +}; +use crate::net::api::getblock_v3::NakamotoBlockStream; +use crate::net::api::*; +use crate::net::connection::ConnectionOptions; +use crate::net::http::HttpChunkGenerator; +use crate::net::httpcore::{ + HttpPreambleExtensions, HttpRequestContentsExtensions, RPCRequestHandler, StacksHttp, + StacksHttpRequest, +}; +use crate::net::test::TestEventObserver; +use crate::net::tests::inv::nakamoto::make_nakamoto_peer_from_invs; +use crate::net::{ProtocolFamily, TipRequest}; +use crate::util_lib::db::DBConn; + +#[test] +fn test_try_parse_request() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + let mut http = StacksHttp::new(addr.clone(), &ConnectionOptions::default()); + + let request = StacksHttpRequest::new_gettransaction( + addr.into(), + Txid::from_hex("00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF").unwrap(), + TipRequest::UseLatestAnchoredTip, + ); + let bytes = request.try_serialize().unwrap(); + + debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); + + let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); + let mut handler = gettransaction::RPCGetTransactionRequestHandler::new(); + let mut parsed_request = http + .handle_try_parse_request( + &mut handler, + &parsed_preamble.expect_request(), + &bytes[offset..], + ) + .unwrap(); + + // parsed request consumes headers that would not be in a constructed request + parsed_request.clear_headers(); + let (preamble, contents) = parsed_request.destruct(); + + // consumed path args + assert_eq!( + handler.txid, + Some( + Txid::from_hex("00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF") + .unwrap() + ) + ); + + assert_eq!(&preamble, request.preamble()); + + handler.restart(); + assert!(handler.txid.is_none()); +} + +struct TransactionLogState(bool); + +impl TransactionLogState { + fn new(enable: bool) -> Self { + let current_value = TRANSACTION_LOG.with(|v| *v.borrow()); + TRANSACTION_LOG.with(|v| *v.borrow_mut() = enable); + Self { 0: current_value } + } +} + +impl Drop for TransactionLogState { + fn drop(&mut self) { + TRANSACTION_LOG.with(|v| *v.borrow_mut() = self.0); + } +} + +#[test] +fn test_transaction_log_not_implemented() { + // TRANSACTION_LOG original value will be restored at the end of test + let enable_transaction_log = TransactionLogState::new(false); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let test_observer = TestEventObserver::new(); + let rpc_test = TestRPC::setup_nakamoto(function_name!(), &test_observer); + + let mut requests = vec![]; + + // query dummy transaction + let request = StacksHttpRequest::new_gettransaction( + addr.into(), + Txid([0x21; 32]), + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + + let mut responses = rpc_test.run(requests); + + // get response (501 Not Implemented) + let response = responses.remove(0); + + let (preamble, body) = response.destruct(); + + assert_eq!(preamble.status_code, 501); +} + +#[test] +fn test_try_make_response() { + // TRANSACTION_LOG original value will be restored at the end of test + let enable_transaction_log = TransactionLogState::new(true); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let test_observer = TestEventObserver::new(); + let rpc_test = TestRPC::setup_nakamoto(function_name!(), &test_observer); + + let consensus_hash = rpc_test.consensus_hash; + let canonical_tip = rpc_test.canonical_tip.clone(); + + // dummy hack for generating an invalid tip + let mut dummy_tip = rpc_test.canonical_tip.clone(); + dummy_tip.0[0] = dummy_tip.0[0].wrapping_add(1); + + let peer = &rpc_test.peer_1; + let sortdb = peer.sortdb.as_ref().unwrap(); + let tenure_blocks = rpc_test + .peer_1 + .chainstate_ref() + .nakamoto_blocks_db() + .get_all_blocks_in_tenure(&consensus_hash, &canonical_tip) + .unwrap(); + + let nakamoto_block_genesis = tenure_blocks.first().unwrap(); + let tx_genesis = &nakamoto_block_genesis.txs[0]; + + let nakamoto_block_tip = tenure_blocks.last().unwrap(); + let tx_tip = &nakamoto_block_tip.txs[0]; + + let mut requests = vec![]; + + // query the transactions + let request = StacksHttpRequest::new_gettransaction( + addr.into(), + tx_genesis.txid(), + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + + let request = StacksHttpRequest::new_gettransaction( + addr.into(), + tx_tip.txid(), + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + + // valid tx with explicit tip + let request = StacksHttpRequest::new_gettransaction( + addr.into(), + tx_tip.txid(), + TipRequest::SpecificTip(canonical_tip), + ); + requests.push(request); + + // valid tx with explicit tip + let request = StacksHttpRequest::new_gettransaction( + addr.into(), + tx_tip.txid(), + TipRequest::SpecificTip(dummy_tip), + ); + requests.push(request); + + // fake transaction + let request = StacksHttpRequest::new_gettransaction( + addr.into(), + Txid([0x21; 32]), + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + + let mut responses = rpc_test.run(requests); + + // check genesis txid + let response = responses.remove(0); + let resp = response.decode_gettransaction().unwrap(); + + let tx_bytes = hex_bytes(&resp.tx).unwrap(); + let stacks_transaction = StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).unwrap(); + assert_eq!(stacks_transaction.txid(), tx_genesis.txid()); + assert_eq!(stacks_transaction.serialize_to_vec(), tx_bytes); + + // check tip txid + let response = responses.remove(0); + let resp = response.decode_gettransaction().unwrap(); + + let tx_bytes = hex_bytes(&resp.tx).unwrap(); + let stacks_transaction = StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).unwrap(); + assert_eq!(stacks_transaction.txid(), tx_tip.txid()); + assert_eq!(stacks_transaction.serialize_to_vec(), tx_bytes); + + // check explicit tip txid + let response = responses.remove(0); + let resp = response.decode_gettransaction().unwrap(); + + let tx_bytes = hex_bytes(&resp.tx).unwrap(); + let stacks_transaction = StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).unwrap(); + assert_eq!(stacks_transaction.txid(), tx_tip.txid()); + assert_eq!(stacks_transaction.serialize_to_vec(), tx_bytes); + + // check invalid tip txid + let response = responses.remove(0); + let (preamble, body) = response.destruct(); + + assert_eq!(preamble.status_code, 404); + + // invalid tx + let response = responses.remove(0); + let (preamble, body) = response.destruct(); + + assert_eq!(preamble.status_code, 404); +} diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index c6c62dd1fe..1bd26fade0 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -84,6 +84,7 @@ mod getstxtransfercost; mod gettenure; mod gettenureinfo; mod gettenuretip; +mod gettransaction; mod gettransaction_unconfirmed; mod liststackerdbreplicas; mod postblock; diff --git a/stackslib/src/net/http/error.rs b/stackslib/src/net/http/error.rs index c84c04d585..2c54c6261f 100644 --- a/stackslib/src/net/http/error.rs +++ b/stackslib/src/net/http/error.rs @@ -130,6 +130,7 @@ pub fn http_error_from_code_and_text(code: u16, message: String) -> Box Box::new(HttpForbidden::new(message)), 404 => Box::new(HttpNotFound::new(message)), 500 => Box::new(HttpServerError::new(message)), + 501 => Box::new(HttpNotImplemented::new(message)), 503 => Box::new(HttpServiceUnavailable::new(message)), _ => Box::new(HttpError::new(code, message)), } @@ -319,6 +320,33 @@ impl HttpErrorResponse for HttpServerError { } } +/// HTTP 501 +pub struct HttpNotImplemented { + error_text: String, +} + +impl HttpNotImplemented { + pub fn new(error_text: String) -> Self { + Self { error_text } + } +} + +impl HttpErrorResponse for HttpNotImplemented { + fn code(&self) -> u16 { + 501 + } + fn payload(&self) -> HttpResponsePayload { + HttpResponsePayload::Text(self.error_text.clone()) + } + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + try_parse_error_response(preamble.status_code, preamble.content_type, body) + } +} + /// HTTP 503 pub struct HttpServiceUnavailable { error_text: String, diff --git a/stackslib/src/net/http/mod.rs b/stackslib/src/net/http/mod.rs index ca7a97c5be..9ec4d411dc 100644 --- a/stackslib/src/net/http/mod.rs +++ b/stackslib/src/net/http/mod.rs @@ -38,8 +38,8 @@ pub use crate::net::http::common::{ }; pub use crate::net::http::error::{ http_error_from_code_and_text, http_reason, HttpBadRequest, HttpError, HttpErrorResponse, - HttpForbidden, HttpNotFound, HttpPaymentRequired, HttpServerError, HttpServiceUnavailable, - HttpUnauthorized, + HttpForbidden, HttpNotFound, HttpNotImplemented, HttpPaymentRequired, HttpServerError, + HttpServiceUnavailable, HttpUnauthorized, }; pub use crate::net::http::request::{ HttpRequest, HttpRequestContents, HttpRequestPayload, HttpRequestPreamble, diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs index 281feae99a..629e099ed9 100644 --- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs +++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs @@ -9422,6 +9422,167 @@ fn v3_blockbyheight_api_endpoint() { run_loop_thread.join().unwrap(); } +/// Test `/v3/transactions/txid` API endpoint +/// +/// This endpoint returns a JSON with index_block_hash +/// end the transaction body as hex. +#[test] +#[ignore] +fn v3_transactions_api_endpoint() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + env::set_var("STACKS_TRANSACTION_LOG", "1"); + + let (mut conf, _miner_account) = naka_neon_integration_conf(None); + let password = "12345".to_string(); + conf.connection_options.auth_token = Some(password.clone()); + conf.miner.wait_on_interim_blocks = Duration::from_secs(1); + let stacker_sk = setup_stacker(&mut conf); + let signer_sk = Secp256k1PrivateKey::new(); + let signer_addr = tests::to_addr(&signer_sk); + let sender_sk = Secp256k1PrivateKey::new(); + // setup sender + recipient for some test stx transfers + // these are necessary for the interim blocks to get mined at all + let sender_addr = tests::to_addr(&sender_sk); + let send_amt = 100; + let send_fee = 180; + conf.add_initial_balance( + PrincipalData::from(sender_addr).to_string(), + send_amt + send_fee, + ); + conf.add_initial_balance(PrincipalData::from(signer_addr).to_string(), 100000); + + // only subscribe to the block proposal events + test_observer::spawn(); + test_observer::register(&mut conf, &[EventKeyType::MinedBlocks]); + + let mut btcd_controller = BitcoinCoreController::new(conf.clone()); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + let mut btc_regtest_controller = BitcoinRegtestController::new(conf.clone(), None); + btc_regtest_controller.bootstrap_chain(201); + + let mut run_loop = boot_nakamoto::BootRunLoop::new(conf.clone()).unwrap(); + let run_loop_stopper = run_loop.get_termination_switch(); + let Counters { + blocks_processed, + naka_submitted_commits: commits_submitted, + naka_proposed_blocks: proposals_submitted, + .. + } = run_loop.counters(); + + let coord_channel = run_loop.coordinator_channels(); + + let run_loop_thread = thread::spawn(move || run_loop.start(None, 0)); + let mut signers = TestSigners::new(vec![signer_sk]); + wait_for_runloop(&blocks_processed); + boot_to_epoch_3( + &conf, + &blocks_processed, + &[stacker_sk], + &[signer_sk], + &mut Some(&mut signers), + &mut btc_regtest_controller, + ); + + info!("------------------------- Reached Epoch 3.0 -------------------------"); + + blind_signer(&conf, &signers, proposals_submitted); + + wait_for_first_naka_block_commit(60, &commits_submitted); + + // Mine 1 nakamoto tenure + next_block_and_mine_commit( + &mut btc_regtest_controller, + 60, + &coord_channel, + &commits_submitted, + ) + .unwrap(); + + let burnchain = conf.get_burnchain(); + let _sortdb = burnchain.open_sortition_db(true).unwrap(); + let (_chainstate, _) = StacksChainState::open( + conf.is_mainnet(), + conf.burnchain.chain_id, + &conf.get_chainstate_path_str(), + None, + ) + .unwrap(); + + info!("------------------------- Setup finished, run test -------------------------"); + + let http_origin = format!("http://{}", &conf.node.rpc_bind); + + let get_v3_transactions = |txid: Txid| { + let url = &format!("{http_origin}/v3/transactions/{txid}"); + info!("Send request: GET {url}"); + reqwest::blocking::get(url) + .unwrap_or_else(|e| panic!("GET request failed: {e}")) + .json::() + .unwrap() + }; + + let get_transaction_from_block = |index_block_hash: String, txid: String| { + for block_json in test_observer::get_blocks() { + if block_json["index_block_hash"].as_str().unwrap() == format!("0x{}", index_block_hash) + { + for transaction_json in block_json["transactions"].as_array().unwrap() { + if transaction_json["txid"].as_str().unwrap() == format!("0x{}", txid) { + return Some(transaction_json["raw_tx"].as_str().unwrap().to_string()); + } + } + } + } + None + }; + + let block_events = test_observer::get_mined_nakamoto_blocks(); + + let last_block_event = block_events.last().unwrap(); + + let first_transaction = match last_block_event.tx_events.first().unwrap() { + TransactionEvent::Success(first_transaction) => Some(first_transaction.txid), + _ => None, + } + .unwrap(); + + let response_json = get_v3_transactions(first_transaction); + + assert_eq!( + response_json + .get("index_block_hash") + .unwrap() + .as_str() + .unwrap(), + last_block_event.block_id + ); + + let raw_tx = get_transaction_from_block( + last_block_event.block_id.clone(), + first_transaction.to_hex(), + ) + .unwrap(); + + assert_eq!( + format!("0x{}", response_json.get("tx").unwrap().as_str().unwrap()), + raw_tx + ); + + info!("------------------------- Test finished, clean up -------------------------"); + + coord_channel + .lock() + .expect("Mutex poisoned") + .stop_chains_coordinator(); + run_loop_stopper.store(false, Ordering::SeqCst); + + run_loop_thread.join().unwrap(); +} + #[test] #[ignore] /// This test spins up a nakamoto-neon node.