diff --git a/README.md b/README.md index e8acfba..d374bd0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,229 @@ -# FastAPI Application +# Cryptocurrency Exchange Platform -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A comprehensive cryptocurrency exchange (CEX) platform built with FastAPI, featuring secure wallet management, transaction signing, fiat transfers, and crypto trading capabilities. + +## Features + +### 🔐 Authentication & Security +- JWT-based authentication system +- Secure password hashing with bcrypt +- Rate limiting middleware (100 requests/minute) +- Security headers middleware +- Input validation and sanitization + +### 💰 Wallet Management +- Multi-currency crypto wallet support (BTC, ETH, USDT) +- Fiat account management (USD, EUR, GBP) +- Local private key generation and encryption +- Secure wallet address generation + +### 💸 Transaction Handling +- Local transaction signing (no external APIs) +- Crypto send/receive functionality +- Fiat deposit/withdrawal system +- Transaction history and tracking +- Fee calculation and management + +### 🏗️ Architecture +- Clean, modular codebase structure +- SQLAlchemy ORM with Alembic migrations +- Pydantic schemas for data validation +- Service layer pattern +- Comprehensive error handling + +## Tech Stack + +- **Framework**: FastAPI 0.104.1 +- **Database**: SQLite with SQLAlchemy +- **Authentication**: JWT with python-jose +- **Cryptography**: ecdsa, bitcoin, web3, cryptography +- **Validation**: Pydantic +- **Server**: Uvicorn +- **Code Quality**: Ruff + +## Project Structure + +``` +├── app/ +│ ├── api/ # API endpoints +│ │ ├── auth.py # Authentication routes +│ │ ├── wallets.py # Wallet management routes +│ │ └── transactions.py # Transaction routes +│ ├── core/ # Core configuration +│ │ ├── config.py # Application settings +│ │ ├── security.py # Security utilities +│ │ └── middleware.py # Security middleware +│ ├── db/ # Database configuration +│ │ ├── base.py # SQLAlchemy base +│ │ └── session.py # Database session +│ ├── models/ # Database models +│ │ ├── user.py # User model +│ │ ├── wallet.py # Wallet & fiat account models +│ │ └── transaction.py # Transaction models +│ ├── schemas/ # Pydantic schemas +│ ├── services/ # Business logic +│ │ ├── auth.py # Authentication service +│ │ ├── wallet.py # Wallet service +│ │ └── transaction.py # Transaction service +│ ├── utils/ # Utilities +│ │ ├── crypto.py # Cryptocurrency utilities +│ │ └── validation.py # Input validation +│ └── storage/ # Application storage +│ ├── db/ # SQLite database +│ └── logs/ # Application logs +├── alembic/ # Database migrations +├── requirements.txt # Python dependencies +├── main.py # Application entry point +└── .env.example # Environment variables template +``` + +## Installation & Setup + +### 1. Clone the repository +```bash +git clone +cd cryptocurrencyexchangeplatform-vgi538 +``` + +### 2. Install dependencies +```bash +pip install -r requirements.txt +``` + +### 3. Environment Configuration +```bash +cp .env.example .env +# Edit .env file with your configuration +``` + +**Required Environment Variables:** +- `SECRET_KEY`: JWT secret key (use a long, random string in production) +- `DATABASE_URL`: SQLite database path +- `BTC_NETWORK`: Bitcoin network (mainnet/testnet) +- `ETH_NETWORK`: Ethereum network (mainnet/goerli) + +### 4. Database Setup +```bash +# Run migrations +alembic upgrade head +``` + +### 5. Run the application +```bash +# Development mode +uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +# Production mode +python main.py +``` + +## API Documentation + +Once running, access the interactive API documentation: +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc +- **OpenAPI JSON**: http://localhost:8000/openapi.json + +## API Endpoints + +### Authentication +- `POST /auth/register` - User registration +- `POST /auth/login` - User login +- `GET /auth/me` - Get current user info + +### Wallets +- `POST /wallets/crypto` - Create crypto wallet +- `POST /wallets/fiat` - Create fiat account +- `GET /wallets/crypto` - Get user's crypto wallets +- `GET /wallets/fiat` - Get user's fiat accounts +- `GET /wallets/crypto/{wallet_id}` - Get specific crypto wallet +- `GET /wallets/fiat/{account_id}` - Get specific fiat account + +### Transactions +- `POST /transactions/crypto/send` - Send cryptocurrency +- `POST /transactions/fiat/deposit` - Deposit fiat currency +- `POST /transactions/fiat/withdraw` - Withdraw fiat currency +- `GET /transactions/crypto` - Get crypto transaction history +- `GET /transactions/fiat` - Get fiat transaction history +- `GET /transactions/crypto/{transaction_id}` - Get specific transaction + +### System +- `GET /` - Application info +- `GET /health` - Health check endpoint + +## Security Features + +### 🔒 Private Key Management +- Private keys are generated locally using secure random number generation +- Keys are encrypted using PBKDF2 with SHA-256 and stored encrypted in database +- Encryption uses user-specific salts and application secret key + +### 🛡️ Transaction Security +- All transactions are signed locally using the wallet's private key +- No external APIs required for transaction signing +- Transaction data integrity verified through cryptographic signatures + +### 🚦 Rate Limiting +- 100 requests per minute per IP address +- Automatic cleanup of rate limit storage +- Configurable limits per endpoint + +### 🔐 Input Validation +- Comprehensive input validation for all endpoints +- Email, password, and phone number format validation +- Transaction amount and currency validation +- Address format validation for different cryptocurrencies + +## Supported Cryptocurrencies + +- **Bitcoin (BTC)**: Testnet and Mainnet support +- **Ethereum (ETH)**: Goerli and Mainnet support +- **Tether (USDT)**: ERC-20 token on Ethereum network + +## Supported Fiat Currencies + +- USD (US Dollar) +- EUR (Euro) +- GBP (British Pound) + +## Error Handling + +The application includes comprehensive error handling: +- HTTP status codes for different error types +- Detailed error messages for debugging +- Global exception handler for unhandled errors +- Validation errors with specific field information + +## Development + +### Code Quality +```bash +# Run linting and auto-fix +ruff check --fix . +``` + +### Testing +```bash +# Run tests (when implemented) +pytest +``` + +## Production Deployment + +### Environment Variables for Production +- Set `DEBUG=False` +- Use a strong, unique `SECRET_KEY` +- Configure proper database URL +- Set appropriate CORS origins +- Use production cryptocurrency networks + +### Security Considerations +- Use HTTPS in production +- Implement proper key management +- Set up database backups +- Monitor transaction activity +- Implement additional KYC/AML compliance + +## License + +This project is developed for educational and development purposes. Ensure compliance with financial regulations in your jurisdiction before production use. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..017f263 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..8cb0ada --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,57 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import sys +import os + +# Add the project root to Python path +sys.path.append(os.path.dirname(os.path.dirname(__file__))) + +from app.db.base import Base +# Import models for Alembic to detect schema changes +from app.models.user import User # noqa: F401 +from app.models.wallet import Wallet, FiatAccount # noqa: F401 +from app.models.transaction import Transaction, FiatTransaction # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..335f1b0 --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,127 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2025-06-20 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create users table + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_verified', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('kyc_level', sa.Integer(), nullable=True), + sa.Column('phone_number', sa.String(), nullable=True), + sa.Column('country', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + + # Create wallets table + op.create_table('wallets', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(), nullable=False), + sa.Column('address', sa.String(), nullable=False), + sa.Column('private_key_encrypted', sa.String(), nullable=False), + sa.Column('balance', sa.Float(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_wallets_address'), 'wallets', ['address'], unique=True) + op.create_index(op.f('ix_wallets_id'), 'wallets', ['id'], unique=False) + + # Create fiat_accounts table + op.create_table('fiat_accounts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(), nullable=False), + sa.Column('balance', sa.Float(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_fiat_accounts_id'), 'fiat_accounts', ['id'], unique=False) + + # Create transactions table + op.create_table('transactions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('from_wallet_id', sa.Integer(), nullable=True), + sa.Column('to_wallet_id', sa.Integer(), nullable=True), + sa.Column('transaction_hash', sa.String(), nullable=True), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('fee', sa.Float(), nullable=True), + sa.Column('currency', sa.String(), nullable=False), + sa.Column('transaction_type', sa.Enum('SEND', 'RECEIVE', 'DEPOSIT', 'WITHDRAWAL', name='transactiontype'), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'CONFIRMED', 'FAILED', 'CANCELLED', name='transactionstatus'), nullable=True), + sa.Column('from_address', sa.String(), nullable=True), + sa.Column('to_address', sa.String(), nullable=False), + sa.Column('block_number', sa.Integer(), nullable=True), + sa.Column('confirmations', sa.Integer(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['from_wallet_id'], ['wallets.id'], ), + sa.ForeignKeyConstraint(['to_wallet_id'], ['wallets.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_transactions_id'), 'transactions', ['id'], unique=False) + op.create_index(op.f('ix_transactions_transaction_hash'), 'transactions', ['transaction_hash'], unique=True) + + # Create fiat_transactions table + op.create_table('fiat_transactions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('account_id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('currency', sa.String(), nullable=False), + sa.Column('transaction_type', sa.Enum('SEND', 'RECEIVE', 'DEPOSIT', 'WITHDRAWAL', name='transactiontype'), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'CONFIRMED', 'FAILED', 'CANCELLED', name='transactionstatus'), nullable=True), + sa.Column('bank_reference', sa.String(), nullable=True), + sa.Column('payment_method', sa.String(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['account_id'], ['fiat_accounts.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_fiat_transactions_id'), 'fiat_transactions', ['id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_fiat_transactions_id'), table_name='fiat_transactions') + op.drop_table('fiat_transactions') + op.drop_index(op.f('ix_transactions_transaction_hash'), table_name='transactions') + op.drop_index(op.f('ix_transactions_id'), table_name='transactions') + op.drop_table('transactions') + op.drop_index(op.f('ix_fiat_accounts_id'), table_name='fiat_accounts') + op.drop_table('fiat_accounts') + op.drop_index(op.f('ix_wallets_id'), table_name='wallets') + op.drop_index(op.f('ix_wallets_address'), table_name='wallets') + op.drop_table('wallets') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..7b5e86d --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from app.schemas.user import UserCreate, UserResponse, UserLogin, Token +from app.services.auth import AuthService, get_current_user +from app.core.security import create_access_token +from app.db.session import get_db +from app.models.user import User + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post("/register", response_model=UserResponse) +def register(user_data: UserCreate, db: Session = Depends(get_db)): + auth_service = AuthService(db) + user = auth_service.create_user(user_data) + return user + + +@router.post("/login", response_model=Token) +def login(login_data: UserLogin, db: Session = Depends(get_db)): + auth_service = AuthService(db) + user = auth_service.authenticate_user(login_data) + + access_token = create_access_token(data={"sub": user.email}) + return {"access_token": access_token, "token_type": "bearer"} + + +@router.get("/me", response_model=UserResponse) +def get_current_user_info(current_user: User = Depends(get_current_user)): + return current_user \ No newline at end of file diff --git a/app/api/transactions.py b/app/api/transactions.py new file mode 100644 index 0000000..7122ee0 --- /dev/null +++ b/app/api/transactions.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from typing import List +from app.schemas.transaction import TransactionCreate, TransactionResponse, FiatTransactionCreate, FiatTransactionResponse +from app.services.transaction import TransactionService +from app.services.auth import get_current_user +from app.db.session import get_db +from app.models.user import User + +router = APIRouter(prefix="/transactions", tags=["Transactions"]) + + +@router.post("/crypto/send", response_model=TransactionResponse) +def send_crypto( + transaction_data: TransactionCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + transaction_service = TransactionService(db) + transaction = transaction_service.send_crypto(current_user, transaction_data) + return transaction + + +@router.post("/fiat/deposit", response_model=FiatTransactionResponse) +def deposit_fiat( + transaction_data: FiatTransactionCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + transaction_service = TransactionService(db) + transaction = transaction_service.deposit_fiat(current_user, transaction_data) + return transaction + + +@router.post("/fiat/withdraw", response_model=FiatTransactionResponse) +def withdraw_fiat( + transaction_data: FiatTransactionCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + transaction_service = TransactionService(db) + transaction = transaction_service.withdraw_fiat(current_user, transaction_data) + return transaction + + +@router.get("/crypto", response_model=List[TransactionResponse]) +def get_crypto_transactions( + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + transaction_service = TransactionService(db) + transactions = transaction_service.get_user_transactions(current_user, limit, offset) + return transactions + + +@router.get("/fiat", response_model=List[FiatTransactionResponse]) +def get_fiat_transactions( + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + transaction_service = TransactionService(db) + transactions = transaction_service.get_user_fiat_transactions(current_user, limit, offset) + return transactions + + +@router.get("/crypto/{transaction_id}", response_model=TransactionResponse) +def get_crypto_transaction( + transaction_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + transaction_service = TransactionService(db) + transaction = transaction_service.get_transaction_by_id(transaction_id, current_user) + if not transaction: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Transaction not found" + ) + return transaction \ No newline at end of file diff --git a/app/api/wallets.py b/app/api/wallets.py new file mode 100644 index 0000000..2a2aeb9 --- /dev/null +++ b/app/api/wallets.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from app.schemas.wallet import WalletCreate, WalletResponse, FiatAccountCreate, FiatAccountResponse +from app.services.wallet import WalletService +from app.services.auth import get_current_user +from app.db.session import get_db +from app.models.user import User + +router = APIRouter(prefix="/wallets", tags=["Wallets"]) + + +@router.post("/crypto", response_model=WalletResponse) +def create_crypto_wallet( + wallet_data: WalletCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + wallet_service = WalletService(db) + wallet = wallet_service.create_crypto_wallet(current_user, wallet_data) + return wallet + + +@router.post("/fiat", response_model=FiatAccountResponse) +def create_fiat_account( + account_data: FiatAccountCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + wallet_service = WalletService(db) + account = wallet_service.create_fiat_account(current_user, account_data) + return account + + +@router.get("/crypto", response_model=List[WalletResponse]) +def get_crypto_wallets( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + wallet_service = WalletService(db) + wallets = wallet_service.get_user_wallets(current_user) + return wallets + + +@router.get("/fiat", response_model=List[FiatAccountResponse]) +def get_fiat_accounts( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + wallet_service = WalletService(db) + accounts = wallet_service.get_user_fiat_accounts(current_user) + return accounts + + +@router.get("/crypto/{wallet_id}", response_model=WalletResponse) +def get_crypto_wallet( + wallet_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + wallet_service = WalletService(db) + wallet = wallet_service.get_wallet_by_id(wallet_id, current_user) + if not wallet: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Wallet not found" + ) + return wallet + + +@router.get("/fiat/{account_id}", response_model=FiatAccountResponse) +def get_fiat_account( + account_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + wallet_service = WalletService(db) + account = wallet_service.get_fiat_account_by_id(account_id, current_user) + if not account: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Fiat account not found" + ) + return account \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..72b2079 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,35 @@ +from pydantic_settings import BaseSettings +import os + + +class Settings(BaseSettings): + app_name: str = "Cryptocurrency Exchange Platform" + app_version: str = "1.0.0" + debug: bool = True + + # Security + secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + + # Database + database_url: str = "sqlite:////app/storage/db/db.sqlite" + + # CORS + allowed_origins: list = ["*"] + + # Crypto settings + btc_network: str = "testnet" # mainnet or testnet + eth_network: str = "goerli" # mainnet or goerli + + # Fiat settings + min_fiat_deposit: float = 10.0 + max_fiat_deposit: float = 10000.0 + min_fiat_withdrawal: float = 10.0 + max_fiat_withdrawal: float = 5000.0 + + class Config: + env_file = ".env" + + +settings = Settings() \ No newline at end of file diff --git a/app/core/middleware.py b/app/core/middleware.py new file mode 100644 index 0000000..58d2cd7 --- /dev/null +++ b/app/core/middleware.py @@ -0,0 +1,72 @@ +from fastapi import Request, status +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +import time +import logging +from typing import Dict +from collections import defaultdict + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Rate limiting storage +rate_limit_storage: Dict[str, Dict] = defaultdict(lambda: {"count": 0, "reset_time": 0}) + + +class RateLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, calls: int = 100, period: int = 60): + super().__init__(app) + self.calls = calls + self.period = period + + async def dispatch(self, request: Request, call_next): + client_ip = request.client.host + current_time = time.time() + + # Clean up old entries + if current_time > rate_limit_storage[client_ip]["reset_time"]: + rate_limit_storage[client_ip] = {"count": 0, "reset_time": current_time + self.period} + + # Check rate limit + if rate_limit_storage[client_ip]["count"] >= self.calls: + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit exceeded"} + ) + + # Increment counter + rate_limit_storage[client_ip]["count"] += 1 + + response = await call_next(request) + return response + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + + # Add security headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + return response + + +class LoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start_time = time.time() + + # Log request + logger.info(f"Request: {request.method} {request.url}") + + response = await call_next(request) + + # Log response + process_time = time.time() - start_time + logger.info(f"Response: {response.status_code} - {process_time:.4f}s") + + return response \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..810cf9a --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,37 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + return encoded_jwt + + +def verify_token(token: str): + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + email: str = payload.get("sub") + if email is None: + return None + return email + except JWTError: + return None + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..7c2377a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..a09a180 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from pathlib import Path + +# Ensure storage directory exists +DB_DIR = Path("/app/storage/db") +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ca08a20 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,13 @@ +from app.models.user import User +from app.models.wallet import Wallet, FiatAccount +from app.models.transaction import Transaction, FiatTransaction, TransactionStatus, TransactionType + +__all__ = [ + "User", + "Wallet", + "FiatAccount", + "Transaction", + "FiatTransaction", + "TransactionStatus", + "TransactionType" +] \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..793eaca --- /dev/null +++ b/app/models/transaction.py @@ -0,0 +1,73 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Enum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +import enum +from app.db.base import Base + + +class TransactionStatus(enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class TransactionType(enum.Enum): + SEND = "send" + RECEIVE = "receive" + DEPOSIT = "deposit" + WITHDRAWAL = "withdrawal" + + +class Transaction(Base): + __tablename__ = "transactions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + from_wallet_id = Column(Integer, ForeignKey("wallets.id"), nullable=True) + to_wallet_id = Column(Integer, ForeignKey("wallets.id"), nullable=True) + + transaction_hash = Column(String, unique=True, nullable=True) + amount = Column(Float, nullable=False) + fee = Column(Float, default=0.0) + currency = Column(String, nullable=False) + + transaction_type = Column(Enum(TransactionType), nullable=False) + status = Column(Enum(TransactionStatus), default=TransactionStatus.PENDING) + + from_address = Column(String, nullable=True) + to_address = Column(String, nullable=False) + + block_number = Column(Integer, nullable=True) + confirmations = Column(Integer, default=0) + + notes = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + user = relationship("User", back_populates="transactions") + from_wallet = relationship("Wallet", foreign_keys=[from_wallet_id], back_populates="transactions_sent") + to_wallet = relationship("Wallet", foreign_keys=[to_wallet_id], back_populates="transactions_received") + + +class FiatTransaction(Base): + __tablename__ = "fiat_transactions" + + id = Column(Integer, primary_key=True, index=True) + account_id = Column(Integer, ForeignKey("fiat_accounts.id"), nullable=False) + + amount = Column(Float, nullable=False) + currency = Column(String, nullable=False) + transaction_type = Column(Enum(TransactionType), nullable=False) + status = Column(Enum(TransactionStatus), default=TransactionStatus.PENDING) + + bank_reference = Column(String, nullable=True) + payment_method = Column(String, nullable=True) # bank_transfer, card, etc. + + notes = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + account = relationship("FiatAccount", back_populates="deposits") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..3de8801 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + full_name = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # KYC fields + kyc_level = Column(Integer, default=0) # 0: none, 1: basic, 2: advanced + phone_number = Column(String, nullable=True) + country = Column(String, nullable=True) + + # Relationships + wallets = relationship("Wallet", back_populates="owner") + fiat_accounts = relationship("FiatAccount", back_populates="owner") + transactions = relationship("Transaction", back_populates="user") \ No newline at end of file diff --git a/app/models/wallet.py b/app/models/wallet.py new file mode 100644 index 0000000..5b5d7d8 --- /dev/null +++ b/app/models/wallet.py @@ -0,0 +1,39 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.db.base import Base + + +class Wallet(Base): + __tablename__ = "wallets" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + currency = Column(String, nullable=False) # BTC, ETH, USDT + address = Column(String, unique=True, nullable=False) + private_key_encrypted = Column(String, nullable=False) + balance = Column(Float, default=0.0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + owner = relationship("User", back_populates="wallets") + transactions_sent = relationship("Transaction", foreign_keys="Transaction.from_wallet_id", back_populates="from_wallet") + transactions_received = relationship("Transaction", foreign_keys="Transaction.to_wallet_id", back_populates="to_wallet") + + +class FiatAccount(Base): + __tablename__ = "fiat_accounts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + currency = Column(String, nullable=False) # USD, EUR, GBP + balance = Column(Float, default=0.0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + owner = relationship("User", back_populates="fiat_accounts") + deposits = relationship("FiatTransaction", foreign_keys="FiatTransaction.account_id", back_populates="account") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..f50ea56 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,20 @@ +from app.schemas.user import UserCreate, UserResponse, UserLogin, Token, TokenData, UserUpdate +from app.schemas.wallet import WalletCreate, WalletResponse, FiatAccountCreate, FiatAccountResponse +from app.schemas.transaction import TransactionCreate, TransactionResponse, FiatTransactionCreate, FiatTransactionResponse + +__all__ = [ + "UserCreate", + "UserResponse", + "UserLogin", + "Token", + "TokenData", + "UserUpdate", + "WalletCreate", + "WalletResponse", + "FiatAccountCreate", + "FiatAccountResponse", + "TransactionCreate", + "TransactionResponse", + "FiatTransactionCreate", + "FiatTransactionResponse" +] \ No newline at end of file diff --git a/app/schemas/transaction.py b/app/schemas/transaction.py new file mode 100644 index 0000000..413faaa --- /dev/null +++ b/app/schemas/transaction.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from app.models.transaction import TransactionStatus, TransactionType + + +class TransactionBase(BaseModel): + amount: float + currency: str + to_address: str + notes: Optional[str] = None + + +class TransactionCreate(TransactionBase): + from_wallet_id: Optional[int] = None + + +class TransactionResponse(TransactionBase): + id: int + user_id: int + from_wallet_id: Optional[int] + to_wallet_id: Optional[int] + transaction_hash: Optional[str] + fee: float + transaction_type: TransactionType + status: TransactionStatus + from_address: Optional[str] + block_number: Optional[int] + confirmations: int + created_at: datetime + + class Config: + from_attributes = True + + +class FiatTransactionBase(BaseModel): + amount: float + currency: str + payment_method: Optional[str] = None + notes: Optional[str] = None + + +class FiatTransactionCreate(FiatTransactionBase): + transaction_type: TransactionType + + +class FiatTransactionResponse(FiatTransactionBase): + id: int + account_id: int + transaction_type: TransactionType + status: TransactionStatus + bank_reference: Optional[str] + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..6f4e1d6 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + + +class UserBase(BaseModel): + email: EmailStr + full_name: str + phone_number: Optional[str] = None + country: Optional[str] = None + + +class UserCreate(UserBase): + password: str + + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + phone_number: Optional[str] = None + country: Optional[str] = None + + +class UserResponse(UserBase): + id: int + is_active: bool + is_verified: bool + kyc_level: int + created_at: datetime + + class Config: + from_attributes = True + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + email: Optional[str] = None \ No newline at end of file diff --git a/app/schemas/wallet.py b/app/schemas/wallet.py new file mode 100644 index 0000000..30eadc2 --- /dev/null +++ b/app/schemas/wallet.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel +from datetime import datetime + + +class WalletBase(BaseModel): + currency: str + + +class WalletCreate(WalletBase): + pass + + +class WalletResponse(WalletBase): + id: int + address: str + balance: float + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class FiatAccountBase(BaseModel): + currency: str + + +class FiatAccountCreate(FiatAccountBase): + pass + + +class FiatAccountResponse(FiatAccountBase): + id: int + balance: float + is_active: bool + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..0452c3e --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,80 @@ +from sqlalchemy.orm import Session +from fastapi import HTTPException, status, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.models.user import User +from app.schemas.user import UserCreate, UserLogin +from app.core.security import verify_password, get_password_hash, verify_token +from app.db.session import get_db + +security = HTTPBearer() + + +class AuthService: + def __init__(self, db: Session): + self.db = db + + def create_user(self, user_data: UserCreate) -> User: + # Check if user already exists + existing_user = self.db.query(User).filter(User.email == user_data.email).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Create new user + hashed_password = get_password_hash(user_data.password) + db_user = User( + email=user_data.email, + hashed_password=hashed_password, + full_name=user_data.full_name, + phone_number=user_data.phone_number, + country=user_data.country + ) + + self.db.add(db_user) + self.db.commit() + self.db.refresh(db_user) + return db_user + + def authenticate_user(self, login_data: UserLogin) -> User: + user = self.db.query(User).filter(User.email == login_data.email).first() + if not user or not verify_password(login_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Account is inactive" + ) + + return user + + def get_user_by_email(self, email: str) -> User: + return self.db.query(User).filter(User.email == email).first() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + token = credentials.credentials + email = verify_token(token) + if email is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + auth_service = AuthService(db) + user = auth_service.get_user_by_email(email) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + return user \ No newline at end of file diff --git a/app/services/transaction.py b/app/services/transaction.py new file mode 100644 index 0000000..781bf13 --- /dev/null +++ b/app/services/transaction.py @@ -0,0 +1,302 @@ +from sqlalchemy.orm import Session +from fastapi import HTTPException, status +from typing import List, Optional +from app.models.transaction import Transaction, FiatTransaction, TransactionStatus, TransactionType +from app.models.wallet import Wallet, FiatAccount +from app.models.user import User +from app.schemas.transaction import TransactionCreate, FiatTransactionCreate +from app.services.wallet import WalletService +from app.utils.crypto import WalletFactory +from app.core.config import settings +import uuid +import hashlib + + +class TransactionService: + def __init__(self, db: Session): + self.db = db + self.wallet_service = WalletService(db) + + def send_crypto(self, user: User, transaction_data: TransactionCreate) -> Transaction: + # Get sender wallet + from_wallet = self.wallet_service.get_wallet_by_id(transaction_data.from_wallet_id, user) + if not from_wallet: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Wallet not found" + ) + + # Validate currency match + if from_wallet.currency != transaction_data.currency: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Currency mismatch" + ) + + # Check balance + if from_wallet.balance < transaction_data.amount: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Insufficient balance" + ) + + # Validate recipient address + if not WalletFactory.verify_address(transaction_data.currency, transaction_data.to_address): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid recipient address" + ) + + # Calculate fee (simplified - in production, use dynamic fee calculation) + fee = self._calculate_transaction_fee(transaction_data.currency, transaction_data.amount) + + if from_wallet.balance < (transaction_data.amount + fee): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Insufficient balance including fees" + ) + + try: + # Create transaction record + transaction = Transaction( + user_id=user.id, + from_wallet_id=from_wallet.id, + amount=transaction_data.amount, + fee=fee, + currency=transaction_data.currency, + transaction_type=TransactionType.SEND, + status=TransactionStatus.PENDING, + from_address=from_wallet.address, + to_address=transaction_data.to_address, + notes=transaction_data.notes + ) + + self.db.add(transaction) + self.db.flush() # Get the transaction ID + + # Sign and broadcast transaction + signed_hash = self._sign_transaction(user, from_wallet, transaction) + transaction.transaction_hash = signed_hash + + # Update wallet balance + new_balance = from_wallet.balance - transaction_data.amount - fee + self.wallet_service.update_wallet_balance(from_wallet.id, new_balance) + + # In production, you would broadcast to the network here + # For now, we'll mark as confirmed + transaction.status = TransactionStatus.CONFIRMED + transaction.confirmations = 1 + + self.db.commit() + self.db.refresh(transaction) + + return transaction + + except Exception as e: + self.db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Transaction failed: {str(e)}" + ) + + def receive_crypto(self, user: User, amount: float, currency: str, from_address: str, to_wallet_id: int) -> Transaction: + # Get recipient wallet + to_wallet = self.wallet_service.get_wallet_by_id(to_wallet_id, user) + if not to_wallet: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Wallet not found" + ) + + # Create transaction record + transaction = Transaction( + user_id=user.id, + to_wallet_id=to_wallet.id, + amount=amount, + currency=currency, + transaction_type=TransactionType.RECEIVE, + status=TransactionStatus.CONFIRMED, + from_address=from_address, + to_address=to_wallet.address, + confirmations=1 + ) + + self.db.add(transaction) + + # Update wallet balance + new_balance = to_wallet.balance + amount + self.wallet_service.update_wallet_balance(to_wallet.id, new_balance) + + self.db.commit() + self.db.refresh(transaction) + + return transaction + + def deposit_fiat(self, user: User, transaction_data: FiatTransactionCreate) -> FiatTransaction: + # Get fiat account + fiat_account = self.db.query(FiatAccount).filter( + FiatAccount.user_id == user.id, + FiatAccount.currency == transaction_data.currency + ).first() + + if not fiat_account: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Fiat account not found" + ) + + # Validate deposit limits + if transaction_data.amount < settings.min_fiat_deposit: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Minimum deposit amount is {settings.min_fiat_deposit}" + ) + + if transaction_data.amount > settings.max_fiat_deposit: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum deposit amount is {settings.max_fiat_deposit}" + ) + + # Create fiat transaction + transaction = FiatTransaction( + account_id=fiat_account.id, + amount=transaction_data.amount, + currency=transaction_data.currency, + transaction_type=TransactionType.DEPOSIT, + status=TransactionStatus.PENDING, + payment_method=transaction_data.payment_method, + notes=transaction_data.notes, + bank_reference=str(uuid.uuid4()) + ) + + self.db.add(transaction) + self.db.commit() + self.db.refresh(transaction) + + return transaction + + def withdraw_fiat(self, user: User, transaction_data: FiatTransactionCreate) -> FiatTransaction: + # Get fiat account + fiat_account = self.db.query(FiatAccount).filter( + FiatAccount.user_id == user.id, + FiatAccount.currency == transaction_data.currency + ).first() + + if not fiat_account: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Fiat account not found" + ) + + # Check balance + if fiat_account.balance < transaction_data.amount: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Insufficient balance" + ) + + # Validate withdrawal limits + if transaction_data.amount < settings.min_fiat_withdrawal: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Minimum withdrawal amount is {settings.min_fiat_withdrawal}" + ) + + if transaction_data.amount > settings.max_fiat_withdrawal: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum withdrawal amount is {settings.max_fiat_withdrawal}" + ) + + # Create fiat transaction + transaction = FiatTransaction( + account_id=fiat_account.id, + amount=transaction_data.amount, + currency=transaction_data.currency, + transaction_type=TransactionType.WITHDRAWAL, + status=TransactionStatus.PENDING, + payment_method=transaction_data.payment_method, + notes=transaction_data.notes, + bank_reference=str(uuid.uuid4()) + ) + + self.db.add(transaction) + + # Update fiat account balance + new_balance = fiat_account.balance - transaction_data.amount + self.wallet_service.update_fiat_balance(fiat_account.id, new_balance) + + self.db.commit() + self.db.refresh(transaction) + + return transaction + + def get_user_transactions(self, user: User, limit: int = 50, offset: int = 0) -> List[Transaction]: + return self.db.query(Transaction).filter( + Transaction.user_id == user.id + ).order_by(Transaction.created_at.desc()).offset(offset).limit(limit).all() + + def get_user_fiat_transactions(self, user: User, limit: int = 50, offset: int = 0) -> List[FiatTransaction]: + fiat_accounts = self.wallet_service.get_user_fiat_accounts(user) + account_ids = [acc.id for acc in fiat_accounts] + + return self.db.query(FiatTransaction).filter( + FiatTransaction.account_id.in_(account_ids) + ).order_by(FiatTransaction.created_at.desc()).offset(offset).limit(limit).all() + + def get_transaction_by_id(self, transaction_id: int, user: User) -> Optional[Transaction]: + return self.db.query(Transaction).filter( + Transaction.id == transaction_id, + Transaction.user_id == user.id + ).first() + + def _sign_transaction(self, user: User, from_wallet: Wallet, transaction: Transaction) -> str: + """Sign the transaction using the wallet's private key""" + try: + # Get private key + private_key = self.wallet_service.get_private_key(from_wallet, user) + + # Prepare transaction data for signing + transaction_data = { + 'from': from_wallet.address, + 'to': transaction.to_address, + 'amount': transaction.amount, + 'fee': transaction.fee, + 'nonce': transaction.id, + 'currency': transaction.currency + } + + # Sign transaction + signature = WalletFactory.sign_transaction( + transaction.currency, + private_key, + transaction_data + ) + + # Generate transaction hash + tx_hash = hashlib.sha256( + f"{signature}_{transaction.id}_{transaction.from_address}_{transaction.to_address}".encode() + ).hexdigest() + + return tx_hash + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to sign transaction: {str(e)}" + ) + + def _calculate_transaction_fee(self, currency: str, amount: float) -> float: + """Calculate transaction fee based on currency and amount""" + # Simplified fee calculation - in production, use dynamic fee estimation + fee_rates = { + 'BTC': 0.0001, # Fixed fee + 'ETH': 0.002, # 0.2% of amount + 'USDT': 0.001 # 0.1% of amount + } + + if currency == 'BTC': + return fee_rates['BTC'] + else: + return amount * fee_rates.get(currency, 0.001) \ No newline at end of file diff --git a/app/services/wallet.py b/app/services/wallet.py new file mode 100644 index 0000000..8740fc3 --- /dev/null +++ b/app/services/wallet.py @@ -0,0 +1,134 @@ +from sqlalchemy.orm import Session +from fastapi import HTTPException, status +from typing import List, Optional +from app.models.wallet import Wallet, FiatAccount +from app.models.user import User +from app.schemas.wallet import WalletCreate, FiatAccountCreate +from app.utils.crypto import WalletFactory, CryptoUtils +from app.core.config import settings + + +class WalletService: + def __init__(self, db: Session): + self.db = db + + def create_crypto_wallet(self, user: User, wallet_data: WalletCreate) -> Wallet: + # Check if user already has a wallet for this currency + existing_wallet = self.db.query(Wallet).filter( + Wallet.user_id == user.id, + Wallet.currency == wallet_data.currency.upper() + ).first() + + if existing_wallet: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Wallet for {wallet_data.currency} already exists" + ) + + try: + # Generate wallet + private_key, address = WalletFactory.create_wallet(wallet_data.currency) + + # Encrypt private key + encryption_password = f"{user.email}_{settings.secret_key}" + encrypted_private_key = CryptoUtils.encrypt_private_key(private_key, encryption_password) + + # Create wallet record + db_wallet = Wallet( + user_id=user.id, + currency=wallet_data.currency.upper(), + address=address, + private_key_encrypted=encrypted_private_key, + balance=0.0 + ) + + self.db.add(db_wallet) + self.db.commit() + self.db.refresh(db_wallet) + + return db_wallet + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create wallet: {str(e)}" + ) + + def create_fiat_account(self, user: User, account_data: FiatAccountCreate) -> FiatAccount: + # Check if user already has an account for this currency + existing_account = self.db.query(FiatAccount).filter( + FiatAccount.user_id == user.id, + FiatAccount.currency == account_data.currency.upper() + ).first() + + if existing_account: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Fiat account for {account_data.currency} already exists" + ) + + db_account = FiatAccount( + user_id=user.id, + currency=account_data.currency.upper(), + balance=0.0 + ) + + self.db.add(db_account) + self.db.commit() + self.db.refresh(db_account) + + return db_account + + def get_user_wallets(self, user: User) -> List[Wallet]: + return self.db.query(Wallet).filter( + Wallet.user_id == user.id, + Wallet.is_active + ).all() + + def get_user_fiat_accounts(self, user: User) -> List[FiatAccount]: + return self.db.query(FiatAccount).filter( + FiatAccount.user_id == user.id, + FiatAccount.is_active + ).all() + + def get_wallet_by_id(self, wallet_id: int, user: User) -> Optional[Wallet]: + return self.db.query(Wallet).filter( + Wallet.id == wallet_id, + Wallet.user_id == user.id + ).first() + + def get_fiat_account_by_id(self, account_id: int, user: User) -> Optional[FiatAccount]: + return self.db.query(FiatAccount).filter( + FiatAccount.id == account_id, + FiatAccount.user_id == user.id + ).first() + + def update_wallet_balance(self, wallet_id: int, new_balance: float) -> bool: + wallet = self.db.query(Wallet).filter(Wallet.id == wallet_id).first() + if wallet: + wallet.balance = new_balance + self.db.commit() + return True + return False + + def update_fiat_balance(self, account_id: int, new_balance: float) -> bool: + account = self.db.query(FiatAccount).filter(FiatAccount.id == account_id).first() + if account: + account.balance = new_balance + self.db.commit() + return True + return False + + def get_private_key(self, wallet: Wallet, user: User) -> str: + try: + encryption_password = f"{user.email}_{settings.secret_key}" + private_key = CryptoUtils.decrypt_private_key( + wallet.private_key_encrypted, + encryption_password + ) + return private_key + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to decrypt private key" + ) \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/crypto.py b/app/utils/crypto.py new file mode 100644 index 0000000..c77ea30 --- /dev/null +++ b/app/utils/crypto.py @@ -0,0 +1,151 @@ +import hashlib +import secrets +import ecdsa +from bitcoin import privtopub, pubtoaddr +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +import base64 +import os +from typing import Tuple, Dict +from web3 import Web3 + + +class CryptoUtils: + @staticmethod + def generate_encryption_key(password: str, salt: bytes = None) -> bytes: + if salt is None: + salt = os.urandom(16) + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + ) + key = base64.urlsafe_b64encode(kdf.derive(password.encode())) + return key + + @staticmethod + def encrypt_private_key(private_key: str, password: str) -> str: + salt = os.urandom(16) + key = CryptoUtils.generate_encryption_key(password, salt) + f = Fernet(key) + encrypted = f.encrypt(private_key.encode()) + # Combine salt and encrypted data + return base64.b64encode(salt + encrypted).decode() + + @staticmethod + def decrypt_private_key(encrypted_key: str, password: str) -> str: + data = base64.b64decode(encrypted_key.encode()) + salt = data[:16] + encrypted = data[16:] + + key = CryptoUtils.generate_encryption_key(password, salt) + f = Fernet(key) + decrypted = f.decrypt(encrypted) + return decrypted.decode() + + +class BitcoinWallet: + @staticmethod + def generate_wallet() -> Tuple[str, str]: + # Generate private key + private_key = secrets.randbits(256) + private_key_hex = hex(private_key)[2:].zfill(64) + + # Generate public key and address + public_key = privtopub(private_key_hex) + address = pubtoaddr(public_key) + + return private_key_hex, address + + @staticmethod + def sign_transaction(private_key_hex: str, transaction_data: Dict) -> str: + # Simplified transaction signing for Bitcoin + # In production, use proper Bitcoin transaction libraries + message = str(transaction_data).encode() + message_hash = hashlib.sha256(message).digest() + + private_key_int = int(private_key_hex, 16) + signing_key = ecdsa.SigningKey.from_string( + private_key_int.to_bytes(32, byteorder='big'), + curve=ecdsa.SECP256k1 + ) + + signature = signing_key.sign(message_hash) + return signature.hex() + + @staticmethod + def verify_address(address: str) -> bool: + # Basic Bitcoin address validation + try: + if len(address) < 26 or len(address) > 35: + return False + return address.startswith(('1', '3', 'bc1')) + except Exception: + return False + + +class EthereumWallet: + @staticmethod + def generate_wallet() -> Tuple[str, str]: + # Generate private key + private_key = secrets.randbits(256) + private_key_hex = hex(private_key)[2:].zfill(64) + + # Generate address using Web3 + w3 = Web3() + account = w3.eth.account.from_key(private_key_hex) + + return private_key_hex, account.address + + @staticmethod + def sign_transaction(private_key_hex: str, transaction_data: Dict) -> str: + w3 = Web3() + + # Sign the transaction + signed_txn = w3.eth.account.sign_transaction(transaction_data, private_key_hex) + return signed_txn.rawTransaction.hex() + + @staticmethod + def verify_address(address: str) -> bool: + try: + return Web3.is_address(address) + except Exception: + return False + + +class WalletFactory: + @staticmethod + def create_wallet(currency: str) -> Tuple[str, str]: + currency = currency.upper() + + if currency == 'BTC': + return BitcoinWallet.generate_wallet() + elif currency in ['ETH', 'USDT']: + return EthereumWallet.generate_wallet() + else: + raise ValueError(f"Unsupported currency: {currency}") + + @staticmethod + def sign_transaction(currency: str, private_key: str, transaction_data: Dict) -> str: + currency = currency.upper() + + if currency == 'BTC': + return BitcoinWallet.sign_transaction(private_key, transaction_data) + elif currency in ['ETH', 'USDT']: + return EthereumWallet.sign_transaction(private_key, transaction_data) + else: + raise ValueError(f"Unsupported currency: {currency}") + + @staticmethod + def verify_address(currency: str, address: str) -> bool: + currency = currency.upper() + + if currency == 'BTC': + return BitcoinWallet.verify_address(address) + elif currency in ['ETH', 'USDT']: + return EthereumWallet.verify_address(address) + else: + return False \ No newline at end of file diff --git a/app/utils/validation.py b/app/utils/validation.py new file mode 100644 index 0000000..77c077b --- /dev/null +++ b/app/utils/validation.py @@ -0,0 +1,133 @@ +import re +from fastapi import HTTPException, status + + +class ValidationUtils: + @staticmethod + def validate_email(email: str) -> bool: + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return re.match(pattern, email) is not None + + @staticmethod + def validate_password(password: str) -> bool: + # At least 8 characters, one uppercase, one lowercase, one digit + if len(password) < 8: + return False + if not re.search(r'[A-Z]', password): + return False + if not re.search(r'[a-z]', password): + return False + if not re.search(r'\d', password): + return False + return True + + @staticmethod + def validate_phone_number(phone: str) -> bool: + # Basic international phone number validation + pattern = r'^\+?[1-9]\d{1,14}$' + return re.match(pattern, phone) is not None + + @staticmethod + def validate_amount(amount: float, min_amount: float = 0.0001, max_amount: float = 1000000) -> bool: + if amount <= 0: + return False + if amount < min_amount or amount > max_amount: + return False + return True + + @staticmethod + def validate_currency_code(currency: str) -> bool: + valid_currencies = ['BTC', 'ETH', 'USDT', 'USD', 'EUR', 'GBP'] + return currency.upper() in valid_currencies + + @staticmethod + def sanitize_string(input_string: str, max_length: int = 255) -> str: + if not input_string: + return "" + + # Remove potentially dangerous characters + sanitized = re.sub(r'[<>"\']', '', input_string) + + # Trim to max length + return sanitized[:max_length].strip() + + @staticmethod + def validate_kyc_level(level: int) -> bool: + return level in [0, 1, 2] + + @staticmethod + def validate_transaction_type(transaction_type: str) -> bool: + valid_types = ['SEND', 'RECEIVE', 'DEPOSIT', 'WITHDRAWAL'] + return transaction_type.upper() in valid_types + + +def validate_required_fields(data: dict, required_fields: list) -> None: + """Validate that all required fields are present and not empty""" + missing_fields = [] + for field in required_fields: + if field not in data or not data[field]: + missing_fields.append(field) + + if missing_fields: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Missing required fields: {', '.join(missing_fields)}" + ) + + +def validate_user_registration(email: str, password: str, full_name: str) -> None: + """Validate user registration data""" + if not ValidationUtils.validate_email(email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid email format" + ) + + if not ValidationUtils.validate_password(password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters with uppercase, lowercase, and digit" + ) + + if len(full_name.strip()) < 2: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Full name must be at least 2 characters" + ) + + +def validate_transaction_amount(amount: float, currency: str) -> None: + """Validate transaction amount based on currency""" + min_amounts = { + 'BTC': 0.00001, + 'ETH': 0.001, + 'USDT': 0.01, + 'USD': 0.01, + 'EUR': 0.01, + 'GBP': 0.01 + } + + max_amounts = { + 'BTC': 100, + 'ETH': 1000, + 'USDT': 100000, + 'USD': 100000, + 'EUR': 100000, + 'GBP': 100000 + } + + currency = currency.upper() + min_amount = min_amounts.get(currency, 0.01) + max_amount = max_amounts.get(currency, 100000) + + if amount < min_amount: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Minimum amount for {currency} is {min_amount}" + ) + + if amount > max_amount: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum amount for {currency} is {max_amount}" + ) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..9041179 --- /dev/null +++ b/main.py @@ -0,0 +1,76 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from app.core.config import settings +from app.core.middleware import RateLimitMiddleware, SecurityHeadersMiddleware, LoggingMiddleware +from app.api import auth, wallets, transactions +from app.db.session import engine +from app.db.base import Base +import uvicorn + +# Create database tables +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + description="A comprehensive cryptocurrency exchange platform with wallet management, trading, and secure transaction handling.", + openapi_url="/openapi.json" +) + +# Security middleware +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware(RateLimitMiddleware, calls=100, period=60) +app.add_middleware(LoggingMiddleware) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth.router) +app.include_router(wallets.router) +app.include_router(transactions.router) + + +@app.get("/") +def root(): + return { + "title": settings.app_name, + "version": settings.app_version, + "description": "Cryptocurrency Exchange Platform API", + "documentation": "/docs", + "health_check": "/health" + } + + +@app.get("/health") +def health_check(): + return { + "status": "healthy", + "service": settings.app_name, + "version": settings.app_version, + "environment": "development" if settings.debug else "production" + } + + +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"} + ) + + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=settings.debug + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0e1f43c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +python-jose[cryptography]==3.3.0 +python-multipart==0.0.6 +passlib[bcrypt]==1.7.4 +python-decouple==3.8 +pydantic==2.5.0 +pydantic-settings==2.1.0 +cryptography==41.0.8 +ecdsa==0.18.0 +bitcoin==1.1.42 +web3==6.12.0 +requests==2.31.0 +ruff==0.1.7 +pytest==7.4.3 +pytest-asyncio==0.21.1 +httpx==0.25.2 \ No newline at end of file