Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions quantara/web_app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
Main FastAPI application module for the QUANTARA API (Stellar/Soroban).

Sets up FastAPI with session and CORS middleware, registers routers for
dashboard, position, user, vault, leaderboard, referal, and telegram
endpoints, and exposes a /health endpoint for CI orchestration.
dashboard, position, user, vault, leaderboard, referal, telegram, and
authentication endpoints, and exposes a /health endpoint for CI orchestration.
"""

import logging
Expand All @@ -30,14 +30,15 @@
from web_app.api.vault import router as vault_router
from web_app.api.leaderboard import router as leaderboard_router
from web_app.api.referal import router as referal_router
from web_app.api.wallet_auth import router as auth_router
from web_app.config_validator import assert_valid_config
from web_app.db.database import init_db
from web_app.db.database import init_db, get_database

logger = logging.getLogger(__name__)
DEFAULT_CORS_ORIGINS = ["http://localhost:3000"]
CORS_ALLOW_METHODS = ["GET", "POST"]
CORS_ALLOW_HEADERS = ["Content-Type", "Authorization"]
CORS_ALLOW_HEADERS = ["Content-Type", "Authorization", "X-Wallet-Id", "X-Nonce", "X-Signature"]


def get_cors_origins() -> list[str]:
Expand Down Expand Up @@ -135,7 +136,7 @@ async def global_exception_handler(request: Request, exc: Exception):
allow_headers=CORS_ALLOW_HEADERS,
allow_methods=CORS_ALLOW_METHODS,
)
# Rate limiting middleware must be added after CORS/session so it wraps the
# Rate limiting middleware -- must be added after CORS/session so it wraps the
# full middleware stack and can reject requests before they reach routers.
app.add_middleware(SlowAPIMiddleware)

Expand Down Expand Up @@ -175,7 +176,7 @@ async def health_check(response: Response, db: Session = Depends(get_database)):
return health_status


# No startup-time blockchain contract init needed the frontend
# No startup-time blockchain contract init needed -- the frontend
# invokes Soroban contracts directly via Freighter + stellar-sdk.

# Include the routers
Expand All @@ -186,3 +187,4 @@ async def health_check(response: Response, db: Session = Depends(get_database)):
app.include_router(vault_router)
app.include_router(leaderboard_router)
app.include_router(referal_router)
app.include_router(auth_router)
17 changes: 14 additions & 3 deletions quantara/web_app/api/position.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
RepayTransactionDataResponse,
WithdrawAllData,
)
from web_app.api.wallet_auth import verify_wallet_signature
from web_app.contract_tools.constants import TokenMultipliers, TokenParams
from web_app.contract_tools.mixins import DashboardMixin, DepositMixin, PositionMixin
from web_app.db.crud import PositionDBConnector, TransactionDBConnector
Expand Down Expand Up @@ -66,13 +67,16 @@ async def create_position_with_transaction_data(
request: Request,
form_data: PositionFormData,
client: StellarClient = Depends(get_stellar_client),
wallet: str = Depends(verify_wallet_signature),
) -> LoopLiquidityData:
"""
Create a new user position and return the data needed by the frontend
to call the Soroban loop_liquidity contract.

Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers.

