-
Notifications
You must be signed in to change notification settings - Fork 32
withdraw escrow #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
withdraw escrow #110
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,14 @@ | ||
| mod deposit; | ||
| mod domain; | ||
| mod withdraw; | ||
|
|
||
| use axum::{Router, routing::post}; | ||
| pub use domain::*; | ||
|
|
||
| use crate::AppState; | ||
|
|
||
| pub(crate) fn router() -> Router<AppState> { | ||
| Router::new().route("/deposit", post(deposit::deposit_handler)) | ||
| Router::new() | ||
| .route("/deposit", post(deposit::deposit_handler)) | ||
| .route("/withdraw", post(withdraw::withdraw_handler)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, this is looking good 👍 |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AppState>, | ||
| Json(payload): Json<WithdrawalRequest>, | ||
| ) -> Result<StatusCode> { | ||
| payload.validate()?; | ||
| let mut tx = state.db.pool.begin().await?; | ||
|
|
||
| let query_result = sqlx::query!( | ||
| r#" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can cache your query using |
||
| 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" | ||
| ))) | ||
| } | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was a well done implementation, great job man 👍 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tests are good, pretty detailed and decent too. 👍 |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great Job here with the Domain modelling and Validation man 👍