diff --git a/Cargo.lock b/Cargo.lock index 51251b6c1c..e33bb2b33c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1068,7 +1068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", - "regex-automata 0.4.9", + "regex-automata", "serde", ] @@ -4489,11 +4489,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -5040,16 +5040,6 @@ dependencies = [ "utils-networking", ] -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -5616,12 +5606,6 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "owned_ttf_parser" version = "0.25.0" @@ -6223,7 +6207,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "rand_xorshift 0.3.0", - "regex-syntax 0.8.5", + "regex-syntax", "rusty-fork", "tempfile", "unarray", @@ -6558,7 +6542,7 @@ dependencies = [ "crossterm", "fd-lock", "itertools 0.12.1", - "nu-ansi-term 0.50.1", + "nu-ansi-term", "serde", "strip-ansi-escapes", "strum", @@ -6596,17 +6580,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] @@ -6617,15 +6592,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -8672,14 +8641,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", - "nu-ansi-term 0.46.0", + "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "serde", "serde_json", "sharded-slab", diff --git a/api-server/stack-test-suite/tests/v2/mod.rs b/api-server/stack-test-suite/tests/v2/mod.rs index f80b77305d..415266acd9 100644 --- a/api-server/stack-test-suite/tests/v2/mod.rs +++ b/api-server/stack-test-suite/tests/v2/mod.rs @@ -38,6 +38,7 @@ mod token_ids; mod token_ticker; mod transaction; mod transaction_merkle_path; +mod transaction_output; mod transaction_submit; mod transactions; diff --git a/api-server/stack-test-suite/tests/v2/transaction_output.rs b/api-server/stack-test-suite/tests/v2/transaction_output.rs new file mode 100644 index 0000000000..7cc2adb377 --- /dev/null +++ b/api-server/stack-test-suite/tests/v2/transaction_output.rs @@ -0,0 +1,201 @@ +// Copyright (c) 2025 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use api_web_server::api::json_helpers::tx_input_to_json; + +use super::*; + +#[tokio::test] +async fn invalid_transaction_id() { + let (task, response) = + spawn_webserver("/api/v2/transaction/invalid-transaction-id/output/1").await; + + assert_eq!(response.status(), 400); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(body["error"].as_str().unwrap(), "Invalid transaction Id"); + + task.abort(); +} + +#[tokio::test] +async fn transaction_not_found() { + let (task, response) = spawn_webserver( + "/api/v2/transaction/0000000000000000000000000000000000000000000000000000000000000001/output/1", + ) + .await; + + assert_eq!(response.status(), 404); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!( + body["error"].as_str().unwrap(), + "Transaction output not found" + ); + + task.abort(); +} + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test] +async fn ok(#[case] seed: Seed) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (tx, rx) = tokio::sync::oneshot::channel(); + + let task = tokio::spawn(async move { + let web_server_state = { + let mut rng = make_seedable_rng(seed); + let block_height = rng.gen_range(2..50); + let n_blocks = rng.gen_range(block_height..100); + + let chain_config = create_unit_test_config(); + + let chainstate_blocks = { + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config(chain_config.clone()) + .build(); + + let chainstate_block_ids = tf + .create_chain_return_ids(&tf.genesis().get_id().into(), n_blocks, &mut rng) + .unwrap(); + + // Need the "- 1" to account for the genesis block not in the vec + let block_id = chainstate_block_ids[block_height - 1]; + let block = tf.block(tf.to_chain_block_id(&block_id)); + let prev_block = + tf.block(tf.to_chain_block_id(&chainstate_block_ids[block_height - 2])); + let prev_tx = &prev_block.transactions()[0]; + + let transaction_index = rng.gen_range(0..block.transactions().len()); + let transaction = block.transactions()[transaction_index].transaction(); + let transaction_id = transaction.get_id(); + + let utxos = transaction.inputs().iter().map(|inp| match inp { + TxInput::Utxo(outpoint) => { + Some(prev_tx.outputs()[outpoint.output_index() as usize].clone()) + } + TxInput::Account(_) + | TxInput::AccountCommand(_, _) + | TxInput::OrderAccountCommand(_) => None, + }); + + let expected_transaction = json!({ + "block_id": block_id.to_hash().encode_hex::(), + "timestamp": block.timestamp().to_string(), + "confirmations": BlockHeight::new((n_blocks - block_height) as u64).to_string(), + "version_byte": transaction.version_byte(), + "is_replaceable": transaction.is_replaceable(), + "flags": transaction.flags(), + "inputs": transaction.inputs().iter().zip(utxos).map(|(inp, utxo)| json!({ + "input": tx_input_to_json(inp, &TokenDecimals::Single(None), &chain_config), + "utxo": utxo.as_ref().map(|txo| txoutput_to_json(txo, &chain_config, &TokenDecimals::Single(None))), + })).collect::>(), + "outputs": transaction.outputs() + .iter() + .map(|out| txoutput_to_json(out, &chain_config, &TokenDecimals::Single(None))) + .collect::>(), + }); + + _ = tx.send(( + block_id.to_hash().encode_hex::(), + transaction_id.to_hash().encode_hex::(), + expected_transaction, + )); + + chainstate_block_ids + .iter() + .map(|id| tf.block(tf.to_chain_block_id(id))) + .collect::>() + }; + + let storage = { + let mut storage = TransactionalApiServerInMemoryStorage::new(&chain_config); + + let mut db_tx = storage.transaction_rw().await.unwrap(); + db_tx.reinitialize_storage(&chain_config).await.unwrap(); + db_tx.commit().await.unwrap(); + + storage + }; + + let chain_config = Arc::new(chain_config); + let mut local_node = BlockchainState::new(Arc::clone(&chain_config), storage); + local_node.scan_genesis(chain_config.genesis_block()).await.unwrap(); + local_node.scan_blocks(BlockHeight::new(0), chainstate_blocks).await.unwrap(); + + ApiServerWebServerState { + db: Arc::new(local_node.storage().clone_storage().await), + chain_config: Arc::clone(&chain_config), + rpc: Arc::new(DummyRPC {}), + cached_values: Arc::new(CachedValues { + feerate_points: RwLock::new((get_time(), vec![])), + }), + time_getter: Default::default(), + } + }; + + web_server(listener, web_server_state, true).await + }); + + let (block_id, transaction_id, expected_transaction) = rx.await.unwrap(); + let url = format!("/api/v2/transaction/{transaction_id}"); + + // Given that the listener port is open, this will block until a + // response is made (by the web server, which takes the listener + // over) + let response = reqwest::get(format!("http://{}:{}{url}", addr.ip(), addr.port())) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + let body = body.as_object().unwrap(); + + assert_eq!(body.get("block_id").unwrap(), &block_id); + assert_eq!( + body.get("version_byte").unwrap(), + &expected_transaction["version_byte"] + ); + assert_eq!( + body.get("is_replaceable").unwrap(), + &expected_transaction["is_replaceable"] + ); + assert_eq!(body.get("flags").unwrap(), &expected_transaction["flags"]); + assert_eq!(body.get("inputs").unwrap(), &expected_transaction["inputs"]); + assert_eq!( + body.get("outputs").unwrap(), + &expected_transaction["outputs"] + ); + assert_eq!( + body.get("timestamp").unwrap(), + &expected_transaction["timestamp"] + ); + assert_eq!( + body.get("confirmations").unwrap(), + &expected_transaction["confirmations"] + ); + + task.abort(); +} diff --git a/api-server/web-server/src/api/v2.rs b/api-server/web-server/src/api/v2.rs index 0cde5cc75c..fcb3d9bd73 100644 --- a/api-server/web-server/src/api/v2.rs +++ b/api-server/web-server/src/api/v2.rs @@ -39,7 +39,8 @@ use common::{ chain::{ block::timestamp::BlockTimestamp, tokens::{IsTokenFreezable, IsTokenFrozen, IsTokenUnfreezable, TokenId}, - Block, ChainConfig, Destination, SignedTransaction, Transaction, + Block, ChainConfig, Destination, OutPointSourceId, SignedTransaction, Transaction, + UtxoOutPoint, }, primitives::{Amount, BlockHeight, CoinOrTokenId, Id, Idable, H256}, }; @@ -97,7 +98,8 @@ pub fn routes< let router = router .route("/transaction", get(transactions)) .route("/transaction/:id", get(transaction)) - .route("/transaction/:id/merkle-path", get(transaction_merkle_path)); + .route("/transaction/:id/merkle-path", get(transaction_merkle_path)) + .route("/transaction/:id/output/:idx", get(transaction_output)); let router = router .route("/address/:address", get(address)) @@ -560,6 +562,48 @@ pub async fn transaction( Ok(Json(json)) } +pub async fn transaction_output( + Path((transaction_id, output_idx)): Path<(String, u32)>, + State(state): State, Arc>>, +) -> Result { + let transaction_id: Id = H256::from_str(&transaction_id) + .map_err(|_| { + ApiServerWebServerError::ClientError( + ApiServerWebServerClientError::InvalidTransactionId, + ) + })? + .into(); + + let outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_id), output_idx); + + let db_tx = state.db.transaction_ro().await.map_err(|e| { + logging::log::error!("internal error: {e}"); + ApiServerWebServerError::ServerError(ApiServerWebServerServerError::InternalServerError) + })?; + + let utxo = db_tx + .get_utxo(outpoint) + .await + .map_err(|e| { + logging::log::error!("internal error: {e}"); + ApiServerWebServerError::ServerError(ApiServerWebServerServerError::InternalServerError) + })? + .ok_or(ApiServerWebServerError::NotFound( + ApiServerWebServerNotFoundError::TransactionOutputNotFound, + ))?; + + let mut json = txoutput_to_json( + utxo.output(), + &state.chain_config, + &TokenDecimals::Single(utxo.utxo_with_extra_info().token_decimals), + ); + let obj = json.as_object_mut().expect("object"); + + obj.insert("spent".into(), utxo.spent().into()); + + Ok(Json(json)) +} + pub async fn transaction_merkle_path( Path(transaction_id): Path, State(state): State, Arc>>, diff --git a/api-server/web-server/src/error.rs b/api-server/web-server/src/error.rs index cccb970dde..f70d72b099 100644 --- a/api-server/web-server/src/error.rs +++ b/api-server/web-server/src/error.rs @@ -57,6 +57,8 @@ pub enum ApiServerWebServerNotFoundError { TransactionNotFound, #[error("Transaction not part of any block")] TransactionNotPartOfBlock, + #[error("Transaction output not found")] + TransactionOutputNotFound, #[error("Stake pool not found")] PoolNotFound, #[error("Delegation not found")]