diff --git a/.sqlx/query-d6a5c82291c9c90d0b16ef2eb12c102b4d207f4d5bcd9d6d3c5e7ee8a1a2c3a2.json b/.sqlx/query-d6a5c82291c9c90d0b16ef2eb12c102b4d207f4d5bcd9d6d3c5e7ee8a1a2c3a2.json new file mode 100644 index 00000000..3ababc57 --- /dev/null +++ b/.sqlx/query-d6a5c82291c9c90d0b16ef2eb12c102b4d207f4d5bcd9d6d3c5e7ee8a1a2c3a2.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH balance_update AS (\n UPDATE escrow_users\n SET balance = balance - $2\n WHERE wallet_address = $1 AND balance >= $2\n RETURNING 1\n )\n INSERT INTO escrow_transactions (\n wallet_address, amount, currency, transaction_hash, notes, type, status\n )\n SELECT $1, $2, $3, $4, $5, 'withdrawal', 'completed'\n FROM balance_update\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Numeric", + "Varchar", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d6a5c82291c9c90d0b16ef2eb12c102b4d207f4d5bcd9d6d3c5e7ee8a1a2c3a2" +} diff --git a/src/http/transaction/domain.rs b/src/http/transaction/domain.rs index d7375d7b..14d55857 100644 --- a/src/http/transaction/domain.rs +++ b/src/http/transaction/domain.rs @@ -43,3 +43,32 @@ pub fn validate_currency(currency: &str, _context: &()) -> garde::Result { Err(garde::Error::new("Unsupported currency")) } } + +// src/transaction/withdraw.rs +// use bigdecimal::{BigDecimal, Zero}; +// use garde::Validate; +// use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Validate)] +pub struct WithdrawalRequest { + #[garde(custom(validate_starknet_address))] + pub wallet_address: String, + #[garde(custom(validate_withdrawal_amount))] + pub amount: BigDecimal, + #[garde(custom(validate_currency))] + pub currency: String, + #[garde(inner(length(min = 1, max = 255)))] + pub notes: Option, + #[garde(length(equal = 65))] + pub transaction_hash: String, +} + +pub fn validate_withdrawal_amount(amount: &BigDecimal, _context: &()) -> garde::Result { + if amount > &BigDecimal::zero() { + Ok(()) + } else { + Err(garde::Error::new( + "Withdrawal amount must be greater than zero", + )) + } +} diff --git a/src/http/transaction/mod.rs b/src/http/transaction/mod.rs index e0dbc545..f50119d5 100644 --- a/src/http/transaction/mod.rs +++ b/src/http/transaction/mod.rs @@ -1,5 +1,6 @@ mod deposit; mod domain; +mod withdraw; use axum::{Router, routing::post}; pub use domain::*; @@ -7,5 +8,7 @@ pub use domain::*; use crate::AppState; pub(crate) fn router() -> Router { - Router::new().route("/deposit", post(deposit::deposit_handler)) + Router::new() + .route("/deposit", post(deposit::deposit_handler)) + .route("/withdraw", post(withdraw::withdraw_handler)) } diff --git a/src/http/transaction/withdraw.rs b/src/http/transaction/withdraw.rs new file mode 100644 index 00000000..06e05e5d --- /dev/null +++ b/src/http/transaction/withdraw.rs @@ -0,0 +1,64 @@ +use crate::{AppState, Error, Result, http::transaction::WithdrawalRequest}; +use axum::{Json, extract::State, http::StatusCode}; +use garde::Validate; + +#[tracing::instrument(name = "withdraw_handler", skip(state, payload))] +pub async fn withdraw_handler( + state: State, + Json(payload): Json, +) -> Result { + payload.validate()?; + let mut tx = state.db.pool.begin().await?; + + let query_result = sqlx::query!( + r#" + WITH balance_update AS ( + UPDATE escrow_users + SET balance = balance - $2 + WHERE wallet_address = $1 AND balance >= $2 + RETURNING 1 + ) + INSERT INTO escrow_transactions ( + wallet_address, amount, currency, transaction_hash, notes, type, status + ) + SELECT $1, $2, $3, $4, $5, 'withdrawal', 'completed' + FROM balance_update + "#, + payload.wallet_address, + payload.amount, + payload.currency, + payload.transaction_hash, + payload.notes + ) + .execute(&mut *tx) + .await?; + + match query_result.rows_affected() { + 1 => { + tx.commit().await?; + Ok(StatusCode::CREATED) + } + 0 => { + let exists: bool = sqlx::query_scalar( + r#" + SELECT EXISTS(SELECT 1 FROM escrow_users WHERE wallet_address = $1) + "#, + ) + .bind(payload.wallet_address) + .fetch_one(&mut *tx) + .await?; + + if exists { + Err(Error::InvalidRequest("Insufficient Funds".to_string())) + } else { + Err(Error::NotFound) + } + } + _ => { + tracing::error!("Unexpected rows affected {}", query_result.rows_affected()); + Err(Error::InternalServerError(anyhow::anyhow!( + "Unexpected rows affected" + ))) + } + } +} diff --git a/tests/api/transaction.rs b/tests/api/transaction.rs index fe013868..bc5f7b6c 100644 --- a/tests/api/transaction.rs +++ b/tests/api/transaction.rs @@ -3,6 +3,7 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; +use bigdecimal::BigDecimal; use serde_json::json; #[tokio::test] @@ -52,3 +53,175 @@ async fn test_deposit_successful_with_escrow_users_available() { let res = app.request(request).await; assert_eq!(res.status(), StatusCode::CREATED); } + +// use crate::helpers::TestApp; +// use axum::{ +// body::Body, +// http::{Request, StatusCode}, +// }; +// use serde_json::json; +#[tokio::test] +async fn test_withdraw_successful() { + let app = TestApp::new().await; + let db = &app.db; + + let wallet = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + let initial_balance = BigDecimal::from(20000000); + sqlx::query!( + r#" + INSERT INTO escrow_users (wallet_address, balance) + VALUES ($1, $2) + ON CONFLICT (wallet_address) DO UPDATE + SET balance = EXCLUDED.balance + "#, + wallet, + initial_balance + ) + .execute(&db.pool) + .await + .expect("Failed to create escrow account"); + + let tx_hash = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabc"; + let withdrawal_amount = BigDecimal::from(10000000); + let payload = json!({ + "wallet_address": wallet, + "amount": withdrawal_amount.to_string(), + "currency": "USDT", + "notes": "Project withdrawal", + "transaction_hash": tx_hash + }); + + let request = Request::post("/withdraw") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(request).await; + assert_eq!(res.status(), StatusCode::CREATED); + + let updated_balance: BigDecimal = sqlx::query_scalar!( + "SELECT balance FROM escrow_users WHERE wallet_address = $1", + wallet + ) + .fetch_one(&db.pool) + .await + .expect("Failed to fetch balance"); + + assert_eq!(updated_balance, initial_balance - withdrawal_amount.clone()); + + #[derive(Debug, sqlx::FromRow)] + struct Transaction { + amount: BigDecimal, + r#type: String, + } + + let transaction = sqlx::query_as!( + Transaction, + r#" + SELECT amount, type as "type!: String" + FROM escrow_transactions + WHERE wallet_address = $1 AND transaction_hash = $2 + "#, + wallet, + tx_hash + ) + .fetch_one(&db.pool) + .await + .expect("Failed to fetch transaction"); + + assert_eq!(transaction.amount, withdrawal_amount); + assert_eq!(transaction.r#type, "withdrawal".to_string()); +} + +#[tokio::test] +async fn test_withdraw_insufficient_balance() { + let app = TestApp::new().await; + let db = &app.db; + + // Setup: Create escrow user with minimal balance + let wallet = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabce"; + let initial_balance = BigDecimal::from(5000000); + sqlx::query!( + "INSERT INTO escrow_users (wallet_address, balance) VALUES ($1, $2)", + wallet, + initial_balance + ) + .execute(&db.pool) + .await + .expect("Failed to create escrow account"); + + let tx_hash = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabe"; + let withdrawal_amount = 10000000; // More than balance + let payload = json!({ + "wallet_address": wallet, + "amount": withdrawal_amount, + "currency": "USDT", + "notes": "Project withdrawal", + "transaction_hash": tx_hash + }); + + let request = Request::post("/withdraw") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(request).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + // Verify balance wasn't changed + let current_balance = sqlx::query!( + "SELECT balance FROM escrow_users WHERE wallet_address = $1", + wallet + ) + .fetch_one(&db.pool) + .await + .expect("Failed to fetch balance"); + + assert_eq!(current_balance.balance, initial_balance); +} + +#[tokio::test] +async fn test_withdraw_nonexistent_wallet() { + let app = TestApp::new().await; + + let wallet = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcf"; + let tx_hash = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabf"; + let payload = json!({ + "wallet_address": wallet, + "amount": 10000000, + "currency": "USDT", + "notes": "Project withdrawal", + "transaction_hash": tx_hash + }); + + let request = Request::post("/withdraw") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(request).await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_withdraw_invalid_amount() { + let app = TestApp::new().await; + + let wallet = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabca"; + let tx_hash = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabc"; + let payload = json!({ + "wallet_address": wallet, + "amount": 0, // Invalid amount + "currency": "USDT", + "notes": "Project withdrawal", + "transaction_hash": tx_hash + }); + + let request = Request::post("/withdraw") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(request).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +}