diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 3628168..eb7adbd 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -168,7 +168,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tower 0.5.3", +"tower 0.5.3", ] [[package]] @@ -821,6 +821,45 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1416,6 +1455,46 @@ dependencies = [ "zerovec", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1608,6 +1687,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/backend/src/legacy_routes.rs b/backend/src/legacy_routes.rs new file mode 100644 index 0000000..c0f895f --- /dev/null +++ b/backend/src/legacy_routes.rs @@ -0,0 +1,99 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::Serialize; + +use crate::auth::{self, ChallengeResponse, ChallengeStore}; + +#[derive(Serialize)] +pub struct HealthResponse { + status: String, + service: String, +} + +// ─── Auth (public) ─────────────────────────────────────────────────────── + +pub async fn get_challenge( + State(store): State, +) -> Json { + Json(auth::generate_challenge(&store)) +} + +// ─── Health (public) ───────────────────────────────────────────────────── + +pub async fn health() -> Json { + Json(HealthResponse { + status: "ok".to_string(), + service: "comebackhere-backend".to_string(), + }) +} + +// ─── Merchant routes (protected) ───────────────────────────────────────── + +pub async fn merchant_dashboard() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Merchant dashboard data" })) +} + +pub async fn merchant_invoices() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Merchant invoices list" })) +} + +pub async fn create_merchant_invoice() -> impl IntoResponse { + ( + StatusCode::CREATED, + Json(serde_json::json!({ "message": "Invoice created" })), + ) +} + +// ─── Signer routes (protected) ─────────────────────────────────────────── + +pub async fn get_pending_settlements() -> impl IntoResponse { + Json(serde_json::json!({ "settlements": [] })) +} + +pub async fn approve_settlement() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Settlement approved" })) +} + +pub async fn get_disputes() -> impl IntoResponse { + Json(serde_json::json!({ "disputes": [] })) +} + +pub async fn vote_dispute() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Vote recorded" })) +} + +pub async fn get_signers() -> impl IntoResponse { + Json(serde_json::json!({ "signers": [] })) +} + +pub async fn add_signer() -> impl IntoResponse { + ( + StatusCode::CREATED, + Json(serde_json::json!({ "message": "Signer added" })), + ) +} + +pub async fn remove_signer() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Signer removed" })) +} + +pub async fn rotate_signers() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Signers rotated" })) +} + +// ─── Admin routes (protected) ──────────────────────────────────────────── + +pub async fn admin_dashboard() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Admin dashboard" })) +} + +pub async fn admin_pause_contract() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Contract paused" })) +} + +pub async fn admin_unpause_contract() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Contract unpaused" })) +} + +pub async fn admin_settlement_report() -> impl IntoResponse { + Json(serde_json::json!({ "message": "Settlement report" })) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index d215f63..2c2d8a8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -40,6 +40,16 @@ struct ApiDoc; #[tokio::main] async fn main() { + // Initialise structured logging. Level is controlled at runtime via the + // RUST_LOG environment variable (e.g. `RUST_LOG=info`). Defaults to + // `info` when the variable is absent. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + let rpc_url = std::env::var("SOROBAN_RPC_URL") .unwrap_or_else(|_| "http://localhost:8000/soroban/rpc".to_string()); let contract_id = std::env::var("INVOICE_CONTRACT_ID") @@ -68,7 +78,7 @@ async fn main() { .with_state(client); let addr = "0.0.0.0:3001"; - println!("comebackhere-backend listening on {addr}"); + tracing::info!("comebackhere-backend listening on {addr}"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } diff --git a/backend/src/soroban.rs b/backend/src/soroban.rs index 59fa604..ee88b9b 100644 --- a/backend/src/soroban.rs +++ b/backend/src/soroban.rs @@ -177,7 +177,14 @@ impl SorobanClient { // ------------------------------------------------------------------ /// Fetch invoice state from Soroban via get_invoice. + #[tracing::instrument( + name = "soroban.get_invoice", + skip(self), + fields(invoice_id = %invoice_id) + )] pub async fn get_invoice(&self, invoice_id: u64) -> Result { + tracing::debug!("sending simulateTransaction RPC call"); + let args_xdr = encode_u64_arg(invoice_id); let req = RpcRequest { jsonrpc: "2.0", @@ -191,14 +198,22 @@ impl SorobanClient { let resp = self.rpc_post(&req).await?; if let Some(err) = resp.error { - return Err(rpc_error_to_anyhow(&err)); + let e = rpc_error_to_anyhow(&err); + tracing::warn!(error = %e, "RPC error in get_invoice"); + return Err(e); } let result = resp.result.ok_or_else(|| anyhow!("Empty RPC result"))?; - parse_invoice_result(&result, invoice_id) + let invoice = parse_invoice_result(&result, invoice_id)?; + + tracing::debug!(status = ?invoice.status, "get_invoice RPC call succeeded"); + Ok(invoice) } + #[tracing::instrument(name = "soroban.check_rpc_health", skip(self))] pub async fn check_rpc_health(&self) -> Result<()> { + tracing::debug!("sending getLatestLedger health probe"); + let req = RpcRequest { jsonrpc: "2.0", id: 3, @@ -209,7 +224,9 @@ impl SorobanClient { let resp = self.rpc_post(&req).await?; if let Some(err) = resp.error { - return Err(rpc_error_to_anyhow(&err)); + let e = rpc_error_to_anyhow(&err); + tracing::warn!(error = %e, "RPC health probe failed"); + return Err(e); } resp.result @@ -217,7 +234,10 @@ impl SorobanClient { .map(|_| ()) } + #[tracing::instrument(name = "soroban.check_horizon_health", skip(self))] pub async fn check_horizon_health(&self) -> Result<()> { + tracing::debug!("sending Horizon health probe"); + let health_url = format!("{}/health", self.horizon_url.trim_end_matches('/')); with_retry(|| async { @@ -248,6 +268,11 @@ impl SorobanClient { /// Errors: /// - "UNAUTHORIZED" when the contract returns InvoiceError::Unauthorized(1) /// - "NOT_FOUND" when the contract returns InvoiceError::NotFound(6) + #[tracing::instrument( + name = "soroban.pay_invoice", + skip(self, signed_xdr), + fields(invoice_id = %invoice_id, payer = %payer) + )] pub async fn pay_invoice( &self, invoice_id: u64, @@ -258,11 +283,13 @@ impl SorobanClient { let invoice = self.get_invoice(invoice_id).await?; if let Some(expected) = &invoice.payer { if !expected.is_empty() && expected != payer { + tracing::warn!("payer mismatch; rejecting pay_invoice"); return Err(anyhow!("UNAUTHORIZED")); } } // 2. Send the pre-signed transaction. + tracing::debug!("sending sendTransaction RPC call for pay"); let req = RpcRequest { jsonrpc: "2.0", id: 2, @@ -273,7 +300,9 @@ impl SorobanClient { let resp = self.rpc_post(&req).await?; if let Some(err) = resp.error { - return Err(rpc_error_to_anyhow(&err)); + let e = rpc_error_to_anyhow(&err); + tracing::error!(error = %e, "sendTransaction RPC error in pay_invoice"); + return Err(e); } let result = resp.result.ok_or_else(|| anyhow!("Empty RPC result"))?; @@ -284,6 +313,8 @@ impl SorobanClient { .unwrap_or("") .to_string(); + tracing::info!(tx_hash = %tx_hash, "pay_invoice RPC call succeeded"); + // 3. Return updated status (Paid) and the transaction hash. Ok(PayResponse { status: InvoiceStatus::Paid, @@ -298,6 +329,11 @@ impl SorobanClient { /// Errors: /// - "UNAUTHORIZED" when the contract returns ContractError::Unauthorized(1) /// - "NOT_FOUND" when the contract returns ContractError::InvoiceNotFound(4) + #[tracing::instrument( + name = "soroban.cancel_invoice", + skip(self, signed_xdr), + fields(invoice_id = %invoice_id, merchant = %merchant) + )] pub async fn cancel_invoice( &self, invoice_id: u64, @@ -307,10 +343,12 @@ impl SorobanClient { // 1. Verify the caller is the merchant recorded on the invoice. let invoice = self.get_invoice(invoice_id).await?; if invoice.merchant != merchant { + tracing::warn!("merchant mismatch; rejecting cancel_invoice"); return Err(anyhow!("UNAUTHORIZED")); } // 2. Forward the pre-signed cancel transaction. + tracing::debug!("sending sendTransaction RPC call for cancel"); let req = RpcRequest { jsonrpc: "2.0", id: 3, @@ -321,7 +359,9 @@ impl SorobanClient { let resp = self.rpc_post(&req).await?; if let Some(err) = resp.error { - return Err(rpc_error_to_anyhow(&err)); + let e = rpc_error_to_anyhow(&err); + tracing::error!(error = %e, "sendTransaction RPC error in cancel_invoice"); + return Err(e); } let result = resp.result.ok_or_else(|| anyhow!("Empty RPC result"))?; @@ -332,6 +372,8 @@ impl SorobanClient { .unwrap_or("") .to_string(); + tracing::info!(tx_hash = %tx_hash, "cancel_invoice RPC call succeeded"); + Ok(CancelResponse { status: InvoiceStatus::Cancelled, transaction_hash: tx_hash, @@ -346,6 +388,11 @@ impl SorobanClient { /// - "NOT_PAID" when the contract returns ContractError::RefundNotRequested(10) /// - "UNAUTHORIZED" when the contract returns ContractError::Unauthorized(1) /// - "NOT_FOUND" when the contract returns ContractError::InvoiceNotFound(4) + #[tracing::instrument( + name = "soroban.refund_invoice", + skip(self, signed_xdr), + fields(invoice_id = %invoice_id, payer = %payer) + )] pub async fn refund_invoice( &self, invoice_id: u64, @@ -356,11 +403,13 @@ impl SorobanClient { let invoice = self.get_invoice(invoice_id).await?; if let Some(expected) = &invoice.payer { if !expected.is_empty() && expected != payer { + tracing::warn!("payer mismatch; rejecting refund_invoice"); return Err(anyhow!("UNAUTHORIZED")); } } // 2. Forward the pre-signed refund transaction. + tracing::debug!("sending sendTransaction RPC call for refund"); let req = RpcRequest { jsonrpc: "2.0", id: 4, @@ -371,7 +420,9 @@ impl SorobanClient { let resp = self.rpc_post(&req).await?; if let Some(err) = resp.error { - return Err(rpc_error_to_anyhow(&err)); + let e = rpc_error_to_anyhow(&err); + tracing::error!(error = %e, "sendTransaction RPC error in refund_invoice"); + return Err(e); } let result = resp.result.ok_or_else(|| anyhow!("Empty RPC result"))?; @@ -382,6 +433,8 @@ impl SorobanClient { .unwrap_or("") .to_string(); + tracing::info!(tx_hash = %tx_hash, "refund_invoice RPC call succeeded"); + Ok(RefundResponse { status: InvoiceStatus::RefundRequested, transaction_hash: tx_hash,