Parameters:
- **wallet_id**: The wallet ID of the user (Stellar public key G).
- **wallet_id**: The wallet ID of the user (Stellar public key G...).
- **token_symbol**: The symbol of the token used for the position.
- **amount**: The amount of the token being deposited.
- **multiplier**: The multiplier applied to the user's position.
Expand Down Expand Up @@ -122,7 +126,7 @@ async def get_repay_data(
"""
Obtain data for position closing.

:param wallet_id: Wallet ID (Stellar public key G)
:param wallet_id: Wallet ID (Stellar public key G...)
:return: Dict containing the repay transaction data
"""
if not wallet_id:
Expand Down Expand Up @@ -286,10 +290,17 @@ async def get_add_deposit_data(request: Request, position_id: UUID, amount: str,

@router.post("/api/add-extra-deposit/{position_id}")
@limiter.limit(WRITE_LIMIT)
async def add_extra_deposit(request: Request, position_id: UUID, data: AddPositionDepositData):
async def add_extra_deposit(
request: Request,
position_id: UUID,
data: AddPositionDepositData,
wallet: str = Depends(verify_wallet_signature),
):
"""
Add extra deposit to a user position.

Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers.

:param position_id: UUID of the position
:param data: Deposit data to create extra deposit
:return: Dict containing detail
Expand Down
7 changes: 7 additions & 0 deletions quantara/web_app/api/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from fastapi import APIRouter, Depends, HTTPException, Request

from web_app.api.wallet_auth import verify_wallet_signature
from web_app.db.crud import DepositDBConnector, UserDBConnector
from web_app.api.serializers.vault import (
UpdateVaultBalanceRequest,
Expand All @@ -26,9 +27,12 @@ async def deposit_to_vault(
request: Request,
body: VaultDepositRequest,
deposit_connector: DepositDBConnector = Depends(DepositDBConnector),
wallet: str = Depends(verify_wallet_signature),
) -> VaultDepositResponse:
"""
Process a vault deposit request.

Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers.
"""
logger.info(f"Processing deposit request for wallet {body.wallet_id}")

Expand Down Expand Up @@ -78,9 +82,12 @@ async def add_vault_balance(
request: Request,
body: UpdateVaultBalanceRequest,
deposit_connector: DepositDBConnector = Depends(DepositDBConnector),
wallet: str = Depends(verify_wallet_signature),
) -> UpdateVaultBalanceResponse:
"""
Add balance to a user's vault for a specific token.

Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers.
"""
try:
updated_vault = deposit_connector.add_vault_balance(
Expand Down
83 changes: 83 additions & 0 deletions quantara/web_app/api/wallet_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Wallet authentication: Ed25519 challenge-response signature verification."""
import secrets
import time
from typing import Dict, Tuple

from fastapi import APIRouter, Header, HTTPException, Query

from stellar_sdk import Keypair

router = APIRouter(prefix="/api/auth", tags=["Authentication"])

_nonce_store: Dict[str, Tuple[str, float]] = {}
NONCE_TTL: int = 300 # seconds


def _clean_expired_nonces() -> None:
"""Remove nonces past their TTL."""
cutoff = time.monotonic() - NONCE_TTL
expired = [n for n, (_, ts) in _nonce_store.items() if ts < cutoff]
for n in expired:
_nonce_store.pop(n, None)


def _generate_nonce(wallet_id: str) -> str:
"""Generate a cryptographically secure nonce bound to wallet_id."""
_clean_expired_nonces()
nonce = secrets.token_hex(32)
_nonce_store[nonce] = (wallet_id, time.monotonic())
return nonce


def _consume_nonce(nonce: str, wallet_id: str) -> bool:
"""
Validate and consume a nonce atomically.
Returns True only when the nonce exists, has not expired, and belongs to wallet_id.
The nonce is always removed to prevent replay even on a wallet mismatch.
"""
_clean_expired_nonces()
entry = _nonce_store.pop(nonce, None)
if entry is None:
return False
stored_wallet_id, _ = entry
return stored_wallet_id == wallet_id


def _verify_stellar_signature(public_key: str, message: str, signature_hex: str) -> bool:
"""Verify an Ed25519 signature produced by a Stellar keypair."""
try:
keypair = Keypair.from_public_key(public_key)
sig_bytes = bytes.fromhex(signature_hex)
keypair.verify(message.encode(), sig_bytes)
return True
except Exception:
return False


@router.get("/nonce", summary="Request a one-time authentication nonce")
async def get_nonce(
wallet_id: str = Query(..., description="Stellar public key (G...) of the authenticating wallet"),
) -> dict:
"""Issue a one-time nonce for wallet_id. Sign the nonce with your Stellar private key
and pass it as X-Signature on the next authenticated request."""
nonce = _generate_nonce(wallet_id)
return {"nonce": nonce, "expires_in": NONCE_TTL}


async def verify_wallet_signature(
x_wallet_id: str = Header(..., description="Stellar public key of the signer"),
x_nonce: str = Header(..., description="Nonce obtained from GET /api/auth/nonce"),
x_signature: str = Header(..., description="Hex-encoded Ed25519 signature of the nonce"),
) -> str:
"""FastAPI dependency -- verifies a Stellar wallet signature and returns the wallet_id."""
if not _consume_nonce(x_nonce, x_wallet_id):
raise HTTPException(
status_code=401,
detail="Invalid or expired nonce. Request a fresh nonce from /api/auth/nonce.",
)
if not _verify_stellar_signature(x_wallet_id, x_nonce, x_signature):
raise HTTPException(
status_code=401,
detail="Signature verification failed. Ensure the nonce was signed with the correct key.",
)
return x_wallet_id
22 changes: 19 additions & 3 deletions quantara/web_app/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from web_app.api.main import app
from web_app.api.rate_limiter import limiter as _ORIGINAL_LIMITER
from web_app.api.wallet_auth import verify_wallet_signature
from web_app.db.crud import DBConnector, PositionDBConnector, UserDBConnector
from web_app.db.database import get_database
from web_app.db.models import ExtraDeposit
Expand All @@ -22,11 +23,11 @@ def disable_rate_limiting():
"""Disable rate limiting in all tests to avoid Redis dependency.

Three separate Limiter instances can exist during a test run:
1. _ORIGINAL_LIMITER created when rate_limiter.py was first loaded;
1. _ORIGINAL_LIMITER -- created when rate_limiter.py was first loaded;
all @limiter.limit() wrappers in user.py, vault.py, etc. close over it.
2. A reloaded limiter TestRateLimiterConfig calls importlib.reload(),
2. A reloaded limiter -- TestRateLimiterConfig calls importlib.reload(),
which creates a fresh instance and updates the module-level name.
3. A memory_limiter TestRateLimitEnforcement swaps app.state.limiter for
3. A memory_limiter -- TestRateLimitEnforcement swaps app.state.limiter for
an in-memory instance so tests don't need Redis.
We collect every unique instance we can find and disable them all so that
the middleware, the decorator wrappers, and direct function calls all skip
Expand All @@ -46,6 +47,21 @@ def disable_rate_limiting():
lim.enabled = True


@pytest.fixture(autouse=True)
def bypass_wallet_auth():
"""Bypass wallet signature verification for all tests.

Adds a dependency override so every endpoint that Depends(verify_wallet_signature)
receives a fixed wallet_id of "test_wallet" without any real signature exchange.
This keeps tests decoupled from the nonce store and stellar-sdk crypto.

The override is removed (not cleared, to preserve other overrides) after each test.
"""
app.dependency_overrides[verify_wallet_signature] = lambda: "test_wallet"
yield
app.dependency_overrides.pop(verify_wallet_signature, None)


def dict_to_object(data: dict, **kwargs) -> object:
"""
Convert a dictionary to an attribute object
Expand Down
Loading