From b9313b6dbdd6c98dfe395b3601962ace69e0eea6 Mon Sep 17 00:00:00 2001 From: Oleksandr Zahorodnyi Date: Fri, 17 Apr 2026 15:38:52 +0300 Subject: [PATCH 1/3] Add unit tests to indexer --- .../indexer/src/api/dto/offer_participants.rs | 52 ++++++++ crates/indexer/src/api/dto/offer_utxo.rs | 49 ++++++++ crates/indexer/src/api/dto/offers.rs | 74 ++++++++++++ crates/indexer/src/api/error.rs | 49 ++++++++ crates/indexer/src/api/utils.rs | 15 +++ crates/indexer/src/configuration.rs | 47 ++++++++ crates/indexer/src/esplora_client.rs | 26 ++++ .../src/indexer/handlers/lending_creation.rs | 102 ++++++++++++++++ .../src/indexer/handlers/loan_liquidation.rs | 99 ++++++++++++++++ .../src/indexer/handlers/loan_repayment.rs | 99 ++++++++++++++++ crates/indexer/src/indexer/handlers/mod.rs | 2 + .../indexer/handlers/offer_cancellation.rs | 112 ++++++++++++++++++ .../indexer/src/indexer/handlers/pre_lock.rs | 66 +++++++++++ .../src/indexer/handlers/test_utils.rs | 34 ++++++ crates/indexer/src/models/offer.rs | 97 +++++++++++++++ 15 files changed, 923 insertions(+) create mode 100644 crates/indexer/src/indexer/handlers/test_utils.rs diff --git a/crates/indexer/src/api/dto/offer_participants.rs b/crates/indexer/src/api/dto/offer_participants.rs index 76aff3f..c0e00cc 100644 --- a/crates/indexer/src/api/dto/offer_participants.rs +++ b/crates/indexer/src/api/dto/offer_participants.rs @@ -38,3 +38,55 @@ impl From for ParticipantDto { pub struct ScriptQuery { pub script_pubkey: String, } + +#[cfg(test)] +mod tests { + use super::ParticipantDto; + use crate::models::{OfferParticipantModel, ParticipantType}; + use uuid::Uuid; + + #[test] + fn participant_dto_from_model_maps_hex_and_spent_fields() { + let offer_id = Uuid::new_v4(); + let model = OfferParticipantModel { + offer_id, + participant_type: ParticipantType::Borrower, + script_pubkey: vec![0x51, 0xac], + txid: vec![0x01, 0x02, 0x03], + vout: 4, + created_at_height: 500, + spent_txid: Some(vec![0xaa, 0xbb]), + spent_at_height: Some(777), + }; + + let dto = ParticipantDto::from(model); + + assert_eq!(dto.offer_id, offer_id); + assert_eq!(dto.participant_type, ParticipantType::Borrower); + assert_eq!(dto.script_pubkey, "51ac"); + assert_eq!(dto.txid, "030201"); + assert_eq!(dto.vout, 4); + assert_eq!(dto.created_at_height, 500); + assert_eq!(dto.spent_txid, Some("bbaa".to_string())); + assert_eq!(dto.spent_at_height, Some(777)); + } + + #[test] + fn participant_dto_from_model_handles_unspent_participant_utxo() { + let model = OfferParticipantModel { + offer_id: Uuid::new_v4(), + participant_type: ParticipantType::Lender, + script_pubkey: vec![0x00], + txid: vec![0x10], + vout: 0, + created_at_height: 1, + spent_txid: None, + spent_at_height: None, + }; + + let dto = ParticipantDto::from(model); + + assert_eq!(dto.spent_txid, None); + assert_eq!(dto.spent_at_height, None); + } +} diff --git a/crates/indexer/src/api/dto/offer_utxo.rs b/crates/indexer/src/api/dto/offer_utxo.rs index 6c304b0..6438208 100644 --- a/crates/indexer/src/api/dto/offer_utxo.rs +++ b/crates/indexer/src/api/dto/offer_utxo.rs @@ -30,3 +30,52 @@ impl From for OfferUtxoDto { } } } + +#[cfg(test)] +mod tests { + use super::OfferUtxoDto; + use crate::models::{OfferUtxoModel, UtxoType}; + use uuid::Uuid; + + #[test] + fn offer_utxo_dto_from_model_maps_optional_spent_fields() { + let offer_id = Uuid::new_v4(); + let model = OfferUtxoModel { + offer_id, + txid: vec![0x01, 0x02, 0x03], + vout: 7, + utxo_type: UtxoType::Repayment, + created_at_height: 123, + spent_txid: Some(vec![0xaa, 0xbb]), + spent_at_height: Some(456), + }; + + let dto = OfferUtxoDto::from(model); + + assert_eq!(dto.offer_id, offer_id); + assert_eq!(dto.txid, "030201"); + assert_eq!(dto.vout, 7); + assert_eq!(dto.utxo_type, UtxoType::Repayment); + assert_eq!(dto.created_at_height, 123); + assert_eq!(dto.spent_txid, Some("bbaa".to_string())); + assert_eq!(dto.spent_at_height, Some(456)); + } + + #[test] + fn offer_utxo_dto_from_model_handles_unspent_utxo() { + let model = OfferUtxoModel { + offer_id: Uuid::new_v4(), + txid: vec![0x11], + vout: 0, + utxo_type: UtxoType::Lending, + created_at_height: 1, + spent_txid: None, + spent_at_height: None, + }; + + let dto = OfferUtxoDto::from(model); + + assert_eq!(dto.spent_txid, None); + assert_eq!(dto.spent_at_height, None); + } +} diff --git a/crates/indexer/src/api/dto/offers.rs b/crates/indexer/src/api/dto/offers.rs index c1488a6..824c820 100644 --- a/crates/indexer/src/api/dto/offers.rs +++ b/crates/indexer/src/api/dto/offers.rs @@ -91,3 +91,77 @@ pub struct OfferDetailsResponse { pub info: OfferListItemFull, pub participants: Vec, } + +#[cfg(test)] +mod tests { + use super::{OfferListItemFull, OfferListItemShort}; + use crate::models::{OfferModel, OfferModelShort, OfferStatus}; + use uuid::Uuid; + + #[test] + fn offer_list_item_short_from_model_short_maps_and_formats_fields() { + let id = Uuid::new_v4(); + let model = OfferModelShort { + id, + collateral_asset_id: vec![0x01, 0x02, 0x03], + principal_asset_id: vec![0x04, 0x05, 0x06], + collateral_amount: 1000, + principal_amount: 500, + interest_rate: 250, + loan_expiration_time: 123, + current_status: OfferStatus::Active, + created_at_height: 456, + created_at_txid: vec![0xaa, 0xbb, 0xcc], + }; + + let dto = OfferListItemShort::from(model); + + assert_eq!(dto.id, id); + assert_eq!(dto.status, OfferStatus::Active); + assert_eq!(dto.collateral_asset, "030201"); + assert_eq!(dto.principal_asset, "060504"); + assert_eq!(dto.collateral_amount, 1000); + assert_eq!(dto.principal_amount, 500); + assert_eq!(dto.interest_rate, 250); + assert_eq!(dto.loan_expiration_time, 123); + assert_eq!(dto.created_at_height, 456); + assert_eq!(dto.created_at_txid, "ccbbaa"); + } + + #[test] + fn offer_list_item_full_from_model_maps_nested_and_extra_fields() { + let id = Uuid::new_v4(); + let model = OfferModel { + id, + borrower_pubkey: vec![0x11, 0x22], + borrower_output_script_hash: vec![0x33, 0x44], + collateral_asset_id: vec![0x01, 0x02], + principal_asset_id: vec![0x03, 0x04], + first_parameters_nft_asset_id: vec![0x05, 0x06], + second_parameters_nft_asset_id: vec![0x07, 0x08], + borrower_nft_asset_id: vec![0x09, 0x0a], + lender_nft_asset_id: vec![0x0b, 0x0c], + collateral_amount: 99, + principal_amount: 77, + interest_rate: 12, + loan_expiration_time: 321, + current_status: OfferStatus::Pending, + created_at_height: 55, + created_at_txid: vec![0xde, 0xad], + }; + + let dto = OfferListItemFull::from(model); + + assert_eq!(dto.base.id, id); + assert_eq!(dto.base.status, OfferStatus::Pending); + assert_eq!(dto.base.collateral_asset, "0201"); + assert_eq!(dto.base.principal_asset, "0403"); + assert_eq!(dto.base.created_at_txid, "adde"); + assert_eq!(dto.borrower_pubkey, "1122"); + assert_eq!(dto.borrower_output_script_hash, "3344"); + assert_eq!(dto.first_parameters_nft_asset, "0605"); + assert_eq!(dto.second_parameters_nft_asset, "0807"); + assert_eq!(dto.borrower_nft_asset, "0a09"); + assert_eq!(dto.lender_nft_asset, "0c0b"); + } +} diff --git a/crates/indexer/src/api/error.rs b/crates/indexer/src/api/error.rs index c597fc7..2381dce 100644 --- a/crates/indexer/src/api/error.rs +++ b/crates/indexer/src/api/error.rs @@ -46,3 +46,52 @@ impl IntoResponse for ApiError { (status, body).into_response() } } + +#[cfg(test)] +mod tests { + use super::ApiError; + use axum::{body::to_bytes, http::StatusCode, response::IntoResponse}; + use serde_json::Value; + + #[tokio::test] + async fn bad_request_maps_to_400_with_expected_error_payload() { + let response = ApiError::BadRequest("invalid params".to_string()).into_response(); + let status = response.status(); + let body_bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let json: Value = serde_json::from_slice(&body_bytes).expect("valid json"); + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["code"], "bad_request"); + assert_eq!(json["error"]["message"], "invalid params"); + } + + #[tokio::test] + async fn not_found_maps_to_404_with_resource_message() { + let response = ApiError::NotFound("offer-1".to_string()).into_response(); + let status = response.status(); + let body_bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let json: Value = serde_json::from_slice(&body_bytes).expect("valid json"); + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(json["error"]["code"], "not_found"); + assert_eq!(json["error"]["message"], "Resource not found: offer-1"); + } + + #[tokio::test] + async fn database_error_maps_to_500_with_generic_message() { + let response = ApiError::DatabaseError(sqlx::Error::RowNotFound).into_response(); + let status = response.status(); + let body_bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let json: Value = serde_json::from_slice(&body_bytes).expect("valid json"); + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(json["error"]["code"], "internal_error"); + assert_eq!(json["error"]["message"], "An unexpected error occurred"); + } +} diff --git a/crates/indexer/src/api/utils.rs b/crates/indexer/src/api/utils.rs index f5a9480..fcc71e1 100644 --- a/crates/indexer/src/api/utils.rs +++ b/crates/indexer/src/api/utils.rs @@ -3,3 +3,18 @@ pub fn format_hex(mut bytes_vec: Vec) -> String { hex::encode(bytes_vec) } + +#[cfg(test)] +mod tests { + use super::format_hex; + + #[test] + fn format_hex_reverses_then_encodes() { + assert_eq!(format_hex(vec![0x12, 0x34, 0xab]), "ab3412"); + } + + #[test] + fn format_hex_empty_input_returns_empty_string() { + assert_eq!(format_hex(vec![]), ""); + } +} diff --git a/crates/indexer/src/configuration.rs b/crates/indexer/src/configuration.rs index 09ceca7..a9eb763 100644 --- a/crates/indexer/src/configuration.rs +++ b/crates/indexer/src/configuration.rs @@ -95,3 +95,50 @@ impl TryFrom for Environment { } } } + +#[cfg(test)] +mod tests { + use super::{DatabaseSettings, Environment}; + + #[test] + fn connection_string_builds_expected_postgres_url() { + let settings = DatabaseSettings { + username: "postgres".to_string(), + password: "password".to_string(), + port: 5432, + host: "localhost".to_string(), + database_name: "lending-indexer".to_string(), + }; + + assert_eq!( + settings.connection_string(), + "postgres://postgres:password@localhost:5432/lending-indexer" + ); + } + + #[test] + fn environment_try_from_is_case_insensitive() { + assert!(matches!( + Environment::try_from("LoCaL".to_string()), + Ok(Environment::Local) + )); + assert!(matches!( + Environment::try_from("PRODUCTION".to_string()), + Ok(Environment::Production) + )); + } + + #[test] + fn environment_try_from_invalid_returns_error() { + let result = Environment::try_from("staging".to_string()); + assert!(result.is_err()); + let err = result.err().unwrap_or_default(); + assert!(err.contains("not a supported environment")); + } + + #[test] + fn environment_as_str_returns_expected_values() { + assert_eq!(Environment::Local.as_str(), "local"); + assert_eq!(Environment::Production.as_str(), "production"); + } +} diff --git a/crates/indexer/src/esplora_client.rs b/crates/indexer/src/esplora_client.rs index 6f47579..e273a5d 100644 --- a/crates/indexer/src/esplora_client.rs +++ b/crates/indexer/src/esplora_client.rs @@ -152,3 +152,29 @@ pub enum EsploraClientError { #[error("not found")] NotFound, } + +#[cfg(test)] +mod tests { + use super::{DEFAULT_BASE_URL, EsploraClient}; + use simplex::provider::{ProviderTrait, SimplicityNetwork}; + + #[test] + fn new_uses_default_base_url_without_trailing_slash() { + let client = EsploraClient::new(); + assert_eq!(client.base_url, DEFAULT_BASE_URL); + } + + #[test] + fn with_base_url_trims_trailing_slash() { + let client = EsploraClient::with_base_url("https://example.com/api///"); + assert_eq!(client.base_url, "https://example.com/api"); + } + + #[test] + fn to_simplex_provider_returns_liquid_testnet_provider() { + let client = EsploraClient::with_base_url("https://example.com/api"); + let provider = client.to_simplex_provider(); + + assert_eq!(*provider.get_network(), SimplicityNetwork::LiquidTestnet); + } +} diff --git a/crates/indexer/src/indexer/handlers/lending_creation.rs b/crates/indexer/src/indexer/handlers/lending_creation.rs index ffcef68..c8dd8ab 100644 --- a/crates/indexer/src/indexer/handlers/lending_creation.rs +++ b/crates/indexer/src/indexer/handlers/lending_creation.rs @@ -62,3 +62,105 @@ pub fn is_lending_creation_tx(tx: &Transaction, expected_principal_asset: &[u8]) false } + +#[cfg(test)] +mod tests { + use super::is_lending_creation_tx; + use crate::indexer::handlers::test_utils::{ + explicit_asset_output, make_tx_with_inputs, normal_output, + }; + + #[test] + fn valid_lending_creation_tx_returns_true() { + let expected_asset = vec![7_u8; 32]; + let tx = make_tx_with_inputs( + 7, + vec![ + normal_output(), + explicit_asset_output(7), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + ], + ); + + assert!(is_lending_creation_tx(&tx, &expected_asset)); + } + + #[test] + fn inputs_less_than_7_returns_false() { + let expected_asset = vec![7_u8; 32]; + let tx = make_tx_with_inputs( + 6, + vec![ + normal_output(), + explicit_asset_output(7), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + ], + ); + + assert!(!is_lending_creation_tx(&tx, &expected_asset)); + } + + #[test] + fn outputs_less_than_7_returns_false() { + let expected_asset = vec![7_u8; 32]; + let tx = make_tx_with_inputs( + 7, + vec![ + normal_output(), + explicit_asset_output(7), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + ], + ); + + assert!(!is_lending_creation_tx(&tx, &expected_asset)); + } + + #[test] + fn output_1_asset_mismatch_returns_false() { + let expected_asset = vec![7_u8; 32]; + let tx = make_tx_with_inputs( + 7, + vec![ + normal_output(), + explicit_asset_output(8), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + ], + ); + + assert!(!is_lending_creation_tx(&tx, &expected_asset)); + } + + #[test] + fn output_1_non_explicit_asset_returns_false() { + let expected_asset = vec![7_u8; 32]; + let tx = make_tx_with_inputs( + 7, + vec![ + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + ], + ); + + assert!(!is_lending_creation_tx(&tx, &expected_asset)); + } +} diff --git a/crates/indexer/src/indexer/handlers/loan_liquidation.rs b/crates/indexer/src/indexer/handlers/loan_liquidation.rs index 1fda15b..2495187 100644 --- a/crates/indexer/src/indexer/handlers/loan_liquidation.rs +++ b/crates/indexer/src/indexer/handlers/loan_liquidation.rs @@ -51,3 +51,102 @@ pub fn is_loan_liquidation_tx(tx: &Transaction) -> bool { && tx.output[3].is_null_data() && !tx.output[4].is_null_data() } + +#[cfg(test)] +mod tests { + use super::is_loan_liquidation_tx; + use crate::indexer::handlers::test_utils::{make_tx, normal_output, null_output}; + + #[test] + fn valid_liquidation_tx_returns_true() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + null_output(), + normal_output(), + ]); + + assert!(is_loan_liquidation_tx(&tx)); + } + + #[test] + fn output_1_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + null_output(), + null_output(), + normal_output(), + ]); + + assert!(!is_loan_liquidation_tx(&tx)); + } + + #[test] + fn output_2_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + normal_output(), + null_output(), + normal_output(), + ]); + + assert!(!is_loan_liquidation_tx(&tx)); + } + + #[test] + fn output_3_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + normal_output(), + normal_output(), + ]); + + assert!(!is_loan_liquidation_tx(&tx)); + } + + #[test] + fn output_4_is_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + null_output(), + null_output(), + ]); + + assert!(!is_loan_liquidation_tx(&tx)); + } + + #[test] + fn output_0_also_null_data_still_returns_true() { + let tx = make_tx(vec![ + null_output(), + null_output(), + null_output(), + null_output(), + normal_output(), + ]); + + assert!(is_loan_liquidation_tx(&tx)); + } + + #[test] + fn extra_outputs_beyond_index_4_do_not_affect_result() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + null_output(), + normal_output(), + null_output(), + normal_output(), + ]); + + assert!(is_loan_liquidation_tx(&tx)); + } +} diff --git a/crates/indexer/src/indexer/handlers/loan_repayment.rs b/crates/indexer/src/indexer/handlers/loan_repayment.rs index dcf3b35..efa65ee 100644 --- a/crates/indexer/src/indexer/handlers/loan_repayment.rs +++ b/crates/indexer/src/indexer/handlers/loan_repayment.rs @@ -57,3 +57,102 @@ pub fn is_loan_repayment_tx(tx: &Transaction) -> bool { && tx.output[3].is_null_data() && tx.output[4].is_null_data() } + +#[cfg(test)] +mod tests { + use super::is_loan_repayment_tx; + use crate::indexer::handlers::test_utils::{make_tx, normal_output, null_output}; + + #[test] + fn valid_repayment_tx_returns_true() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + null_output(), + null_output(), + null_output(), + ]); + + assert!(is_loan_repayment_tx(&tx)); + } + + #[test] + fn output_1_is_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + null_output(), + null_output(), + ]); + + assert!(!is_loan_repayment_tx(&tx)); + } + + #[test] + fn output_2_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + normal_output(), + null_output(), + null_output(), + ]); + + assert!(!is_loan_repayment_tx(&tx)); + } + + #[test] + fn output_3_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + null_output(), + normal_output(), + null_output(), + ]); + + assert!(!is_loan_repayment_tx(&tx)); + } + + #[test] + fn output_4_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + null_output(), + null_output(), + normal_output(), + ]); + + assert!(!is_loan_repayment_tx(&tx)); + } + + #[test] + fn output_0_also_null_data_still_returns_true() { + let tx = make_tx(vec![ + null_output(), + normal_output(), + null_output(), + null_output(), + null_output(), + ]); + + assert!(is_loan_repayment_tx(&tx)); + } + + #[test] + fn extra_outputs_beyond_index_4_do_not_affect_result() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + null_output(), + null_output(), + null_output(), + normal_output(), + null_output(), + ]); + + assert!(is_loan_repayment_tx(&tx)); + } +} diff --git a/crates/indexer/src/indexer/handlers/mod.rs b/crates/indexer/src/indexer/handlers/mod.rs index d4e838a..51a4ba3 100644 --- a/crates/indexer/src/indexer/handlers/mod.rs +++ b/crates/indexer/src/indexer/handlers/mod.rs @@ -6,6 +6,8 @@ pub mod offers; pub mod participants; pub mod pre_lock; pub mod repayment_claim; +#[cfg(test)] +pub(crate) mod test_utils; pub use lending_creation::*; pub use loan_liquidation::*; diff --git a/crates/indexer/src/indexer/handlers/offer_cancellation.rs b/crates/indexer/src/indexer/handlers/offer_cancellation.rs index ff67e59..338e49b 100644 --- a/crates/indexer/src/indexer/handlers/offer_cancellation.rs +++ b/crates/indexer/src/indexer/handlers/offer_cancellation.rs @@ -52,3 +52,115 @@ pub fn is_offer_cancellation_tx(tx: &Transaction) -> bool { && tx.output[3].is_null_data() && tx.output[4].is_null_data() } + +#[cfg(test)] +mod tests { + use super::is_offer_cancellation_tx; + use crate::indexer::handlers::test_utils::{make_tx, normal_output, null_output}; + + #[test] + fn valid_cancellation_tx_returns_true() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + null_output(), + null_output(), + ]); + + assert!(is_offer_cancellation_tx(&tx)); + } + + #[test] + fn output_1_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + null_output(), + null_output(), + null_output(), + ]); + + assert!(!is_offer_cancellation_tx(&tx)); + } + + #[test] + fn output_2_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + normal_output(), + null_output(), + null_output(), + ]); + + assert!(!is_offer_cancellation_tx(&tx)); + } + + #[test] + fn output_3_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + normal_output(), + null_output(), + ]); + + assert!(!is_offer_cancellation_tx(&tx)); + } + + #[test] + fn output_4_not_null_data_returns_false() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + null_output(), + normal_output(), + ]); + + assert!(!is_offer_cancellation_tx(&tx)); + } + + #[test] + fn all_outputs_normal_returns_false() { + let tx = make_tx(vec![ + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + ]); + + assert!(!is_offer_cancellation_tx(&tx)); + } + + #[test] + fn output_0_also_null_data_still_returns_true() { + let tx = make_tx(vec![ + null_output(), + null_output(), + null_output(), + null_output(), + null_output(), + ]); + + assert!(is_offer_cancellation_tx(&tx)); + } + + #[test] + fn extra_outputs_beyond_index_4_do_not_affect_result() { + let tx = make_tx(vec![ + normal_output(), + null_output(), + null_output(), + null_output(), + null_output(), + normal_output(), + null_output(), + ]); + + assert!(is_offer_cancellation_tx(&tx)); + } +} diff --git a/crates/indexer/src/indexer/handlers/pre_lock.rs b/crates/indexer/src/indexer/handlers/pre_lock.rs index 569b359..80544df 100644 --- a/crates/indexer/src/indexer/handlers/pre_lock.rs +++ b/crates/indexer/src/indexer/handlers/pre_lock.rs @@ -116,3 +116,69 @@ pub fn is_pre_lock_creation_tx( Some(pre_lock_parameters) } + +#[cfg(test)] +mod tests { + use super::is_pre_lock_creation_tx; + use crate::esplora_client::EsploraClient; + use crate::indexer::handlers::test_utils::{make_tx_with_inputs, normal_output, null_output}; + + #[test] + fn returns_none_when_inputs_less_than_5() { + let tx = make_tx_with_inputs( + 4, + vec![ + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + null_output(), + normal_output(), + ], + ); + + let client = EsploraClient::new(); + + assert!(is_pre_lock_creation_tx(&tx, &client).is_none()); + } + + #[test] + fn returns_none_when_outputs_less_than_7() { + let tx = make_tx_with_inputs( + 5, + vec![ + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + null_output(), + ], + ); + + let client = EsploraClient::new(); + + assert!(is_pre_lock_creation_tx(&tx, &client).is_none()); + } + + #[test] + fn returns_none_when_output_5_is_not_null_data() { + let tx = make_tx_with_inputs( + 5, + vec![ + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + normal_output(), + ], + ); + + let client = EsploraClient::new(); + + assert!(is_pre_lock_creation_tx(&tx, &client).is_none()); + } +} diff --git a/crates/indexer/src/indexer/handlers/test_utils.rs b/crates/indexer/src/indexer/handlers/test_utils.rs new file mode 100644 index 0000000..38de6c0 --- /dev/null +++ b/crates/indexer/src/indexer/handlers/test_utils.rs @@ -0,0 +1,34 @@ +use simplex::simplicityhl::elements::{ + AssetId, LockTime, Script, Transaction, TxIn, TxOut, confidential, +}; + +pub(crate) fn null_output() -> TxOut { + TxOut { + script_pubkey: Script::new_op_return(b"burn"), + ..Default::default() + } +} + +pub(crate) fn normal_output() -> TxOut { + TxOut::default() +} + +pub(crate) fn explicit_asset_output(asset_byte: u8) -> TxOut { + let mut output = normal_output(); + let asset_id = AssetId::from_slice(&[asset_byte; 32]).expect("valid asset id"); + output.asset = confidential::Asset::Explicit(asset_id); + output +} + +pub(crate) fn make_tx(outputs: Vec) -> Transaction { + make_tx_with_inputs(1, outputs) +} + +pub(crate) fn make_tx_with_inputs(inputs_count: usize, outputs: Vec) -> Transaction { + Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![TxIn::default(); inputs_count], + output: outputs, + } +} diff --git a/crates/indexer/src/models/offer.rs b/crates/indexer/src/models/offer.rs index 4acd028..0250e0f 100644 --- a/crates/indexer/src/models/offer.rs +++ b/crates/indexer/src/models/offer.rs @@ -111,3 +111,100 @@ pub struct OfferModelShort { pub created_at_height: i64, pub created_at_txid: Vec, } + +#[cfg(test)] +mod tests { + use super::{OfferModel, OfferStatus}; + use lending_contracts::{programs::PreLockParameters, utils::LendingOfferParameters}; + use simplex::{ + provider::SimplicityNetwork, + simplicityhl::elements::{AssetId, Txid, hashes::Hash, secp256k1_zkp::XOnlyPublicKey}, + }; + use std::str::FromStr; + + fn make_pre_lock_params() -> PreLockParameters { + PreLockParameters { + collateral_asset_id: AssetId::from_slice(&[1_u8; 32]).expect("asset"), + principal_asset_id: AssetId::from_slice(&[2_u8; 32]).expect("asset"), + first_parameters_nft_asset_id: AssetId::from_slice(&[3_u8; 32]).expect("asset"), + second_parameters_nft_asset_id: AssetId::from_slice(&[4_u8; 32]).expect("asset"), + borrower_nft_asset_id: AssetId::from_slice(&[5_u8; 32]).expect("asset"), + lender_nft_asset_id: AssetId::from_slice(&[6_u8; 32]).expect("asset"), + offer_parameters: LendingOfferParameters { + collateral_amount: 1_000, + principal_amount: 500, + loan_expiration_time: 12_345, + principal_interest_rate: 250, + }, + borrower_pubkey: XOnlyPublicKey::from_str( + "7c7db0528e8b7b58e698ac104764f6852d74b5a7335bffcdad0ce799dd7742ec", + ) + .expect("valid xonly key"), + borrower_output_script_hash: [9_u8; 32], + network: SimplicityNetwork::LiquidTestnet, + } + } + + #[test] + fn offer_model_new_maps_all_fields_from_pre_lock_parameters() { + let params = make_pre_lock_params(); + let block_height = 777_u64; + let txid = Txid::from_slice(&[10_u8; 32]).expect("txid"); + + let model = OfferModel::new(¶ms, block_height, txid); + + assert_eq!( + model.borrower_pubkey, + params.borrower_pubkey.serialize().to_vec() + ); + assert_eq!( + model.borrower_output_script_hash, + params.borrower_output_script_hash.to_vec() + ); + assert_eq!( + model.collateral_asset_id, + params.collateral_asset_id.into_inner().0.to_vec() + ); + assert_eq!( + model.principal_asset_id, + params.principal_asset_id.into_inner().0.to_vec() + ); + assert_eq!( + model.first_parameters_nft_asset_id, + params.first_parameters_nft_asset_id.into_inner().0.to_vec() + ); + assert_eq!( + model.second_parameters_nft_asset_id, + params + .second_parameters_nft_asset_id + .into_inner() + .0 + .to_vec() + ); + assert_eq!( + model.borrower_nft_asset_id, + params.borrower_nft_asset_id.into_inner().0.to_vec() + ); + assert_eq!( + model.lender_nft_asset_id, + params.lender_nft_asset_id.into_inner().0.to_vec() + ); + assert_eq!(model.collateral_amount, 1_000); + assert_eq!(model.principal_amount, 500); + assert_eq!(model.interest_rate, 250); + assert_eq!(model.loan_expiration_time, 12_345); + assert_eq!(model.current_status, OfferStatus::Pending); + assert_eq!(model.created_at_height, block_height as i64); + assert_eq!(model.created_at_txid, txid.as_byte_array().to_vec()); + } + + #[test] + fn offer_model_new_generates_non_nil_offer_id() { + let params = make_pre_lock_params(); + let txid = Txid::from_slice(&[11_u8; 32]).expect("txid"); + + let model = OfferModel::new(¶ms, 1, txid); + + assert_ne!(model.id, uuid::Uuid::nil()); + } +} From 989aa0a95bdc3ce3d2d26f25a7d78b8506b972cb Mon Sep 17 00:00:00 2001 From: Oleksandr Zahorodnyi Date: Tue, 21 Apr 2026 15:30:36 +0300 Subject: [PATCH 2/3] Add indexer integration tests and minor fixes --- .github/workflows/tests.yml | 5 + ...97f48646a3a4444b9dfbe2f6c1d0c5b89de3.json} | 16 +- Cargo.lock | 42 + crates/indexer/Cargo.toml | 1 + crates/indexer/src/api/db.rs | 2 +- crates/indexer/src/indexer/db.rs | 12 +- .../src/indexer/handlers/loan_liquidation.rs | 4 + .../src/indexer/handlers/loan_repayment.rs | 4 + .../indexer/handlers/offer_cancellation.rs | 4 + .../indexer/src/indexer/handlers/pre_lock.rs | 5 +- crates/indexer/src/indexer/mod.rs | 1 + crates/indexer/tests/api_integration.rs | 722 ++++++++ crates/indexer/tests/common/mod.rs | 271 +++ crates/indexer/tests/indexer_integration.rs | 1555 +++++++++++++++++ 14 files changed, 2634 insertions(+), 10 deletions(-) rename .sqlx/{query-7688a0cdbf410b1e075250aebbd0b6b290942d80ef931690cc24675fe2b6ce47.json => query-5fa2148a824869479fc6ea12c9bf97f48646a3a4444b9dfbe2f6c1d0c5b89de3.json} (74%) create mode 100644 crates/indexer/tests/api_integration.rs create mode 100644 crates/indexer/tests/common/mod.rs create mode 100644 crates/indexer/tests/indexer_integration.rs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37d5ced..93eb424 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,6 +107,11 @@ jobs: cd crates/indexer SKIP_DOCKER=true ./scripts/init_db.sh + - name: Run indexer tests + env: + DATABASE_URL: postgres://${{ env.APP_USER }}:${{ env.APP_USER_PWD }}@localhost:5432/${{ env.APP_DB_NAME }} + run: cargo test -p lending-indexer + - name: Run simplex tests shell: bash run: | diff --git a/.sqlx/query-7688a0cdbf410b1e075250aebbd0b6b290942d80ef931690cc24675fe2b6ce47.json b/.sqlx/query-5fa2148a824869479fc6ea12c9bf97f48646a3a4444b9dfbe2f6c1d0c5b89de3.json similarity index 74% rename from .sqlx/query-7688a0cdbf410b1e075250aebbd0b6b290942d80ef931690cc24675fe2b6ce47.json rename to .sqlx/query-5fa2148a824869479fc6ea12c9bf97f48646a3a4444b9dfbe2f6c1d0c5b89de3.json index e82bc25..ddc7f15 100644 --- a/.sqlx/query-7688a0cdbf410b1e075250aebbd0b6b290942d80ef931690cc24675fe2b6ce47.json +++ b/.sqlx/query-5fa2148a824869479fc6ea12c9bf97f48646a3a4444b9dfbe2f6c1d0c5b89de3.json @@ -1,8 +1,14 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO offers (\n id, borrower_pubkey, borrower_output_script_hash, collateral_asset_id, principal_asset_id,\n first_parameters_nft_asset_id, second_parameters_nft_asset_id,\n borrower_nft_asset_id, lender_nft_asset_id,\n collateral_amount, principal_amount, interest_rate,\n loan_expiration_time, created_at_height, created_at_txid\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (created_at_txid) DO NOTHING\n ", + "query": "\n INSERT INTO offers (\n id, borrower_pubkey, borrower_output_script_hash, collateral_asset_id, principal_asset_id,\n first_parameters_nft_asset_id, second_parameters_nft_asset_id,\n borrower_nft_asset_id, lender_nft_asset_id,\n collateral_amount, principal_amount, interest_rate,\n loan_expiration_time, created_at_height, created_at_txid\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (created_at_txid) DO NOTHING\n RETURNING id\n ", "describe": { - "columns": [], + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], "parameters": { "Left": [ "Uuid", @@ -22,7 +28,9 @@ "Bytea" ] }, - "nullable": [] + "nullable": [ + false + ] }, - "hash": "7688a0cdbf410b1e075250aebbd0b6b290942d80ef931690cc24675fe2b6ce47" + "hash": "5fa2148a824869479fc6ea12c9bf97f48646a3a4444b9dfbe2f6c1d0c5b89de3" } diff --git a/Cargo.lock b/Cargo.lock index a999e0e..f29f1e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1692,6 +1692,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serial_test", "smplx-std", "sqlx", "thiserror 2.0.18", @@ -2671,6 +2672,15 @@ dependencies = [ "regex", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2696,6 +2706,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "secp256k1" version = "0.29.1" @@ -2856,6 +2872,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot 0.12.5", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/crates/indexer/Cargo.toml b/crates/indexer/Cargo.toml index 7299a56..86d15d5 100644 --- a/crates/indexer/Cargo.toml +++ b/crates/indexer/Cargo.toml @@ -51,3 +51,4 @@ features = [ [dev-dependencies] anyhow = "1" cargo-husky = { workspace = true } +serial_test = "3" diff --git a/crates/indexer/src/api/db.rs b/crates/indexer/src/api/db.rs index 99f71de..da792bd 100644 --- a/crates/indexer/src/api/db.rs +++ b/crates/indexer/src/api/db.rs @@ -29,7 +29,7 @@ pub async fn fetch_offers_full_info_filtered( ) -> Result, sqlx::Error> { let mut query_builder: QueryBuilder = QueryBuilder::new( r#" - SELECT id, current_status, borrower_pubkey, collateral_asset_id, principal_asset_id, + SELECT id, current_status, borrower_pubkey, borrower_output_script_hash, collateral_asset_id, principal_asset_id, first_parameters_nft_asset_id, second_parameters_nft_asset_id, borrower_nft_asset_id, lender_nft_asset_id, collateral_amount, principal_amount, interest_rate, loan_expiration_time, created_at_height, created_at_txid FROM offers WHERE 1=1 diff --git a/crates/indexer/src/indexer/db.rs b/crates/indexer/src/indexer/db.rs index e28190e..19007b9 100644 --- a/crates/indexer/src/indexer/db.rs +++ b/crates/indexer/src/indexer/db.rs @@ -46,8 +46,11 @@ pub async fn upsert_sync_state( skip(sql_tx, offer), fields(offer_id = %offer.id) )] -pub async fn insert_offer(sql_tx: &mut DbTx<'_>, offer: &OfferModel) -> Result<(), sqlx::Error> { - sqlx::query!( +pub async fn insert_offer( + sql_tx: &mut DbTx<'_>, + offer: &OfferModel, +) -> Result, sqlx::Error> { + let row = sqlx::query!( r#" INSERT INTO offers ( id, borrower_pubkey, borrower_output_script_hash, collateral_asset_id, principal_asset_id, @@ -57,6 +60,7 @@ pub async fn insert_offer(sql_tx: &mut DbTx<'_>, offer: &OfferModel) -> Result<( loan_expiration_time, created_at_height, created_at_txid ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (created_at_txid) DO NOTHING + RETURNING id "#, offer.id, offer.borrower_pubkey, @@ -74,14 +78,14 @@ pub async fn insert_offer(sql_tx: &mut DbTx<'_>, offer: &OfferModel) -> Result<( offer.created_at_height, offer.created_at_txid, ) - .execute(&mut **sql_tx) + .fetch_optional(&mut **sql_tx) .await .map_err(|e| { tracing::error!("Failed to insert offer to the DB: {e:?}"); e })?; - Ok(()) + Ok(row.map(|r| r.id)) } #[tracing::instrument( diff --git a/crates/indexer/src/indexer/handlers/loan_liquidation.rs b/crates/indexer/src/indexer/handlers/loan_liquidation.rs index 2495187..8f81a9c 100644 --- a/crates/indexer/src/indexer/handlers/loan_liquidation.rs +++ b/crates/indexer/src/indexer/handlers/loan_liquidation.rs @@ -46,6 +46,10 @@ pub async fn handle_loan_liquidation( } pub fn is_loan_liquidation_tx(tx: &Transaction) -> bool { + if tx.output.len() < 5 { + return false; + } + tx.output[1].is_null_data() && tx.output[2].is_null_data() && tx.output[3].is_null_data() diff --git a/crates/indexer/src/indexer/handlers/loan_repayment.rs b/crates/indexer/src/indexer/handlers/loan_repayment.rs index efa65ee..d98cfac 100644 --- a/crates/indexer/src/indexer/handlers/loan_repayment.rs +++ b/crates/indexer/src/indexer/handlers/loan_repayment.rs @@ -52,6 +52,10 @@ pub async fn handle_loan_repayment( } pub fn is_loan_repayment_tx(tx: &Transaction) -> bool { + if tx.output.len() < 5 { + return false; + } + !tx.output[1].is_null_data() && tx.output[2].is_null_data() && tx.output[3].is_null_data() diff --git a/crates/indexer/src/indexer/handlers/offer_cancellation.rs b/crates/indexer/src/indexer/handlers/offer_cancellation.rs index 338e49b..c6368c5 100644 --- a/crates/indexer/src/indexer/handlers/offer_cancellation.rs +++ b/crates/indexer/src/indexer/handlers/offer_cancellation.rs @@ -47,6 +47,10 @@ pub async fn handle_offer_cancellation( } pub fn is_offer_cancellation_tx(tx: &Transaction) -> bool { + if tx.output.len() < 5 { + return false; + } + tx.output[1].is_null_data() && tx.output[2].is_null_data() && tx.output[3].is_null_data() diff --git a/crates/indexer/src/indexer/handlers/pre_lock.rs b/crates/indexer/src/indexer/handlers/pre_lock.rs index 80544df..3894bf4 100644 --- a/crates/indexer/src/indexer/handlers/pre_lock.rs +++ b/crates/indexer/src/indexer/handlers/pre_lock.rs @@ -35,7 +35,10 @@ pub async fn handle_pre_lock_creation( let offer_model = OfferModel::new(&pre_lock_params, block_height, txid); - db::insert_offer(sql_tx, &offer_model).await?; + if db::insert_offer(sql_tx, &offer_model).await?.is_none() { + tracing::debug!(%txid, "Pre-lock offer already indexed, skipping"); + return Ok(()); + } let pre_lock_outpoint = OutPoint { txid, vout: 0 }; let pre_lock_offer_utxo = OfferUtxoModel { diff --git a/crates/indexer/src/indexer/mod.rs b/crates/indexer/src/indexer/mod.rs index 227bc22..bf93ae1 100644 --- a/crates/indexer/src/indexer/mod.rs +++ b/crates/indexer/src/indexer/mod.rs @@ -4,6 +4,7 @@ mod handlers; mod processors; pub mod worker; +pub use cache::UtxoCache; pub use db::*; pub use handlers::*; pub use processors::*; diff --git a/crates/indexer/tests/api_integration.rs b/crates/indexer/tests/api_integration.rs new file mode 100644 index 0000000..26cac7d --- /dev/null +++ b/crates/indexer/tests/api_integration.rs @@ -0,0 +1,722 @@ +mod common; + +use std::time::Duration; + +use lending_indexer::api::server::run_server; +use lending_indexer::models::{OfferStatus, ParticipantType, UtxoType}; +use reqwest::StatusCode; +use serde_json::Value; +use serial_test::serial; +use simplex::simplicityhl::elements::OutPoint; +use sqlx::PgPool; +use tokio::net::TcpListener; +use tokio::time::timeout; +use uuid::Uuid; + +use crate::common::{ + FIXED_BORROWER_PUBKEY_HEX, offer_model, outpoint_from_uuid_vout, seed_offer_row, + seed_offer_utxo_row, seed_participant_utxo_row, spent_offer_utxo, spent_participant, test_pool, + unique_32_bytes_from_uuid, unspent_offer_utxo, unspent_participant, +}; + +async fn start_api(pool: PgPool) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + run_server(listener, pool).await; + }); + // Socket is already listening after `bind`; kernel buffers early + // connection attempts, so no startup sleep is required. + Ok((format!("http://{addr}"), handle)) +} + +async fn response_json(response: reqwest::Response) -> anyhow::Result { + // `reqwest::Response::json` needs the `json` feature which this crate + // does not enable. + let body = response.text().await?; + Ok(serde_json::from_str(&body)?) +} + +async fn get_json(http: &reqwest::Client, url: String) -> anyhow::Result { + let response = http.get(url).send().await?.error_for_status()?; + response_json(response).await +} + +async fn post_json( + http: &reqwest::Client, + url: String, + body: Value, +) -> anyhow::Result { + Ok(http + .post(url) + .header("content-type", "application/json") + .body(body.to_string()) + .send() + .await?) +} + +fn ids_from_objects(value: &Value) -> Vec { + let mut ids: Vec = value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default(); + ids.sort(); + ids +} + +fn uuid_strings_from_array(value: &Value) -> Vec { + let mut ids: Vec = value + .as_array() + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default(); + ids.sort(); + ids +} + +fn assert_ids_match_unordered(value: &Value, expected: &[Uuid]) { + let mut expected_ids: Vec = expected.iter().map(Uuid::to_string).collect(); + expected_ids.sort(); + assert_eq!(ids_from_objects(value), expected_ids); +} + +fn assert_uuid_values_match_unordered(value: &Value, expected: &[Uuid]) { + let mut expected_ids: Vec = expected.iter().map(Uuid::to_string).collect(); + expected_ids.sort(); + assert_eq!(uuid_strings_from_array(value), expected_ids); +} + +/// Canonical offer graph used across most list/detail tests: +/// - spent pre-lock UTXO (vout 0) + current unspent lending UTXO (vout 2); +/// - historical borrower participant (vout 1, `51ac`) + current +/// unspent borrower participant (vout 3, `52ac`). +async fn seed_offer_graph( + pool: &PgPool, + offer_id: Uuid, + status: OfferStatus, + created_at_height: i64, +) -> anyhow::Result<()> { + let mut offer = offer_model( + offer_id, + created_at_height, + unique_32_bytes_from_uuid(offer_id), + ); + offer.current_status = status; + seed_offer_row(pool, &offer).await?; + + let outpoint = outpoint_from_uuid_vout(offer_id, 0); + let pre_lock = spent_offer_utxo( + offer_id, + outpoint, + UtxoType::PreLock, + created_at_height, + created_at_height + 1, + 0x99, + ); + let lending = unspent_offer_utxo( + offer_id, + OutPoint { + txid: outpoint.txid, + vout: 2, + }, + UtxoType::Lending, + created_at_height + 2, + ); + seed_offer_utxo_row(pool, &pre_lock).await?; + seed_offer_utxo_row(pool, &lending).await?; + + let old_borrower = spent_participant( + offer_id, + ParticipantType::Borrower, + OutPoint { + txid: outpoint.txid, + vout: 1, + }, + vec![0x51, 0xac], + created_at_height, + created_at_height + 3, + 0x77, + ); + let current_borrower = unspent_participant( + offer_id, + ParticipantType::Borrower, + OutPoint { + txid: outpoint.txid, + vout: 3, + }, + vec![0x52, 0xac], + created_at_height + 4, + ); + seed_participant_utxo_row(pool, &old_borrower).await?; + seed_participant_utxo_row(pool, ¤t_borrower).await?; + + Ok(()) +} + +const PENDING_OFFER_HEIGHT: i64 = 42; +const ACTIVE_OFFER_HEIGHT: i64 = 43; + +async fn setup_seeded_api() -> anyhow::Result<(String, tokio::task::JoinHandle<()>, Uuid, Uuid)> { + let pool = test_pool().await?; + + let pending_offer = Uuid::new_v4(); + let active_offer = Uuid::new_v4(); + seed_offer_graph( + &pool, + pending_offer, + OfferStatus::Pending, + PENDING_OFFER_HEIGHT, + ) + .await?; + seed_offer_graph( + &pool, + active_offer, + OfferStatus::Active, + ACTIVE_OFFER_HEIGHT, + ) + .await?; + + let (base_url, server_handle) = start_api(pool).await?; + Ok((base_url, server_handle, pending_offer, active_offer)) +} + +#[tokio::test] +#[serial] +async fn get_offers_returns_all_seeded_offers_with_correct_status() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let json = get_json(&http, format!("{base_url}/offers")).await?; + + assert_eq!(json.as_array().map_or(0, Vec::len), 2); + assert_ids_match_unordered(&json, &[pending_offer, active_offer]); + + // Pins `ORDER BY created_at_height DESC` (active_offer's height > pending's). + assert_eq!(json[0]["id"], active_offer.to_string()); + assert_eq!(json[0]["status"], "active"); + assert_eq!(json[1]["id"], pending_offer.to_string()); + assert_eq!(json[1]["status"], "pending"); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn get_offers_full_returns_borrower_pubkey_among_other_fields() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let json = get_json(&http, format!("{base_url}/offers/full")).await?; + + assert_eq!(json.as_array().map_or(0, Vec::len), 2); + assert_ids_match_unordered(&json, &[pending_offer, active_offer]); + assert!(json[0]["borrower_pubkey"].as_str().is_some()); + assert!(json[0]["borrower_output_script_hash"].as_str().is_some()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn get_offer_details_returns_offer_with_latest_participant() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, _active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let json = get_json(&http, format!("{base_url}/offers/{pending_offer}")).await?; + + assert_eq!(json["id"], pending_offer.to_string()); + assert_eq!(json["status"], "pending"); + assert_eq!(json["participants"].as_array().map_or(0, Vec::len), 1); + assert_eq!(json["participants"][0]["script_pubkey"], "52ac"); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn post_offers_batch_returns_requested_offers() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let response = post_json( + &http, + format!("{base_url}/offers/batch"), + serde_json::json!({ "ids": [pending_offer, active_offer] }), + ) + .await? + .error_for_status()?; + let json = response_json(response).await?; + + assert_eq!(json.as_array().map_or(0, Vec::len), 2); + assert_ids_match_unordered(&json, &[pending_offer, active_offer]); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn post_offers_batch_handles_empty_and_partial_ids() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, _active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let empty_response = post_json( + &http, + format!("{base_url}/offers/batch"), + serde_json::json!({ "ids": [] }), + ) + .await? + .error_for_status()?; + let empty = response_json(empty_response).await?; + assert_eq!(empty.as_array().map_or(0, Vec::len), 0); + + let partial_response = post_json( + &http, + format!("{base_url}/offers/batch"), + serde_json::json!({ "ids": [pending_offer, Uuid::new_v4()] }), + ) + .await? + .error_for_status()?; + let partial = response_json(partial_response).await?; + assert_eq!(partial.as_array().map_or(0, Vec::len), 1); + assert_eq!(partial[0]["id"], pending_offer.to_string()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn get_offers_by_script_returns_only_owners_of_unspent_match() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + // `52ac` = current unspent borrower script across both seeded offers; + // `51ac` = historical spent script, must not leak into the by-script lookup. + let current = get_json( + &http, + format!("{base_url}/offers/by-script?script_pubkey=52ac"), + ) + .await?; + assert_eq!(current.as_array().map_or(0, Vec::len), 2); + assert_uuid_values_match_unordered(¤t, &[pending_offer, active_offer]); + + let historical = get_json( + &http, + format!("{base_url}/offers/by-script?script_pubkey=51ac"), + ) + .await?; + assert_eq!(historical.as_array().map_or(0, Vec::len), 0); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn get_offers_by_borrower_pubkey_returns_pending_only() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, _active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let json = get_json( + &http, + format!("{base_url}/offers/by-borrower-pubkey?borrower_pubkey={FIXED_BORROWER_PUBKEY_HEX}"), + ) + .await?; + + assert_eq!(json.as_array().map_or(0, Vec::len), 1); + assert_eq!(json[0], pending_offer.to_string()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn get_latest_participants_returns_current_snapshot() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, _active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let json = get_json( + &http, + format!("{base_url}/offers/{pending_offer}/participants"), + ) + .await?; + + assert_eq!(json.as_array().map_or(0, Vec::len), 1); + assert_eq!(json[0]["script_pubkey"], "52ac"); + assert_eq!(json[0]["participant_type"], "borrower"); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn get_participants_history_returns_full_movement_history() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, _active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let json = get_json( + &http, + format!("{base_url}/offers/{pending_offer}/participants/history"), + ) + .await?; + + assert_eq!(json.as_array().map_or(0, Vec::len), 2); + assert_eq!(json[0]["script_pubkey"], "51ac"); + assert_eq!( + json[0]["spent_txid"], + "7777777777777777777777777777777777777777777777777777777777777777" + ); + assert_eq!(json[1]["script_pubkey"], "52ac"); + assert!(json[1]["spent_txid"].is_null()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn get_offer_utxos_returns_full_history_ordered_by_height() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, _active_offer) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let json = get_json(&http, format!("{base_url}/offers/{pending_offer}/utxos")).await?; + + assert_eq!(json.as_array().map_or(0, Vec::len), 2); + assert_eq!(json[0]["utxo_type"], "pre_lock"); + assert_eq!(json[0]["spent_at_height"], PENDING_OFFER_HEIGHT + 1); + assert_eq!(json[1]["utxo_type"], "lending"); + assert!(json[1]["spent_at_height"].is_null()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn offers_filters_apply_status_asset_pagination_and_order() -> anyhow::Result<()> { + let pool = test_pool().await?; + + let offer_a = Uuid::new_v4(); + let offer_b = Uuid::new_v4(); + let offer_c = Uuid::new_v4(); + let offer_d = Uuid::new_v4(); + + for (id, status, height, collat, princ) in [ + (offer_a, OfferStatus::Pending, 40, 0xaa_u8, 0x10_u8), + (offer_b, OfferStatus::Active, 60, 0xbb, 0xaa), + (offer_c, OfferStatus::Pending, 80, 0xcc, 0xdd), + (offer_d, OfferStatus::Pending, 70, 0xaa, 0xee), + ] { + let mut offer = offer_model(id, height, unique_32_bytes_from_uuid(id)); + offer.current_status = status; + offer.collateral_asset_id = vec![collat; 32]; + offer.principal_asset_id = vec![princ; 32]; + seed_offer_row(&pool, &offer).await?; + } + + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + + // status=pending -> 3 offers, ordered by height DESC: c(80) -> d(70) -> a(40). + let pending = get_json(&http, format!("{base_url}/offers?status=pending")).await?; + assert_eq!(pending.as_array().map_or(0, Vec::len), 3); + assert_eq!(pending[0]["id"], offer_c.to_string()); + assert_eq!(pending[1]["id"], offer_d.to_string()); + assert_eq!(pending[2]["id"], offer_a.to_string()); + + // `asset` matches either collateral_asset_id or principal_asset_id: + // a(collat=aa), b(princ=aa), d(collat=aa). + let asset_filter = "aa".repeat(32); + let by_asset = get_json(&http, format!("{base_url}/offers?asset={asset_filter}")).await?; + assert_eq!(by_asset.as_array().map_or(0, Vec::len), 3); + assert_eq!(by_asset[0]["id"], offer_d.to_string()); + assert_eq!(by_asset[1]["id"], offer_b.to_string()); + assert_eq!(by_asset[2]["id"], offer_a.to_string()); + + let paged = get_json(&http, format!("{base_url}/offers?limit=1&offset=1")).await?; + assert_eq!(paged.as_array().map_or(0, Vec::len), 1); + assert_eq!(paged[0]["id"], offer_d.to_string()); + + let full_pending = get_json( + &http, + format!("{base_url}/offers/full?status=pending&limit=1"), + ) + .await?; + assert_eq!(full_pending.as_array().map_or(0, Vec::len), 1); + assert_eq!(full_pending[0]["id"], offer_c.to_string()); + assert_eq!(full_pending[0]["status"], "pending"); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn history_endpoints_return_404_for_unknown_offer() -> anyhow::Result<()> { + let pool = test_pool().await?; + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + let unknown_offer_id = Uuid::new_v4(); + + for path in [ + format!("{base_url}/offers/{unknown_offer_id}/participants"), + format!("{base_url}/offers/{unknown_offer_id}/participants/history"), + format!("{base_url}/offers/{unknown_offer_id}/utxos"), + ] { + let response = http.get(path).send().await?; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = response_json(response).await?; + assert_eq!(body["error"]["code"], "not_found"); + } + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn query_endpoints_return_empty_arrays_when_no_matches() -> anyhow::Result<()> { + let pool = test_pool().await?; + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + + let by_script = get_json( + &http, + format!("{base_url}/offers/by-script?script_pubkey=52ac"), + ) + .await?; + assert_eq!(by_script.as_array().map_or(0, Vec::len), 0); + + let by_borrower = get_json( + &http, + format!("{base_url}/offers/by-borrower-pubkey?borrower_pubkey={FIXED_BORROWER_PUBKEY_HEX}"), + ) + .await?; + assert_eq!(by_borrower.as_array().map_or(0, Vec::len), 0); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn validation_errors_match_error_contract() -> anyhow::Result<()> { + let pool = test_pool().await?; + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + + let not_found = http + .get(format!("{base_url}/offers/{}", Uuid::new_v4())) + .send() + .await?; + assert_eq!(not_found.status(), StatusCode::NOT_FOUND); + assert_eq!( + response_json(not_found).await?["error"]["code"], + "not_found" + ); + + let invalid_script = http + .get(format!("{base_url}/offers/by-script?script_pubkey=zzzz")) + .send() + .await?; + assert_eq!(invalid_script.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(invalid_script).await?["error"]["code"], + "bad_request" + ); + + let invalid_borrower = http + .get(format!( + "{base_url}/offers/by-borrower-pubkey?borrower_pubkey=deadbeef" + )) + .send() + .await?; + assert_eq!(invalid_borrower.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(invalid_borrower).await?["error"]["code"], + "bad_request" + ); + + server_handle.abort(); + Ok(()) +} + +/// Regression: guards the "no sleep" assumption in `start_api`. The server +/// must accept a request immediately after the helper returns. +#[tokio::test] +#[serial] +async fn server_accepts_connection_immediately_after_start_api() -> anyhow::Result<()> { + let pool = test_pool().await?; + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + + let response = timeout( + Duration::from_secs(2), + http.get(format!("{base_url}/offers")).send(), + ) + .await??; + assert!(response.status().is_success()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn offers_endpoint_returns_400_on_invalid_status_enum() -> anyhow::Result<()> { + let pool = test_pool().await?; + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + + let response = http + .get(format!("{base_url}/offers?status=bogus")) + .send() + .await?; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "unknown `status` must be rejected by Query; if this \ + fails the endpoint is silently treating it as `None` -> regression" + ); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn offers_endpoint_returns_400_on_non_uuid_path() -> anyhow::Result<()> { + let pool = test_pool().await?; + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + + let response = http + .get(format!("{base_url}/offers/not-a-uuid")) + .send() + .await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn offers_batch_returns_400_on_malformed_body() -> anyhow::Result<()> { + let pool = test_pool().await?; + let (base_url, server_handle) = start_api(pool).await?; + let http = reqwest::Client::new(); + + // Pins: Axum's `Json` extractor splits failure modes: + // syntactically invalid JSON -> 400 + // missing/wrong-typed field -> 422 + // Pin both so a future custom rejection layer doesn't change the contract + // silently. + let garbage = http + .post(format!("{base_url}/offers/batch")) + .header("content-type", "application/json") + .body("{ not json }") + .send() + .await?; + assert_eq!(garbage.status(), StatusCode::BAD_REQUEST); + + let missing_ids = http + .post(format!("{base_url}/offers/batch")) + .header("content-type", "application/json") + .body("{}") + .send() + .await?; + assert_eq!(missing_ids.status(), StatusCode::UNPROCESSABLE_ENTITY); + + server_handle.abort(); + Ok(()) +} + +/// Intent: mirrors `OfferDetailsResponse` flattened into one struct, defined +/// locally on purpose. Decouples the test from serde renames on the +/// production DTO. +#[derive(serde::Deserialize, Debug)] +#[allow(dead_code)] +struct ExpectedOfferDetailsDto { + id: Uuid, + status: String, + collateral_asset: String, + principal_asset: String, + collateral_amount: u64, + principal_amount: u64, + interest_rate: u32, + loan_expiration_time: u32, + created_at_height: u64, + created_at_txid: String, + borrower_pubkey: String, + borrower_output_script_hash: String, + first_parameters_nft_asset: String, + second_parameters_nft_asset: String, + borrower_nft_asset: String, + lender_nft_asset: String, + participants: Vec, +} + +#[derive(serde::Deserialize, Debug)] +#[allow(dead_code)] +struct ExpectedParticipantDto { + offer_id: Uuid, + participant_type: String, + script_pubkey: String, + txid: String, + vout: u32, + created_at_height: u64, + spent_txid: Option, + spent_at_height: Option, +} + +#[tokio::test] +#[serial] +async fn offer_details_full_dto_shape() -> anyhow::Result<()> { + let (base_url, server_handle, pending_offer, _active) = setup_seeded_api().await?; + let http = reqwest::Client::new(); + + let raw = get_json(&http, format!("{base_url}/offers/{pending_offer}")).await?; + let dto: ExpectedOfferDetailsDto = + serde_json::from_value(raw.clone()).expect("response must match full DTO shape"); + + assert_eq!(dto.id, pending_offer); + assert_eq!(dto.status, "pending"); + assert_eq!(dto.collateral_amount, 1_000); + assert_eq!(dto.principal_amount, 500); + assert_eq!(dto.interest_rate, 120); + assert_eq!(dto.loan_expiration_time, 1_234_567); + assert_eq!(dto.created_at_height, PENDING_OFFER_HEIGHT as u64); + assert_eq!(dto.borrower_pubkey, FIXED_BORROWER_PUBKEY_HEX); + // 32-byte seeded values serialize as 64-char hex strings. + assert_eq!(dto.collateral_asset.len(), 64); + assert_eq!(dto.principal_asset.len(), 64); + assert_eq!(dto.first_parameters_nft_asset.len(), 64); + assert_eq!(dto.second_parameters_nft_asset.len(), 64); + assert_eq!(dto.borrower_nft_asset.len(), 64); + assert_eq!(dto.lender_nft_asset.len(), 64); + assert_eq!(dto.participants.len(), 1); + assert_eq!(dto.participants[0].script_pubkey, "52ac"); + assert_eq!(dto.participants[0].participant_type, "borrower"); + assert!(dto.participants[0].spent_txid.is_none()); + + server_handle.abort(); + Ok(()) +} diff --git a/crates/indexer/tests/common/mod.rs b/crates/indexer/tests/common/mod.rs new file mode 100644 index 0000000..29d1bc2 --- /dev/null +++ b/crates/indexer/tests/common/mod.rs @@ -0,0 +1,271 @@ +#![allow(dead_code)] + +use std::env; +use std::str::FromStr; + +use lending_indexer::indexer::{ + insert_offer, insert_offer_utxo, insert_participant_utxo, update_offer_status, +}; +use lending_indexer::models::{ + OfferModel, OfferParticipantModel, OfferStatus, OfferUtxoModel, ParticipantType, UtxoType, +}; +use simplex::simplicityhl::elements::{ + AssetId, LockTime, OutPoint, Script, Transaction, TxIn, TxOut, Txid, confidential, + hashes::Hash, secp256k1_zkp::XOnlyPublicKey, +}; +use sqlx::PgPool; +use uuid::Uuid; + +pub const FIXED_BORROWER_PUBKEY_HEX: &str = + "7c7db0528e8b7b58e698ac104764f6852d74b5a7335bffcdad0ce799dd7742ec"; + +pub fn fixed_borrower_pubkey_bytes() -> Vec { + XOnlyPublicKey::from_str(FIXED_BORROWER_PUBKEY_HEX) + .expect("valid xonly key") + .serialize() + .to_vec() +} + +/// Returns a ready-to-use pool with migrations applied and domain tables +/// truncated. Panics (instead of silent-skip) when `DATABASE_URL` is not +/// configured. Silent-skip would mask a completely empty test run in CI. +pub async fn test_pool() -> anyhow::Result { + let database_url = env::var("DATABASE_URL").expect( + "DATABASE_URL must be set for integration tests. \ + See crates/indexer/scripts/init_db.sh", + ); + + let pool = PgPool::connect(&database_url).await?; + sqlx::migrate!("./migrations").run(&pool).await?; + sqlx::query( + r#" + TRUNCATE TABLE + offer_participants, + offer_utxos, + offers, + sync_state + RESTART IDENTITY CASCADE + "#, + ) + .execute(&pool) + .await?; + + Ok(pool) +} + +/// Produces a deterministic 32-byte blob unique per UUID. Handy for +/// `created_at_txid` (which has a UNIQUE constraint) when seeding several +/// offers in the same test. +pub fn unique_32_bytes_from_uuid(id: Uuid) -> Vec { + let mut buf = [0_u8; 32]; + buf[..16].copy_from_slice(id.as_bytes()); + buf[16..].copy_from_slice(id.as_bytes()); + buf.to_vec() +} + +pub fn offer_model(id: Uuid, created_at_height: i64, created_at_txid: Vec) -> OfferModel { + OfferModel { + id, + borrower_pubkey: fixed_borrower_pubkey_bytes(), + borrower_output_script_hash: vec![2; 32], + collateral_asset_id: vec![3; 32], + principal_asset_id: vec![4; 32], + first_parameters_nft_asset_id: vec![5; 32], + second_parameters_nft_asset_id: vec![6; 32], + borrower_nft_asset_id: vec![7; 32], + lender_nft_asset_id: vec![8; 32], + collateral_amount: 1_000, + principal_amount: 500, + interest_rate: 120, + loan_expiration_time: 1_234_567, + current_status: OfferStatus::Pending, + created_at_height, + created_at_txid, + } +} + +pub async fn seed_offer_row(pool: &PgPool, offer: &OfferModel) -> anyhow::Result<()> { + let mut sql_tx = pool.begin().await?; + insert_offer(&mut sql_tx, offer).await?; + if !matches!(offer.current_status, OfferStatus::Pending) { + update_offer_status(&mut sql_tx, offer.id, offer.current_status).await?; + } + sql_tx.commit().await?; + Ok(()) +} + +pub async fn seed_offer_utxo_row(pool: &PgPool, utxo: &OfferUtxoModel) -> anyhow::Result<()> { + let mut sql_tx = pool.begin().await?; + insert_offer_utxo(&mut sql_tx, utxo).await?; + sql_tx.commit().await?; + Ok(()) +} + +pub async fn seed_participant_utxo_row( + pool: &PgPool, + participant: &OfferParticipantModel, +) -> anyhow::Result<()> { + let mut sql_tx = pool.begin().await?; + insert_participant_utxo(&mut sql_tx, participant).await?; + sql_tx.commit().await?; + Ok(()) +} + +pub fn unspent_offer_utxo( + offer_id: Uuid, + outpoint: OutPoint, + utxo_type: UtxoType, + created_at_height: i64, +) -> OfferUtxoModel { + OfferUtxoModel { + offer_id, + txid: outpoint.txid.as_byte_array().to_vec(), + vout: outpoint.vout as i32, + utxo_type, + created_at_height, + spent_txid: None, + spent_at_height: None, + } +} + +pub fn spent_offer_utxo( + offer_id: Uuid, + outpoint: OutPoint, + utxo_type: UtxoType, + created_at_height: i64, + spent_at_height: i64, + spent_txid_byte: u8, +) -> OfferUtxoModel { + OfferUtxoModel { + offer_id, + txid: outpoint.txid.as_byte_array().to_vec(), + vout: outpoint.vout as i32, + utxo_type, + created_at_height, + spent_txid: Some(vec![spent_txid_byte; 32]), + spent_at_height: Some(spent_at_height), + } +} + +pub fn unspent_participant( + offer_id: Uuid, + participant_type: ParticipantType, + outpoint: OutPoint, + script_pubkey: Vec, + created_at_height: i64, +) -> OfferParticipantModel { + OfferParticipantModel { + offer_id, + participant_type, + script_pubkey, + txid: outpoint.txid.as_byte_array().to_vec(), + vout: outpoint.vout as i32, + created_at_height, + spent_txid: None, + spent_at_height: None, + } +} + +pub fn spent_participant( + offer_id: Uuid, + participant_type: ParticipantType, + outpoint: OutPoint, + script_pubkey: Vec, + created_at_height: i64, + spent_at_height: i64, + spent_txid_byte: u8, +) -> OfferParticipantModel { + OfferParticipantModel { + offer_id, + participant_type, + script_pubkey, + txid: outpoint.txid.as_byte_array().to_vec(), + vout: outpoint.vout as i32, + created_at_height, + spent_txid: Some(vec![spent_txid_byte; 32]), + spent_at_height: Some(spent_at_height), + } +} + +pub fn outpoint_with_txid_byte(txid_byte: u8, vout: u32) -> OutPoint { + OutPoint { + txid: Txid::from_slice(&[txid_byte; 32]).expect("valid txid bytes"), + vout, + } +} + +pub fn outpoint_from_uuid_vout(id: Uuid, vout: u32) -> OutPoint { + let mut txid_bytes = [0_u8; 32]; + txid_bytes[..16].copy_from_slice(id.as_bytes()); + txid_bytes[16..].copy_from_slice(id.as_bytes()); + // Perturb first byte so UTXO txid differs from offer's created_at_txid. + txid_bytes[0] ^= 0x5a; + OutPoint { + txid: Txid::from_slice(&txid_bytes).expect("valid txid bytes"), + vout, + } +} + +pub fn normal_output() -> TxOut { + TxOut::default() +} + +pub fn null_data_output() -> TxOut { + TxOut { + script_pubkey: Script::new_op_return(b"burn"), + ..Default::default() + } +} + +/// Concrete non-empty, non-OP_RETURN script. `Script::default()` is empty +/// and looks like a malformed output in participant-movement assertions. +pub fn non_op_return_script() -> Script { + Script::from(vec![0x51_u8]) // OP_1 +} + +pub fn tx_with_input(spent: OutPoint, outputs: Vec) -> Transaction { + Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: outputs, + } +} + +pub fn tx_with_inputs(spent: Vec, outputs: Vec) -> Transaction { + Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: spent + .into_iter() + .map(|previous_output| TxIn { + previous_output, + ..Default::default() + }) + .collect(), + output: outputs, + } +} + +pub fn padded_tx_with_inputs(known_inputs: Vec, outputs: Vec) -> Transaction { + let mut inputs = known_inputs; + let mut vout_counter = 1_000_u32; + while inputs.len() < 7 { + inputs.push(outpoint_with_txid_byte(0xff, vout_counter)); + vout_counter += 1; + } + tx_with_inputs(inputs, outputs) +} + +pub fn explicit_asset_output(asset_byte: u8, script_pubkey: Script) -> TxOut { + let mut output = TxOut { + script_pubkey, + ..Default::default() + }; + let asset_id = AssetId::from_slice(&[asset_byte; 32]).expect("valid asset id"); + output.asset = confidential::Asset::Explicit(asset_id); + output +} diff --git a/crates/indexer/tests/indexer_integration.rs b/crates/indexer/tests/indexer_integration.rs new file mode 100644 index 0000000..dc6f830 --- /dev/null +++ b/crates/indexer/tests/indexer_integration.rs @@ -0,0 +1,1555 @@ +mod common; + +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +use std::str::FromStr; + +use axum::{ + Router, + body::Body, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::get, +}; +use lending_contracts::{programs::PreLockParameters, utils::LendingOfferParameters}; +use lending_indexer::esplora_client::EsploraClient; +use lending_indexer::indexer::{ + UtxoCache, get_last_indexed_height, handle_pre_lock_creation, load_utxo_cache, process_block, + process_tx, upsert_sync_state, +}; +use lending_indexer::models::{ + ActiveUtxo, OfferStatus, OfferUtxoModel, ParticipantType, UtxoData, UtxoType, +}; +use serial_test::serial; +use simplex::provider::SimplicityNetwork; +use simplex::simplicityhl::elements::{ + AssetId, OutPoint, Script, TxOut, Txid, encode, hashes::Hash, secp256k1_zkp::XOnlyPublicKey, +}; +use sqlx::{PgPool, Row}; +use tokio::net::TcpListener; +use uuid::Uuid; + +use crate::common::{ + FIXED_BORROWER_PUBKEY_HEX, explicit_asset_output, non_op_return_script, normal_output, + null_data_output, offer_model, outpoint_with_txid_byte, padded_tx_with_inputs, seed_offer_row, + seed_offer_utxo_row, seed_participant_utxo_row, spent_offer_utxo, spent_participant, test_pool, + tx_with_input, unspent_offer_utxo, unspent_participant, +}; + +#[derive(Clone)] +struct MockEsploraState { + block_hash: String, + txids: Vec, + tx_bytes_by_id: HashMap>, +} + +async fn start_mock_esplora( + state: MockEsploraState, +) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { + async fn get_block_hash( + State(state): State>, + Path(_height): Path, + ) -> impl IntoResponse { + state.block_hash.clone() + } + + async fn get_block_txids( + State(state): State>, + Path(_hash): Path, + ) -> impl IntoResponse { + axum::Json(state.txids.clone()) + } + + async fn get_raw_tx( + State(state): State>, + Path(txid): Path, + ) -> impl IntoResponse { + match state.tx_bytes_by_id.get(&txid) { + Some(bytes) => ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], + Body::from(bytes.clone()), + ) + .into_response(), + None => (StatusCode::NOT_FOUND, "not found").into_response(), + } + } + + let app = Router::new() + .route("/block-height/{height}", get(get_block_hash)) + .route("/block/{hash}/txids", get(get_block_txids)) + .route("/tx/{txid}/raw", get(get_raw_tx)) + .with_state(Arc::new(state)); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn seed_offer_with_pre_lock( + pool: &PgPool, + offer_id: Uuid, + outpoint: OutPoint, + created_at_height: i64, +) -> anyhow::Result<()> { + // Mirrors production: `handle_pre_lock_creation` stores the pre-lock + // txid as `created_at_txid`. + let mut offer = offer_model( + offer_id, + created_at_height, + outpoint.txid.as_byte_array().to_vec(), + ); + offer.current_status = lending_indexer::models::OfferStatus::Pending; + seed_offer_row(pool, &offer).await?; + + let pre_lock = unspent_offer_utxo(offer_id, outpoint, UtxoType::PreLock, created_at_height); + seed_offer_utxo_row(pool, &pre_lock).await?; + + Ok(()) +} + +async fn offer_utxo_type_spent_set( + pool: &PgPool, + offer_id: Uuid, +) -> anyhow::Result> { + let rows = sqlx::query( + "SELECT utxo_type::text AS utxo_type, spent_txid IS NOT NULL AS is_spent \ + FROM offer_utxos WHERE offer_id = $1", + ) + .bind(offer_id) + .fetch_all(pool) + .await?; + + let mut set = HashSet::new(); + for row in rows { + let utxo_type: String = row.get("utxo_type"); + let is_spent: bool = row.get("is_spent"); + set.insert((utxo_type, is_spent)); + } + Ok(set) +} + +async fn count_offer_utxos( + pool: &PgPool, + offer_id: Uuid, + utxo_type: &str, + spent: Option, +) -> anyhow::Result { + let query_text = match spent { + Some(true) => { + "SELECT COUNT(*)::BIGINT AS c FROM offer_utxos \ + WHERE offer_id = $1 AND utxo_type::text = $2 AND spent_txid IS NOT NULL" + } + Some(false) => { + "SELECT COUNT(*)::BIGINT AS c FROM offer_utxos \ + WHERE offer_id = $1 AND utxo_type::text = $2 AND spent_txid IS NULL" + } + None => { + "SELECT COUNT(*)::BIGINT AS c FROM offer_utxos \ + WHERE offer_id = $1 AND utxo_type::text = $2" + } + }; + let row = sqlx::query(query_text) + .bind(offer_id) + .bind(utxo_type) + .fetch_one(pool) + .await?; + Ok(row.get::("c")) +} + +async fn count_participants( + pool: &PgPool, + offer_id: Uuid, + participant_type: &str, + spent: Option, +) -> anyhow::Result { + let query_text = match spent { + Some(true) => { + "SELECT COUNT(*)::BIGINT AS c FROM offer_participants \ + WHERE offer_id = $1 AND participant_type::text = $2 AND spent_txid IS NOT NULL" + } + Some(false) => { + "SELECT COUNT(*)::BIGINT AS c FROM offer_participants \ + WHERE offer_id = $1 AND participant_type::text = $2 AND spent_txid IS NULL" + } + None => { + "SELECT COUNT(*)::BIGINT AS c FROM offer_participants \ + WHERE offer_id = $1 AND participant_type::text = $2" + } + }; + let row = sqlx::query(query_text) + .bind(offer_id) + .bind(participant_type) + .fetch_one(pool) + .await?; + Ok(row.get::("c")) +} + +async fn current_status(pool: &PgPool, offer_id: Uuid) -> anyhow::Result { + let row = sqlx::query("SELECT current_status::text AS s FROM offers WHERE id = $1") + .bind(offer_id) + .fetch_one(pool) + .await?; + Ok(row.get::("s")) +} + +async fn sync_state_row_count(pool: &PgPool) -> anyhow::Result { + let row = sqlx::query("SELECT COUNT(*)::BIGINT AS c FROM sync_state") + .fetch_one(pool) + .await?; + Ok(row.get::("c")) +} + +#[tokio::test] +#[serial] +async fn process_tx_full_repay_then_claim_lifecycle() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(11, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 100).await?; + cache.insert( + pre_lock_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + // Dispatch: all outputs non-null-data -> lending path (not cancellation). + // Pad to 7 inputs so the tx matches the shape of a real lending-creation + // spend even if the dispatcher later adds an input-count guard. + let lending_tx = padded_tx_with_inputs(vec![pre_lock_outpoint], vec![normal_output(); 5]); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &lending_tx, &mut cache, &client, 101).await?; + sql_tx.commit().await?; + } + + // Dispatch: output[1] non-null + [2, 3, 4] null-data -> repayment path. + let lending_outpoint = OutPoint { + txid: lending_tx.txid(), + vout: 0, + }; + let repayment_tx = tx_with_input( + lending_outpoint, + vec![ + normal_output(), + normal_output(), + null_data_output(), + null_data_output(), + null_data_output(), + ], + ); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &repayment_tx, &mut cache, &client, 102).await?; + sql_tx.commit().await?; + } + + let repayment_outpoint = OutPoint { + txid: repayment_tx.txid(), + vout: 1, + }; + let claim_tx = tx_with_input(repayment_outpoint, vec![normal_output(), normal_output()]); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &claim_tx, &mut cache, &client, 103).await?; + sql_tx.commit().await?; + } + + assert_eq!(current_status(&pool, offer_id).await?, "claimed"); + + let utxos = offer_utxo_type_spent_set(&pool, offer_id).await?; + let expected: HashSet<(String, bool)> = [ + ("pre_lock".to_string(), true), + ("lending".to_string(), true), + ("repayment".to_string(), true), + ("claim".to_string(), true), + ] + .into_iter() + .collect(); + assert_eq!(utxos, expected); + + assert!(cache.get(&pre_lock_outpoint).is_none()); + assert!(cache.get(&lending_outpoint).is_none()); + assert!(cache.get(&repayment_outpoint).is_none()); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_tx_liquidation_updates_offer_and_archives_utxo() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(22, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 200).await?; + cache.insert( + pre_lock_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + let lending_tx = padded_tx_with_inputs(vec![pre_lock_outpoint], vec![normal_output(); 5]); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &lending_tx, &mut cache, &client, 201).await?; + sql_tx.commit().await?; + } + + // Dispatch: outputs [1, 2, 3] null-data, [4] non-null -> liquidation path. + let lending_outpoint = OutPoint { + txid: lending_tx.txid(), + vout: 0, + }; + let liquidation_tx = tx_with_input( + lending_outpoint, + vec![ + normal_output(), + null_data_output(), + null_data_output(), + null_data_output(), + normal_output(), + ], + ); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &liquidation_tx, &mut cache, &client, 202).await?; + sql_tx.commit().await?; + } + + assert_eq!(current_status(&pool, offer_id).await?, "liquidated"); + // Pins: liquidation handler inserts the post-liquidation utxo as already + // spent (audit trail stays, cache drops it on restart). + assert_eq!( + count_offer_utxos(&pool, offer_id, "repayment", Some(true)).await?, + 1 + ); + assert_eq!( + count_offer_utxos(&pool, offer_id, "repayment", Some(false)).await?, + 0 + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_tx_prelock_to_cancellation_sets_status_and_archives() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(55, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 400).await?; + cache.insert( + pre_lock_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + // Dispatch: all non-coin outputs null-data -> cancellation path. + let cancellation_tx = tx_with_input( + pre_lock_outpoint, + vec![ + normal_output(), + null_data_output(), + null_data_output(), + null_data_output(), + null_data_output(), + ], + ); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &cancellation_tx, &mut cache, &client, 401).await?; + sql_tx.commit().await?; + } + + assert_eq!(current_status(&pool, offer_id).await?, "cancelled"); + assert_eq!( + count_offer_utxos(&pool, offer_id, "cancellation", Some(true)).await?, + 1 + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn participant_movement_updates_history_and_handles_burn() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(66, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 500).await?; + + let borrower_outpoint = outpoint_with_txid_byte(67, 1); + seed_participant_utxo_row( + &pool, + &unspent_participant( + offer_id, + ParticipantType::Borrower, + borrower_outpoint, + vec![0x51, 0xac], + 501, + ), + ) + .await?; + cache.insert( + borrower_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Participant(ParticipantType::Borrower), + }, + ); + + let move_tx = tx_with_input( + borrower_outpoint, + vec![explicit_asset_output(7, non_op_return_script())], + ); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &move_tx, &mut cache, &client, 502).await?; + sql_tx.commit().await?; + } + + let new_borrower_outpoint = OutPoint { + txid: move_tx.txid(), + vout: 0, + }; + assert!(cache.get(&borrower_outpoint).is_none()); + assert!(cache.get(&new_borrower_outpoint).is_some()); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(false)).await?, + 1 + ); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(true)).await?, + 1 + ); + + // Pins: burn via OP_RETURN marks the old row spent and does NOT insert + // a new participant row. + let burn_tx = tx_with_input( + new_borrower_outpoint, + vec![explicit_asset_output(7, Script::new_op_return(b"burn"))], + ); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &burn_tx, &mut cache, &client, 503).await?; + sql_tx.commit().await?; + } + + assert!(cache.get(&new_borrower_outpoint).is_none()); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(false)).await?, + 0 + ); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(true)).await?, + 2 + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn participant_move_without_target_asset_marks_spent_without_new_utxo() -> anyhow::Result<()> +{ + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(74, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 530).await?; + + let borrower_outpoint = outpoint_with_txid_byte(75, 1); + seed_participant_utxo_row( + &pool, + &unspent_participant( + offer_id, + ParticipantType::Borrower, + borrower_outpoint, + vec![0x51, 0xac], + 531, + ), + ) + .await?; + cache.insert( + borrower_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Participant(ParticipantType::Borrower), + }, + ); + + // Output asset byte = 9, but the seeded `borrower_nft_asset_id` is + // `[7; 32]` -> handler sees no matching output for the NFT. + let move_without_target_asset_tx = tx_with_input( + borrower_outpoint, + vec![explicit_asset_output(9, non_op_return_script())], + ); + { + let mut sql_tx = pool.begin().await?; + process_tx( + &mut sql_tx, + &move_without_target_asset_tx, + &mut cache, + &client, + 532, + ) + .await?; + sql_tx.commit().await?; + } + + assert!(cache.get(&borrower_outpoint).is_none()); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(false)).await?, + 0 + ); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(true)).await?, + 1 + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn single_tx_with_multiple_known_inputs_applies_all_transitions() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(72, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 520).await?; + cache.insert( + pre_lock_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + let borrower_outpoint = outpoint_with_txid_byte(73, 1); + seed_participant_utxo_row( + &pool, + &unspent_participant( + offer_id, + ParticipantType::Borrower, + borrower_outpoint, + vec![0x51, 0xac], + 521, + ), + ) + .await?; + cache.insert( + borrower_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Participant(ParticipantType::Borrower), + }, + ); + + // vout 0 becomes the new Lending UTXO, vout 1 carries the moved + // borrower NFT (asset byte 7 matches seeded `borrower_nft_asset_id`). + // Pad to 7 inputs so the pre-lock -> lending dispatch sees a valid shape. + let combined_tx = padded_tx_with_inputs( + vec![pre_lock_outpoint, borrower_outpoint], + vec![ + normal_output(), + explicit_asset_output(7, non_op_return_script()), + normal_output(), + normal_output(), + normal_output(), + ], + ); + + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &combined_tx, &mut cache, &client, 522).await?; + sql_tx.commit().await?; + + assert_eq!(current_status(&pool, offer_id).await?, "active"); + + let lending_outpoint = OutPoint { + txid: combined_tx.txid(), + vout: 0, + }; + let moved_borrower_outpoint = OutPoint { + txid: combined_tx.txid(), + vout: 1, + }; + assert!(cache.get(&pre_lock_outpoint).is_none()); + assert!(cache.get(&borrower_outpoint).is_none()); + assert!(cache.get(&lending_outpoint).is_some()); + assert!(cache.get(&moved_borrower_outpoint).is_some()); + + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(false)).await?, + 1 + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_rolls_back_db_and_cache_when_later_tx_fails() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + // Valid offer whose pre-lock the first tx of the block will consume. + let valid_offer_id = Uuid::new_v4(); + let valid_prelock_outpoint = outpoint_with_txid_byte(33, 0); + seed_offer_with_pre_lock(&pool, valid_offer_id, valid_prelock_outpoint, 300).await?; + cache.insert( + valid_prelock_outpoint, + ActiveUtxo { + offer_id: valid_offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + // Cached participant pointing at an offer_id that does NOT exist in the + // DB -> participant handler hits `RowNotFound` and aborts the block. + let missing_offer_id = Uuid::new_v4(); + let missing_participant_outpoint = outpoint_with_txid_byte(44, 1); + cache.insert( + missing_participant_outpoint, + ActiveUtxo { + offer_id: missing_offer_id, + data: UtxoData::Participant(ParticipantType::Borrower), + }, + ); + + let good_tx = padded_tx_with_inputs(vec![valid_prelock_outpoint], vec![normal_output(); 5]); + let bad_tx = tx_with_input(missing_participant_outpoint, vec![normal_output()]); + + let mut tx_bytes_by_id = HashMap::new(); + tx_bytes_by_id.insert(good_tx.txid().to_string(), encode::serialize(&good_tx)); + tx_bytes_by_id.insert(bad_tx.txid().to_string(), encode::serialize(&bad_tx)); + + let (base_url, server_handle) = start_mock_esplora(MockEsploraState { + block_hash: "integration-block-hash-1".to_string(), + txids: vec![good_tx.txid().to_string(), bad_tx.txid().to_string()], + tx_bytes_by_id, + }) + .await?; + let client = EsploraClient::with_base_url(&base_url); + + let result = process_block(&pool, &client, &mut cache, 301).await; + assert!(result.is_err()); + + assert_eq!(current_status(&pool, valid_offer_id).await?, "pending"); + assert_eq!( + count_offer_utxos(&pool, valid_offer_id, "pre_lock", Some(false)).await?, + 1 + ); + assert_eq!( + count_offer_utxos(&pool, valid_offer_id, "lending", None).await?, + 0 + ); + assert_eq!(sync_state_row_count(&pool).await?, 0); + + // Cache rolled back: originals intact, optimistic insert from good_tx gone. + assert!(cache.get(&valid_prelock_outpoint).is_some()); + assert!(cache.get(&missing_participant_outpoint).is_some()); + let rolled_back_lending_outpoint = OutPoint { + txid: good_tx.txid(), + vout: 0, + }; + assert!(cache.get(&rolled_back_lending_outpoint).is_none()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_successfully_commits_sync_state_and_cache() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(70, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 510).await?; + cache.insert( + pre_lock_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + let borrower_outpoint = outpoint_with_txid_byte(71, 1); + seed_participant_utxo_row( + &pool, + &unspent_participant( + offer_id, + ParticipantType::Borrower, + borrower_outpoint, + vec![0x51, 0xac], + 511, + ), + ) + .await?; + cache.insert( + borrower_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Participant(ParticipantType::Borrower), + }, + ); + + let lending_tx = padded_tx_with_inputs(vec![pre_lock_outpoint], vec![normal_output(); 5]); + let move_tx = tx_with_input( + borrower_outpoint, + vec![explicit_asset_output(7, non_op_return_script())], + ); + + let mut tx_bytes_by_id = HashMap::new(); + tx_bytes_by_id.insert( + lending_tx.txid().to_string(), + encode::serialize(&lending_tx), + ); + tx_bytes_by_id.insert(move_tx.txid().to_string(), encode::serialize(&move_tx)); + let block_hash = "integration-block-hash-success".to_string(); + + let (base_url, server_handle) = start_mock_esplora(MockEsploraState { + block_hash: block_hash.clone(), + txids: vec![lending_tx.txid().to_string(), move_tx.txid().to_string()], + tx_bytes_by_id, + }) + .await?; + let client = EsploraClient::with_base_url(&base_url); + + process_block(&pool, &client, &mut cache, 512).await?; + + let sync = + sqlx::query("SELECT last_indexed_height, last_indexed_hash FROM sync_state WHERE id = 1") + .fetch_one(&pool) + .await?; + assert_eq!(sync.get::("last_indexed_height"), 512); + assert_eq!(sync.get::("last_indexed_hash"), block_hash); + + let lending_outpoint = OutPoint { + txid: lending_tx.txid(), + vout: 0, + }; + let moved_borrower_outpoint = OutPoint { + txid: move_tx.txid(), + vout: 0, + }; + assert!(cache.get(&pre_lock_outpoint).is_none()); + assert!(cache.get(&borrower_outpoint).is_none()); + assert!(cache.get(&lending_outpoint).is_some()); + assert!(cache.get(&moved_borrower_outpoint).is_some()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn restart_helpers_restore_height_and_only_unspent_cache_entries() -> anyhow::Result<()> { + let pool = test_pool().await?; + + // Pins: fallback-to-config height when sync_state is empty. + let fallback_height = get_last_indexed_height(&pool, 999).await?; + assert_eq!(fallback_height, 999); + + let mut sql_tx = pool.begin().await?; + upsert_sync_state(&mut sql_tx, 777, "hash-777".to_string()).await?; + sql_tx.commit().await?; + assert_eq!(get_last_indexed_height(&pool, 999).await?, 777); + + // Pins `load_utxo_cache`'s `WHERE spent_txid IS NULL` invariant. + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(88, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 600).await?; + + let spent_lending_outpoint = outpoint_with_txid_byte(90, 1); + seed_offer_utxo_row( + &pool, + &spent_offer_utxo( + offer_id, + spent_lending_outpoint, + UtxoType::Lending, + 601, + 602, + 0xab, + ), + ) + .await?; + + let unspent_lender_outpoint = outpoint_with_txid_byte(89, 2); + seed_participant_utxo_row( + &pool, + &unspent_participant( + offer_id, + ParticipantType::Lender, + unspent_lender_outpoint, + vec![0x52, 0xac], + 601, + ), + ) + .await?; + + let spent_borrower_outpoint = outpoint_with_txid_byte(91, 3); + seed_participant_utxo_row( + &pool, + &spent_participant( + offer_id, + ParticipantType::Borrower, + spent_borrower_outpoint, + vec![0x51, 0xac], + 600, + 601, + 0xcd, + ), + ) + .await?; + + let restored_cache = load_utxo_cache(&pool).await?; + assert!(restored_cache.get(&pre_lock_outpoint).is_some()); + assert!(restored_cache.get(&unspent_lender_outpoint).is_some()); + assert!(restored_cache.get(&spent_lending_outpoint).is_none()); + assert!(restored_cache.get(&spent_borrower_outpoint).is_none()); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_returns_error_on_invalid_esplora_tx_payload() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let bogus_txid = Txid::from_slice(&[99; 32])?; + let (base_url, server_handle) = start_mock_esplora(MockEsploraState { + block_hash: "integration-block-hash-invalid".to_string(), + txids: vec![bogus_txid.to_string()], + tx_bytes_by_id: HashMap::from([(bogus_txid.to_string(), vec![0x01, 0x02, 0x03])]), + }) + .await?; + let client = EsploraClient::with_base_url(&base_url); + + let result = process_block(&pool, &client, &mut cache, 700).await; + assert!(result.is_err()); + assert_eq!(sync_state_row_count(&pool).await?, 0); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_returns_error_on_esplora_http_500() -> anyhow::Result<()> { + async fn block_height_500() -> impl IntoResponse { + (StatusCode::INTERNAL_SERVER_ERROR, "boom") + } + + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let app = Router::new().route("/block-height/{height}", get(block_height_500)); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let server_handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let client = EsploraClient::with_base_url(&format!("http://{addr}")); + let result = process_block(&pool, &client, &mut cache, 900).await; + assert!(result.is_err()); + assert_eq!(sync_state_row_count(&pool).await?, 0); + + server_handle.abort(); + Ok(()) +} + +// Intent: these tests drive `handle_pre_lock_creation` directly with +// synthesized parameters. Going through `is_pre_lock_creation_tx` would +// require a real Simplex PreLock script in output[0] and a provider capable +// of fetching the collateral tx, which is out of scope for DB-level +// integration tests. The gatekeeper is covered by its own unit tests; +// everything after it (DB rows + cache inserts) is exercised here. + +fn synthesized_pre_lock_parameters() -> PreLockParameters { + PreLockParameters { + collateral_asset_id: AssetId::from_slice(&[0xc0_u8; 32]).expect("asset"), + principal_asset_id: AssetId::from_slice(&[0xd1_u8; 32]).expect("asset"), + first_parameters_nft_asset_id: AssetId::from_slice(&[0xf1_u8; 32]).expect("asset"), + second_parameters_nft_asset_id: AssetId::from_slice(&[0xf2_u8; 32]).expect("asset"), + borrower_nft_asset_id: AssetId::from_slice(&[0xbb_u8; 32]).expect("asset"), + lender_nft_asset_id: AssetId::from_slice(&[0x1e_u8; 32]).expect("asset"), + offer_parameters: LendingOfferParameters { + collateral_amount: 1_000, + principal_amount: 500, + loan_expiration_time: 12_345, + principal_interest_rate: 250, + }, + borrower_pubkey: XOnlyPublicKey::from_str(FIXED_BORROWER_PUBKEY_HEX) + .expect("valid xonly key"), + borrower_output_script_hash: [0x9a_u8; 32], + network: SimplicityNetwork::LiquidTestnet, + } +} + +/// Pins: handler contract requires >= 7 outputs, reads the borrower script +/// from vout 3 and the lender script from vout 4. +fn pre_lock_shaped_tx( + input_outpoint: OutPoint, + borrower_script: Script, + lender_script: Script, +) -> simplex::simplicityhl::elements::Transaction { + let make_with_script = |script_pubkey: Script| TxOut { + script_pubkey, + ..Default::default() + }; + tx_with_input( + input_outpoint, + vec![ + normal_output(), + normal_output(), + normal_output(), + make_with_script(borrower_script), + make_with_script(lender_script), + normal_output(), + normal_output(), + ], + ) +} + +#[tokio::test] +#[serial] +async fn process_tx_pre_lock_creation_inserts_offer_and_participants() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let params = synthesized_pre_lock_parameters(); + let borrower_script = Script::from(vec![0xaa_u8, 0xbb]); + let lender_script = Script::from(vec![0xcc_u8, 0xdd]); + let tx = pre_lock_shaped_tx( + outpoint_with_txid_byte(0x10, 0), + borrower_script.clone(), + lender_script.clone(), + ); + let txid = tx.txid(); + + { + let mut sql_tx = pool.begin().await?; + handle_pre_lock_creation(&mut sql_tx, &mut cache, params, &tx, 1_000).await?; + sql_tx.commit().await?; + } + + let offer_row = sqlx::query( + "SELECT id, current_status::text AS s, created_at_txid \ + FROM offers", + ) + .fetch_all(&pool) + .await?; + assert_eq!(offer_row.len(), 1, "exactly one offer row expected"); + let offer_id: Uuid = offer_row[0].get("id"); + assert_eq!(offer_row[0].get::("s"), "pending"); + assert_eq!( + offer_row[0].get::, _>("created_at_txid"), + txid.as_byte_array().to_vec() + ); + + let pre_lock_rows = sqlx::query( + "SELECT vout, utxo_type::text AS t, spent_txid FROM offer_utxos WHERE offer_id = $1", + ) + .bind(offer_id) + .fetch_all(&pool) + .await?; + assert_eq!(pre_lock_rows.len(), 1); + assert_eq!(pre_lock_rows[0].get::("vout"), 0); + assert_eq!(pre_lock_rows[0].get::("t"), "pre_lock"); + assert!( + pre_lock_rows[0] + .get::>, _>("spent_txid") + .is_none(), + "pre-lock UTXO must be unspent" + ); + + let participants = sqlx::query( + "SELECT participant_type::text AS pt, vout, script_pubkey, spent_txid \ + FROM offer_participants WHERE offer_id = $1 ORDER BY vout", + ) + .bind(offer_id) + .fetch_all(&pool) + .await?; + assert_eq!(participants.len(), 2); + assert_eq!(participants[0].get::("pt"), "borrower"); + assert_eq!(participants[0].get::("vout"), 3); + assert_eq!( + participants[0].get::, _>("script_pubkey"), + borrower_script.to_bytes().to_vec() + ); + assert!( + participants[0] + .get::>, _>("spent_txid") + .is_none() + ); + assert_eq!(participants[1].get::("pt"), "lender"); + assert_eq!(participants[1].get::("vout"), 4); + assert_eq!( + participants[1].get::, _>("script_pubkey"), + lender_script.to_bytes().to_vec() + ); + + let pre_lock_op = OutPoint { txid, vout: 0 }; + let borrower_op = OutPoint { txid, vout: 3 }; + let lender_op = OutPoint { txid, vout: 4 }; + assert!(cache.get(&pre_lock_op).is_some(), "pre-lock must be cached"); + assert!( + cache.get(&borrower_op).is_some(), + "borrower NFT must be cached" + ); + assert!(cache.get(&lender_op).is_some(), "lender NFT must be cached"); + + Ok(()) +} + +/// Regression: re-processing the same pre-lock transaction must be a no-op +/// (replay, at-least-once delivery, crash-resume). The handler detects the +/// duplicate via `insert_offer`'s `ON CONFLICT (created_at_txid)` short +/// circuit and bails out before touching `offer_utxos` / `offer_participants`. +#[tokio::test] +#[serial] +async fn handle_pre_lock_creation_is_idempotent_on_replay() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let params = synthesized_pre_lock_parameters(); + let tx = pre_lock_shaped_tx( + outpoint_with_txid_byte(0x20, 0), + Script::from(vec![0x51]), + Script::from(vec![0x52]), + ); + + { + let mut sql_tx = pool.begin().await?; + handle_pre_lock_creation(&mut sql_tx, &mut cache, params, &tx, 2_000).await?; + sql_tx.commit().await?; + } + + { + let mut sql_tx = pool.begin().await?; + handle_pre_lock_creation(&mut sql_tx, &mut cache, params, &tx, 2_000).await?; + sql_tx.commit().await?; + } + + let offers = sqlx::query("SELECT COUNT(*)::BIGINT AS c FROM offers") + .fetch_one(&pool) + .await?; + assert_eq!(offers.get::("c"), 1); + + let pre_lock_utxos = sqlx::query( + "SELECT COUNT(*)::BIGINT AS c FROM offer_utxos WHERE utxo_type::text = 'pre_lock'", + ) + .fetch_one(&pool) + .await?; + assert_eq!(pre_lock_utxos.get::("c"), 1); + + let participants = sqlx::query("SELECT COUNT(*)::BIGINT AS c FROM offer_participants") + .fetch_one(&pool) + .await?; + assert_eq!(participants.get::("c"), 2); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn handle_pre_lock_creation_with_malformed_outputs_returns_error() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let params = synthesized_pre_lock_parameters(); + let malformed_tx = tx_with_input( + outpoint_with_txid_byte(0x30, 0), + vec![normal_output(); 6], // < 7 outputs triggers the guard clause + ); + + let mut sql_tx = pool.begin().await?; + let error = handle_pre_lock_creation(&mut sql_tx, &mut cache, params, &malformed_tx, 3_000) + .await + .expect_err("handler must reject tx with < 7 outputs"); + sql_tx.rollback().await?; + + let message = error.to_string(); + assert!( + message.contains("Malformed PreLock transaction"), + "unexpected error message: {message}" + ); + + Ok(()) +} + +/// Pins: a UTXO created by tx1 must be visible to tx2 via `cache.get` +/// BEFORE `commit_block` is called. We use lending_creation -> repayment +/// within one block instead of pre-lock -> lending because the latter +/// needs the full Simplex contract machinery (see module comment above). +#[tokio::test] +#[serial] +async fn same_block_create_and_spend_routes_through_pending_cache() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let offer_id = Uuid::new_v4(); + let pre_lock_outpoint = outpoint_with_txid_byte(0x40, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 4_000).await?; + cache.insert( + pre_lock_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + let lending_tx = padded_tx_with_inputs(vec![pre_lock_outpoint], vec![normal_output(); 5]); + let lending_outpoint = OutPoint { + txid: lending_tx.txid(), + vout: 0, + }; + + // tx2 must see `lending_outpoint` via the pending-ops map; `commit_block` + // has not run yet. + let repayment_tx = tx_with_input( + lending_outpoint, + vec![ + normal_output(), + normal_output(), + null_data_output(), + null_data_output(), + null_data_output(), + ], + ); + let repayment_outpoint = OutPoint { + txid: repayment_tx.txid(), + vout: 1, + }; + + let mut tx_bytes_by_id = HashMap::new(); + tx_bytes_by_id.insert( + lending_tx.txid().to_string(), + encode::serialize(&lending_tx), + ); + tx_bytes_by_id.insert( + repayment_tx.txid().to_string(), + encode::serialize(&repayment_tx), + ); + + let (base_url, server_handle) = start_mock_esplora(MockEsploraState { + block_hash: "integration-same-block-visibility".to_string(), + txids: vec![ + lending_tx.txid().to_string(), + repayment_tx.txid().to_string(), + ], + tx_bytes_by_id, + }) + .await?; + let client = EsploraClient::with_base_url(&base_url); + + process_block(&pool, &client, &mut cache, 4_001).await?; + + assert_eq!(current_status(&pool, offer_id).await?, "repaid"); + assert!(cache.get(&pre_lock_outpoint).is_none()); + // Created + spent within one block -> net absent from the committed cache. + assert!(cache.get(&lending_outpoint).is_none()); + assert!(cache.get(&repayment_outpoint).is_some()); + assert_eq!(sync_state_row_count(&pool).await?, 1); + + server_handle.abort(); + Ok(()) +} + +const LENDER_ASSET_BYTE: u8 = 8; + +#[tokio::test] +#[serial] +async fn lender_nft_movement_updates_history() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + // Seeded `lender_nft_asset_id` is `[8; 32]` -> movement tx must emit + // asset byte 8 for the handler to pick up the new lender NFT output. + let pre_lock_outpoint = outpoint_with_txid_byte(0x50, 0); + seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 5_000).await?; + + let lender_outpoint = outpoint_with_txid_byte(0x51, 2); + seed_participant_utxo_row( + &pool, + &unspent_participant( + offer_id, + ParticipantType::Lender, + lender_outpoint, + vec![0x52, 0xac], + 5_001, + ), + ) + .await?; + cache.insert( + lender_outpoint, + ActiveUtxo { + offer_id, + data: UtxoData::Participant(ParticipantType::Lender), + }, + ); + + let move_tx = tx_with_input( + lender_outpoint, + vec![explicit_asset_output( + LENDER_ASSET_BYTE, + non_op_return_script(), + )], + ); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &move_tx, &mut cache, &client, 5_002).await?; + sql_tx.commit().await?; + } + + let moved_outpoint = OutPoint { + txid: move_tx.txid(), + vout: 0, + }; + assert!(cache.get(&lender_outpoint).is_none()); + assert!(cache.get(&moved_outpoint).is_some()); + assert_eq!( + count_participants(&pool, offer_id, "lender", Some(false)).await?, + 1 + ); + assert_eq!( + count_participants(&pool, offer_id, "lender", Some(true)).await?, + 1 + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn load_utxo_cache_excludes_spent_utxos() -> anyhow::Result<()> { + let pool = test_pool().await?; + + let offer_id = Uuid::new_v4(); + let mut offer = offer_model(offer_id, 6_000, vec![0x42_u8; 32]); + offer.current_status = OfferStatus::Active; + seed_offer_row(&pool, &offer).await?; + + let spent_pre_lock_outpoint = outpoint_with_txid_byte(0x60, 0); + seed_offer_utxo_row( + &pool, + &spent_offer_utxo( + offer_id, + spent_pre_lock_outpoint, + UtxoType::PreLock, + 6_000, + 6_001, + 0x61, + ), + ) + .await?; + + let spent_borrower_outpoint = outpoint_with_txid_byte(0x62, 1); + seed_participant_utxo_row( + &pool, + &spent_participant( + offer_id, + ParticipantType::Borrower, + spent_borrower_outpoint, + vec![0x51, 0xac], + 6_000, + 6_002, + 0x63, + ), + ) + .await?; + + let unspent_borrower_outpoint = outpoint_with_txid_byte(0x64, 2); + seed_participant_utxo_row( + &pool, + &unspent_participant( + offer_id, + ParticipantType::Borrower, + unspent_borrower_outpoint, + vec![0x52, 0xac], + 6_003, + ), + ) + .await?; + + let cache = load_utxo_cache(&pool).await?; + + // Pins `WHERE spent_txid IS NULL` in `load_utxo_cache`. + assert!(cache.get(&unspent_borrower_outpoint).is_some()); + assert!(cache.get(&spent_pre_lock_outpoint).is_none()); + assert!(cache.get(&spent_borrower_outpoint).is_none()); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_rolls_back_when_first_tx_fails() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let valid_offer_id = Uuid::new_v4(); + let valid_prelock_outpoint = outpoint_with_txid_byte(0x70, 0); + seed_offer_with_pre_lock(&pool, valid_offer_id, valid_prelock_outpoint, 7_000).await?; + cache.insert( + valid_prelock_outpoint, + ActiveUtxo { + offer_id: valid_offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + // Bad tx is FIRST -> if atomicity holds, the subsequent good tx must + // never touch the DB or the cache. `missing_offer_id` is cached but + // absent from `offers`, so `get_offer_participant_asset_id` raises + // `RowNotFound` and aborts the block. + let missing_offer_id = Uuid::new_v4(); + let missing_participant_outpoint = outpoint_with_txid_byte(0x71, 1); + cache.insert( + missing_participant_outpoint, + ActiveUtxo { + offer_id: missing_offer_id, + data: UtxoData::Participant(ParticipantType::Borrower), + }, + ); + + let bad_tx = tx_with_input(missing_participant_outpoint, vec![normal_output()]); + let good_tx = padded_tx_with_inputs(vec![valid_prelock_outpoint], vec![normal_output(); 5]); + + let mut tx_bytes_by_id = HashMap::new(); + tx_bytes_by_id.insert(bad_tx.txid().to_string(), encode::serialize(&bad_tx)); + tx_bytes_by_id.insert(good_tx.txid().to_string(), encode::serialize(&good_tx)); + + let (base_url, server_handle) = start_mock_esplora(MockEsploraState { + block_hash: "integration-block-bad-first".to_string(), + txids: vec![bad_tx.txid().to_string(), good_tx.txid().to_string()], + tx_bytes_by_id, + }) + .await?; + let client = EsploraClient::with_base_url(&base_url); + + let result = process_block(&pool, &client, &mut cache, 7_001).await; + assert!(result.is_err()); + + assert_eq!(current_status(&pool, valid_offer_id).await?, "pending"); + // pre_lock stays unspent -> good_tx was never applied. + assert_eq!( + count_offer_utxos(&pool, valid_offer_id, "pre_lock", Some(false)).await?, + 1 + ); + assert_eq!( + count_offer_utxos(&pool, valid_offer_id, "lending", None).await?, + 0 + ); + assert_eq!(sync_state_row_count(&pool).await?, 0); + + assert!(cache.get(&valid_prelock_outpoint).is_some()); + assert!(cache.get(&missing_participant_outpoint).is_some()); + let rolled_back_lending_outpoint = OutPoint { + txid: good_tx.txid(), + vout: 0, + }; + assert!(cache.get(&rolled_back_lending_outpoint).is_none()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_empty_txids_still_commits_sync_state() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let pre_existing_offer_id = Uuid::new_v4(); + let pre_existing_outpoint = outpoint_with_txid_byte(0x80, 0); + seed_offer_with_pre_lock(&pool, pre_existing_offer_id, pre_existing_outpoint, 8_000).await?; + cache.insert( + pre_existing_outpoint, + ActiveUtxo { + offer_id: pre_existing_offer_id, + data: UtxoData::Offer(UtxoType::PreLock), + }, + ); + + let block_hash = "integration-empty-block".to_string(); + let (base_url, server_handle) = start_mock_esplora(MockEsploraState { + block_hash: block_hash.clone(), + txids: vec![], + tx_bytes_by_id: HashMap::new(), + }) + .await?; + let client = EsploraClient::with_base_url(&base_url); + + process_block(&pool, &client, &mut cache, 8_001).await?; + + let sync = + sqlx::query("SELECT last_indexed_height, last_indexed_hash FROM sync_state WHERE id = 1") + .fetch_one(&pool) + .await?; + assert_eq!(sync.get::("last_indexed_height"), 8_001); + assert_eq!(sync.get::("last_indexed_hash"), block_hash); + + assert_eq!( + current_status(&pool, pre_existing_offer_id).await?, + "pending" + ); + assert!(cache.get(&pre_existing_outpoint).is_some()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_propagates_esplora_block_txids_500() -> anyhow::Result<()> { + async fn block_hash_ok() -> impl IntoResponse { + "integration-block-hash-txids-500".to_string() + } + async fn block_txids_500() -> impl IntoResponse { + (StatusCode::INTERNAL_SERVER_ERROR, "txids boom") + } + + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let app = Router::new() + .route("/block-height/{height}", get(block_hash_ok)) + .route("/block/{hash}/txids", get(block_txids_500)); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let server_handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let client = EsploraClient::with_base_url(&format!("http://{addr}")); + let result = process_block(&pool, &client, &mut cache, 9_100).await; + assert!(result.is_err()); + assert_eq!(sync_state_row_count(&pool).await?, 0); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn process_block_propagates_esplora_tx_raw_500() -> anyhow::Result<()> { + async fn block_hash_ok() -> impl IntoResponse { + "integration-block-hash-tx-500".to_string() + } + async fn block_txids_ok() -> impl IntoResponse { + axum::Json(vec![ + "0000000000000000000000000000000000000000000000000000000000000001".to_string(), + ]) + } + async fn tx_raw_500() -> impl IntoResponse { + (StatusCode::INTERNAL_SERVER_ERROR, "tx boom") + } + + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + + let app = Router::new() + .route("/block-height/{height}", get(block_hash_ok)) + .route("/block/{hash}/txids", get(block_txids_ok)) + .route("/tx/{txid}/raw", get(tx_raw_500)); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let server_handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let client = EsploraClient::with_base_url(&format!("http://{addr}")); + let result = process_block(&pool, &client, &mut cache, 9_200).await; + assert!(result.is_err()); + assert_eq!(sync_state_row_count(&pool).await?, 0); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn spent_utxo_does_not_reroute_from_cache() -> anyhow::Result<()> { + let pool = test_pool().await?; + let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); + + let offer_id = Uuid::new_v4(); + let mut offer = offer_model(offer_id, 10_000, vec![0xaa_u8; 32]); + offer.current_status = OfferStatus::Cancelled; + seed_offer_row(&pool, &offer).await?; + + let spent_pre_lock_outpoint = outpoint_with_txid_byte(0x90, 0); + seed_offer_utxo_row( + &pool, + &OfferUtxoModel { + offer_id, + txid: spent_pre_lock_outpoint.txid.as_byte_array().to_vec(), + vout: spent_pre_lock_outpoint.vout as i32, + utxo_type: UtxoType::PreLock, + created_at_height: 10_000, + spent_txid: Some(vec![0x91_u8; 32]), + spent_at_height: Some(10_001), + }, + ) + .await?; + + // Deliberately do NOT seed the cache: load_utxo_cache would have excluded + // this spent outpoint. A tx that now spends it must be ignored entirely. + let stale_spend_tx = tx_with_input(spent_pre_lock_outpoint, vec![normal_output(); 5]); + { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, &stale_spend_tx, &mut cache, &client, 10_100).await?; + sql_tx.commit().await?; + } + + assert_eq!(current_status(&pool, offer_id).await?, "cancelled"); + assert_eq!( + count_offer_utxos(&pool, offer_id, "lending", None).await?, + 0 + ); + let post_tx_outpoint = OutPoint { + txid: stale_spend_tx.txid(), + vout: 0, + }; + assert!(cache.get(&post_tx_outpoint).is_none()); + + Ok(()) +} From 6d4791422e04d9300a4a8d6cc7702da238bc774a Mon Sep 17 00:00:00 2001 From: Oleksandr Zahorodnyi Date: Mon, 4 May 2026 14:32:07 +0300 Subject: [PATCH 3/3] Clean up indexer integration tests --- .env.example | 2 +- .github/workflows/lint.yml | 3 +- .github/workflows/tests.yml | 3 +- Cargo.lock | 1 + crates/indexer/Cargo.toml | 1 + crates/indexer/tests/common/mod.rs | 13 +- crates/indexer/tests/indexer_integration.rs | 254 ++++++++------------ 7 files changed, 116 insertions(+), 161 deletions(-) diff --git a/.env.example b/.env.example index c856c53..063fbf0 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=password POSTGRES_DB=lending-indexer POSTGRES_PORT=5432 -DATABASE_URL=postgresql://postgres:password@postgres:5432/lending-indexer +DATABASE_URL=postgres://postgres:password@postgres:5432/lending-indexer WEB_PORT=80 # Build-time variables for web image diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 66ad7a4..3a2d86b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,6 +16,7 @@ jobs: runs-on: ubuntu-latest env: SQLX_OFFLINE: true + SIMPLEX_VERSION: v0.0.3 steps: - name: Checkout @@ -40,7 +41,7 @@ jobs: BASE_DIR="${XDG_CONFIG_HOME:-$HOME}" SIMPLEX_DIR="${SIMPLEX_DIR:-$BASE_DIR/.simplex}" SIMPLEX_BIN_DIR="$SIMPLEX_DIR/bin" - "$SIMPLEX_BIN_DIR/simplexup" + "$SIMPLEX_BIN_DIR/simplexup" --install "$SIMPLEX_VERSION" echo "$SIMPLEX_BIN_DIR" >> "$GITHUB_PATH" "$SIMPLEX_BIN_DIR/simplex" --version diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 93eb424..61ff7a7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,7 @@ env: SQLX_VERSION: 0.8.0 SQLX_FEATURES: "rustls,postgres" SQLX_FEATURES_KEY: "rustls-postgres" + SIMPLEX_VERSION: v0.0.3 APP_USER: app APP_USER_PWD: secret APP_DB_NAME: lending-indexer @@ -79,7 +80,7 @@ jobs: BASE_DIR="${XDG_CONFIG_HOME:-$HOME}" SIMPLEX_DIR="${SIMPLEX_DIR:-$BASE_DIR/.simplex}" SIMPLEX_BIN_DIR="$SIMPLEX_DIR/bin" - "$SIMPLEX_BIN_DIR/simplexup" + "$SIMPLEX_BIN_DIR/simplexup" --install "$SIMPLEX_VERSION" echo "$SIMPLEX_BIN_DIR" >> "$GITHUB_PATH" "$SIMPLEX_BIN_DIR/simplex" --version diff --git a/Cargo.lock b/Cargo.lock index f29f1e6..79ce589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,6 +1687,7 @@ dependencies = [ "axum", "cargo-husky", "config", + "dotenvy", "hex", "lending-contracts", "reqwest", diff --git a/crates/indexer/Cargo.toml b/crates/indexer/Cargo.toml index 86d15d5..a72f2da 100644 --- a/crates/indexer/Cargo.toml +++ b/crates/indexer/Cargo.toml @@ -51,4 +51,5 @@ features = [ [dev-dependencies] anyhow = "1" cargo-husky = { workspace = true } +dotenvy = "0.15" serial_test = "3" diff --git a/crates/indexer/tests/common/mod.rs b/crates/indexer/tests/common/mod.rs index 29d1bc2..02cc65c 100644 --- a/crates/indexer/tests/common/mod.rs +++ b/crates/indexer/tests/common/mod.rs @@ -1,8 +1,6 @@ #![allow(dead_code)] -use std::env; -use std::str::FromStr; - +use anyhow::Context; use lending_indexer::indexer::{ insert_offer, insert_offer_utxo, insert_participant_utxo, update_offer_status, }; @@ -14,6 +12,7 @@ use simplex::simplicityhl::elements::{ hashes::Hash, secp256k1_zkp::XOnlyPublicKey, }; use sqlx::PgPool; +use std::str::FromStr; use uuid::Uuid; pub const FIXED_BORROWER_PUBKEY_HEX: &str = @@ -30,10 +29,10 @@ pub fn fixed_borrower_pubkey_bytes() -> Vec { /// truncated. Panics (instead of silent-skip) when `DATABASE_URL` is not /// configured. Silent-skip would mask a completely empty test run in CI. pub async fn test_pool() -> anyhow::Result { - let database_url = env::var("DATABASE_URL").expect( - "DATABASE_URL must be set for integration tests. \ - See crates/indexer/scripts/init_db.sh", - ); + let _ = dotenvy::dotenv(); + + let database_url = std::env::var("DATABASE_URL") + .context("DATABASE_URL must be set in the environment or .env for integration tests")?; let pool = PgPool::connect(&database_url).await?; sqlx::migrate!("./migrations").run(&pool).await?; diff --git a/crates/indexer/tests/indexer_integration.rs b/crates/indexer/tests/indexer_integration.rs index dc6f830..ba2b73b 100644 --- a/crates/indexer/tests/indexer_integration.rs +++ b/crates/indexer/tests/indexer_integration.rs @@ -26,7 +26,8 @@ use lending_indexer::models::{ use serial_test::serial; use simplex::provider::SimplicityNetwork; use simplex::simplicityhl::elements::{ - AssetId, OutPoint, Script, TxOut, Txid, encode, hashes::Hash, secp256k1_zkp::XOnlyPublicKey, + AssetId, OutPoint, Script, Transaction, TxOut, Txid, encode, hashes::Hash, + secp256k1_zkp::XOnlyPublicKey, }; use sqlx::{PgPool, Row}; use tokio::net::TcpListener; @@ -46,6 +47,16 @@ struct MockEsploraState { tx_bytes_by_id: HashMap>, } +async fn start_mock_server(app: Router) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + async fn start_mock_esplora( state: MockEsploraState, ) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { @@ -84,13 +95,7 @@ async fn start_mock_esplora( .route("/tx/{txid}/raw", get(get_raw_tx)) .with_state(Arc::new(state)); - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - Ok((format!("http://{addr}"), handle)) + start_mock_server(app).await } async fn seed_offer_with_pre_lock( @@ -207,6 +212,19 @@ async fn sync_state_row_count(pool: &PgPool) -> anyhow::Result { Ok(row.get::("c")) } +async fn process_tx_and_commit( + pool: &PgPool, + tx: &Transaction, + cache: &mut UtxoCache, + client: &EsploraClient, + block_height: u64, +) -> anyhow::Result<()> { + let mut sql_tx = pool.begin().await?; + process_tx(&mut sql_tx, tx, cache, client, block_height).await?; + sql_tx.commit().await?; + Ok(()) +} + #[tokio::test] #[serial] async fn process_tx_full_repay_then_claim_lifecycle() -> anyhow::Result<()> { @@ -229,11 +247,7 @@ async fn process_tx_full_repay_then_claim_lifecycle() -> anyhow::Result<()> { // Pad to 7 inputs so the tx matches the shape of a real lending-creation // spend even if the dispatcher later adds an input-count guard. let lending_tx = padded_tx_with_inputs(vec![pre_lock_outpoint], vec![normal_output(); 5]); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &lending_tx, &mut cache, &client, 101).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &lending_tx, &mut cache, &client, 101).await?; // Dispatch: output[1] non-null + [2, 3, 4] null-data -> repayment path. let lending_outpoint = OutPoint { @@ -250,22 +264,14 @@ async fn process_tx_full_repay_then_claim_lifecycle() -> anyhow::Result<()> { null_data_output(), ], ); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &repayment_tx, &mut cache, &client, 102).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &repayment_tx, &mut cache, &client, 102).await?; let repayment_outpoint = OutPoint { txid: repayment_tx.txid(), vout: 1, }; let claim_tx = tx_with_input(repayment_outpoint, vec![normal_output(), normal_output()]); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &claim_tx, &mut cache, &client, 103).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &claim_tx, &mut cache, &client, 103).await?; assert_eq!(current_status(&pool, offer_id).await?, "claimed"); @@ -306,11 +312,7 @@ async fn process_tx_liquidation_updates_offer_and_archives_utxo() -> anyhow::Res ); let lending_tx = padded_tx_with_inputs(vec![pre_lock_outpoint], vec![normal_output(); 5]); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &lending_tx, &mut cache, &client, 201).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &lending_tx, &mut cache, &client, 201).await?; // Dispatch: outputs [1, 2, 3] null-data, [4] non-null -> liquidation path. let lending_outpoint = OutPoint { @@ -327,11 +329,7 @@ async fn process_tx_liquidation_updates_offer_and_archives_utxo() -> anyhow::Res normal_output(), ], ); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &liquidation_tx, &mut cache, &client, 202).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &liquidation_tx, &mut cache, &client, 202).await?; assert_eq!(current_status(&pool, offer_id).await?, "liquidated"); // Pins: liquidation handler inserts the post-liquidation utxo as already @@ -377,11 +375,7 @@ async fn process_tx_prelock_to_cancellation_sets_status_and_archives() -> anyhow null_data_output(), ], ); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &cancellation_tx, &mut cache, &client, 401).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &cancellation_tx, &mut cache, &client, 401).await?; assert_eq!(current_status(&pool, offer_id).await?, "cancelled"); assert_eq!( @@ -427,11 +421,7 @@ async fn participant_movement_updates_history_and_handles_burn() -> anyhow::Resu borrower_outpoint, vec![explicit_asset_output(7, non_op_return_script())], ); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &move_tx, &mut cache, &client, 502).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &move_tx, &mut cache, &client, 502).await?; let new_borrower_outpoint = OutPoint { txid: move_tx.txid(), @@ -454,11 +444,7 @@ async fn participant_movement_updates_history_and_handles_burn() -> anyhow::Resu new_borrower_outpoint, vec![explicit_asset_output(7, Script::new_op_return(b"burn"))], ); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &burn_tx, &mut cache, &client, 503).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &burn_tx, &mut cache, &client, 503).await?; assert!(cache.get(&new_borrower_outpoint).is_none()); assert_eq!( @@ -511,18 +497,14 @@ async fn participant_move_without_target_asset_marks_spent_without_new_utxo() -> borrower_outpoint, vec![explicit_asset_output(9, non_op_return_script())], ); - { - let mut sql_tx = pool.begin().await?; - process_tx( - &mut sql_tx, - &move_without_target_asset_tx, - &mut cache, - &client, - 532, - ) - .await?; - sql_tx.commit().await?; - } + process_tx_and_commit( + &pool, + &move_without_target_asset_tx, + &mut cache, + &client, + 532, + ) + .await?; assert!(cache.get(&borrower_outpoint).is_none()); assert_eq!( @@ -589,9 +571,7 @@ async fn single_tx_with_multiple_known_inputs_applies_all_transitions() -> anyho ], ); - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &combined_tx, &mut cache, &client, 522).await?; - sql_tx.commit().await?; + process_tx_and_commit(&pool, &combined_tx, &mut cache, &client, 522).await?; assert_eq!(current_status(&pool, offer_id).await?, "active"); @@ -877,13 +857,9 @@ async fn process_block_returns_error_on_esplora_http_500() -> anyhow::Result<()> let mut cache = UtxoCache::new(); let app = Router::new().route("/block-height/{height}", get(block_height_500)); - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let server_handle = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); + let (base_url, server_handle) = start_mock_server(app).await?; - let client = EsploraClient::with_base_url(&format!("http://{addr}")); + let client = EsploraClient::with_base_url(&base_url); let result = process_block(&pool, &client, &mut cache, 900).await; assert!(result.is_err()); assert_eq!(sync_state_row_count(&pool).await?, 0); @@ -1112,81 +1088,73 @@ async fn handle_pre_lock_creation_with_malformed_outputs_returns_error() -> anyh Ok(()) } -/// Pins: a UTXO created by tx1 must be visible to tx2 via `cache.get` -/// BEFORE `commit_block` is called. We use lending_creation -> repayment -/// within one block instead of pre-lock -> lending because the latter -/// needs the full Simplex contract machinery (see module comment above). +/// Pins: a participant NFT created earlier in the block must be visible to a +/// later tx via `cache.get` BEFORE `commit_block` is called. #[tokio::test] #[serial] -async fn same_block_create_and_spend_routes_through_pending_cache() -> anyhow::Result<()> { +async fn same_block_participant_transfer_routes_through_pending_cache() -> anyhow::Result<()> { let pool = test_pool().await?; let mut cache = UtxoCache::new(); + let client = EsploraClient::new(); - let offer_id = Uuid::new_v4(); - let pre_lock_outpoint = outpoint_with_txid_byte(0x40, 0); - seed_offer_with_pre_lock(&pool, offer_id, pre_lock_outpoint, 4_000).await?; - cache.insert( - pre_lock_outpoint, - ActiveUtxo { - offer_id, - data: UtxoData::Offer(UtxoType::PreLock), - }, + let params = synthesized_pre_lock_parameters(); + let pre_lock_tx = pre_lock_shaped_tx( + outpoint_with_txid_byte(0x40, 0), + Script::from(vec![0x51]), + Script::from(vec![0x52]), ); - - let lending_tx = padded_tx_with_inputs(vec![pre_lock_outpoint], vec![normal_output(); 5]); - let lending_outpoint = OutPoint { - txid: lending_tx.txid(), - vout: 0, + let borrower_outpoint = OutPoint { + txid: pre_lock_tx.txid(), + vout: 3, }; - - // tx2 must see `lending_outpoint` via the pending-ops map; `commit_block` - // has not run yet. - let repayment_tx = tx_with_input( - lending_outpoint, - vec![ - normal_output(), - normal_output(), - null_data_output(), - null_data_output(), - null_data_output(), - ], - ); - let repayment_outpoint = OutPoint { - txid: repayment_tx.txid(), - vout: 1, + let lender_outpoint = OutPoint { + txid: pre_lock_tx.txid(), + vout: 4, }; - let mut tx_bytes_by_id = HashMap::new(); - tx_bytes_by_id.insert( - lending_tx.txid().to_string(), - encode::serialize(&lending_tx), - ); - tx_bytes_by_id.insert( - repayment_tx.txid().to_string(), - encode::serialize(&repayment_tx), + // tx2 must see `borrower_outpoint` via the pending-ops map; `commit_block` + // has not run yet. Asset byte 0xbb matches `synthesized_pre_lock_parameters`. + let borrower_move_tx = tx_with_input( + borrower_outpoint, + vec![explicit_asset_output(0xbb, non_op_return_script())], ); + let moved_borrower_outpoint = OutPoint { + txid: borrower_move_tx.txid(), + vout: 0, + }; - let (base_url, server_handle) = start_mock_esplora(MockEsploraState { - block_hash: "integration-same-block-visibility".to_string(), - txids: vec![ - lending_tx.txid().to_string(), - repayment_tx.txid().to_string(), - ], - tx_bytes_by_id, - }) + let mut sql_tx = pool.begin().await?; + cache.begin_block(); + + handle_pre_lock_creation(&mut sql_tx, &mut cache, params, &pre_lock_tx, 4_001).await?; + process_tx(&mut sql_tx, &borrower_move_tx, &mut cache, &client, 4_001).await?; + upsert_sync_state( + &mut sql_tx, + 4_001, + "integration-same-block-participant-visibility".to_string(), + ) .await?; - let client = EsploraClient::with_base_url(&base_url); - - process_block(&pool, &client, &mut cache, 4_001).await?; + sql_tx.commit().await?; + cache.commit_block(); - assert_eq!(current_status(&pool, offer_id).await?, "repaid"); - assert!(cache.get(&pre_lock_outpoint).is_none()); - // Created + spent within one block -> net absent from the committed cache. - assert!(cache.get(&lending_outpoint).is_none()); - assert!(cache.get(&repayment_outpoint).is_some()); + let offer_row = sqlx::query("SELECT id, current_status::text AS s FROM offers") + .fetch_one(&pool) + .await?; + let offer_id: Uuid = offer_row.get("id"); + assert_eq!(offer_row.get::("s"), "pending"); + assert!(cache.get(&borrower_outpoint).is_none()); + assert!(cache.get(&moved_borrower_outpoint).is_some()); + assert!(cache.get(&lender_outpoint).is_some()); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(false)).await?, + 1 + ); + assert_eq!( + count_participants(&pool, offer_id, "borrower", Some(true)).await?, + 1 + ); assert_eq!(sync_state_row_count(&pool).await?, 1); - server_handle.abort(); Ok(()) } @@ -1232,11 +1200,7 @@ async fn lender_nft_movement_updates_history() -> anyhow::Result<()> { non_op_return_script(), )], ); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &move_tx, &mut cache, &client, 5_002).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &move_tx, &mut cache, &client, 5_002).await?; let moved_outpoint = OutPoint { txid: move_tx.txid(), @@ -1452,13 +1416,9 @@ async fn process_block_propagates_esplora_block_txids_500() -> anyhow::Result<() let app = Router::new() .route("/block-height/{height}", get(block_hash_ok)) .route("/block/{hash}/txids", get(block_txids_500)); - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let server_handle = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); + let (base_url, server_handle) = start_mock_server(app).await?; - let client = EsploraClient::with_base_url(&format!("http://{addr}")); + let client = EsploraClient::with_base_url(&base_url); let result = process_block(&pool, &client, &mut cache, 9_100).await; assert!(result.is_err()); assert_eq!(sync_state_row_count(&pool).await?, 0); @@ -1489,13 +1449,9 @@ async fn process_block_propagates_esplora_tx_raw_500() -> anyhow::Result<()> { .route("/block-height/{height}", get(block_hash_ok)) .route("/block/{hash}/txids", get(block_txids_ok)) .route("/tx/{txid}/raw", get(tx_raw_500)); - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let server_handle = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); + let (base_url, server_handle) = start_mock_server(app).await?; - let client = EsploraClient::with_base_url(&format!("http://{addr}")); + let client = EsploraClient::with_base_url(&base_url); let result = process_block(&pool, &client, &mut cache, 9_200).await; assert!(result.is_err()); assert_eq!(sync_state_row_count(&pool).await?, 0); @@ -1534,11 +1490,7 @@ async fn spent_utxo_does_not_reroute_from_cache() -> anyhow::Result<()> { // Deliberately do NOT seed the cache: load_utxo_cache would have excluded // this spent outpoint. A tx that now spends it must be ignored entirely. let stale_spend_tx = tx_with_input(spent_pre_lock_outpoint, vec![normal_output(); 5]); - { - let mut sql_tx = pool.begin().await?; - process_tx(&mut sql_tx, &stale_spend_tx, &mut cache, &client, 10_100).await?; - sql_tx.commit().await?; - } + process_tx_and_commit(&pool, &stale_spend_tx, &mut cache, &client, 10_100).await?; assert_eq!(current_status(&pool, offer_id).await?, "cancelled"); assert_eq!(