diff --git a/backend/README.md b/backend/README.md index 991cdf4..4ed59b2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -88,6 +88,107 @@ Configuration is loaded from: - `POST /payments/qr/generate` - Generate QR payment - `POST /payments/nfc/validate` - Validate NFC payment +#### User-to-User Transfers (Protected) + +Direct transfers between two BLINKS users are exposed via the **Transfers** API. These endpoints construct an **unsigned Stellar transaction XDR** that the client signs and submits, keeping funds non-custodial. + +- `POST /transfers/transfers` - Build an unsigned user-to-user transfer XDR +- `GET /transfers/transfers/{id}` - Get transfer details (skeletal, subject to extension) +- `GET /transfers/transfers/{id}/status` - Get transfer status (skeletal, subject to extension) + +##### `POST /transfers/transfers` – Build unsigned XDR for a direct transfer + +**Purpose** + +- Create an **unsigned Stellar transaction XDR** representing a transfer from the authenticated user (`from_user_id`) to another BLINKS user (`to_user_id`). +- Validate that the recipient exists and has a structurally valid Stellar address. +- Support an optional memo for business / reconciliation needs. + +**Authentication** + +- Requires a valid JWT access token. +- The authenticated user is resolved from the token (`sub`) and injected as `AuthenticatedUser` by the auth middleware. + +**Request Body** + +```json +{ + "to_user_id": "alice", + "amount": 1000000, + "asset": "USDC", + "memo": "Rent payment January" +} +``` + +- **`to_user_id`**: Target BLINKS user identifier. Must correspond to an existing row in the `users` table. +- **`amount`**: Integer amount in the smallest unit for the given asset (e.g. 1 USDC = 1_000_000 if using 7 decimals). Must be `> 0`. +- **`asset`**: Logical asset code used by your application (e.g. `USDC`). +- **`memo`** *(optional)*: Free-text memo attached to the logical transfer for downstream reconciliation and user UX. + +**Validation Rules** + +- `amount` must be strictly greater than zero; otherwise the handler returns: + - `400 BAD_REQUEST` with `error="VALIDATION_ERROR"`. +- `to_user_id` must resolve to an existing user: + - Backed by `IdentityService::get_user_by_id`. + - If not found, the handler returns: + - `404 NOT_FOUND` with `error="NOT_FOUND"`. +- The recipient must have a structurally valid Stellar address: + - Current implementation checks that the address is non-empty and starts with `G`. + - If invalid, the handler returns: + - `400 BAD_REQUEST` with `error="VALIDATION_ERROR"` and message `"Recipient has an invalid Stellar address"`. +- The sender (`from_user_id`) is taken from the authenticated JWT subject and resolved via `IdentityService::get_user_wallet` to obtain the sender’s Stellar address. + +**Backend Flow** + +Implementation lives in `backend/src/http/transfers.rs`: + +- Extracts `AuthenticatedUser` (via middleware) to obtain `from_user_id`. +- Uses `IdentityService` to: + - Fetch the sender wallet (`get_user_wallet`) and derive the sender Stellar address. + - Resolve `to_user_id` into a `User` and obtain the recipient Stellar address. +- Performs a lightweight Stellar address validation for the recipient. +- Constructs a `BuildTransactionDto` with: + - `contract_id = "user_to_user_transfer"` – a logical identifier for the user-to-user transfer contract or flow. + - `method = "transfer"` – the contract method being invoked. + - `args` – JSON-encoded argument list containing: + - `from_user_id`, `from_address` + - `to_user_id`, `to_address` + - `asset`, `amount` + - `memo` (optional) +- Invokes `SorobanService::build_transaction(dto)` which returns a **mock unsigned XDR** string (in production this would be a real Stellar/Soroban transaction XDR). +- Synthesizes a **transient transfer identifier** (`Uuid::new_v4()`) and returns it alongside the unsigned XDR. + +**Response** + +```json +{ + "id": "00000000-0000-0000-0000-000000000000", + "from_user_id": "bob", + "to_user_id": "alice", + "amount": 1000000, + "asset": "USDC", + "status": "pending", + "memo": "Rent payment January", + "unsigned_xdr": "mock_xdr_invoke_user_to_user_transfer_transfer_[...]" +} +``` + +- **`id`**: UUID generated server-side for this transfer request. Currently ephemeral until a full persistence layer for transfers is added. +- **`from_user_id`**: The authenticated user (JWT subject). +- **`to_user_id`**: Recipient BLINKS user ID. +- **`amount`**: Requested transfer amount. +- **`asset`**: Asset code. +- **`status`**: Currently fixed to `"pending"` to reflect that the transfer has not yet been signed or submitted. +- **`memo`**: Echoes the request memo, if provided. +- **`unsigned_xdr`**: Base64-encoded (mock) unsigned transaction XDR that the client must sign and submit to the Stellar network. + +**Client Responsibilities** + +- Sign the `unsigned_xdr` with the user’s Stellar private key on the client side. +- Submit the signed XDR to the Stellar network (e.g. via Horizon/Soroban RPC or another backend endpoint). +- Optionally store or correlate the returned `id` and `memo` for user receipts and history views. + #### Admin (Protected, Admin Only) - `GET /admin/dashboard/stats` - Dashboard statistics - `GET /admin/transactions` - Transaction listing diff --git a/backend/src/http/transfers.rs b/backend/src/http/transfers.rs index 9a3f1ba..863d1ea 100644 --- a/backend/src/http/transfers.rs +++ b/backend/src/http/transfers.rs @@ -6,7 +6,13 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; -use crate::{api_error::ApiError, service::ServiceContainer}; +use crate::{ + api_error::ApiError, + middleware::auth::AuthenticatedUser, + models::BuildTransactionDto, + service::soroban_service::TransactionBuilder, + service::ServiceContainer, +}; #[derive(Debug, Serialize)] pub struct TransferResponse { @@ -16,6 +22,9 @@ pub struct TransferResponse { pub amount: i64, pub asset: String, pub status: String, + pub memo: Option, + /// Unsigned transaction XDR for the user-to-user transfer + pub unsigned_xdr: String, } #[derive(Debug, Deserialize)] @@ -26,12 +35,68 @@ pub struct CreateTransferRequest { pub memo: Option, } +fn is_valid_stellar_address(address: &str) -> bool { + // Lightweight validation suitable for current mock addresses + !address.is_empty() && address.starts_with('G') +} + pub async fn create_transfer( - State(_services): State>, - Json(_request): Json, + State(services): State>, + auth_user: AuthenticatedUser, + Json(request): Json, ) -> Result, ApiError> { - // Placeholder implementation - Err(ApiError::NotFound("Not implemented".to_string())) + if request.amount <= 0 { + return Err(ApiError::Validation( + "Amount must be greater than zero".to_string(), + )); + } + + // Resolve sender and recipient wallets and validate recipient + let from_wallet = services + .identity + .get_user_wallet(&auth_user.user_id) + .await?; + + let to_user = services + .identity + .get_user_by_id(&request.to_user_id) + .await?; + + if !is_valid_stellar_address(&to_user.stellar_address) { + return Err(ApiError::Validation( + "Recipient has an invalid Stellar address".to_string(), + )); + } + + // Build an unsigned transaction XDR for the direct transfer + let dto = BuildTransactionDto { + contract_id: "user_to_user_transfer".to_string(), + method: "transfer".to_string(), + args: vec![ + serde_json::json!({ "from_user_id": auth_user.user_id }), + serde_json::json!({ "from_address": from_wallet.address }), + serde_json::json!({ "to_user_id": request.to_user_id }), + serde_json::json!({ "to_address": to_user.stellar_address }), + serde_json::json!({ "asset": request.asset }), + serde_json::json!({ "amount": request.amount }), + serde_json::json!({ "memo": request.memo }), + ], + }; + + let unsigned_xdr = services.soroban.build_transaction(dto).await?; + + let transfer_id = Uuid::new_v4(); + + Ok(Json(TransferResponse { + id: transfer_id, + from_user_id: auth_user.user_id, + to_user_id: request.to_user_id, + amount: request.amount, + asset: request.asset, + status: "pending".to_string(), + memo: request.memo, + unsigned_xdr, + })) } pub async fn get_transfer(