Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""wallet connection analytics

Revision ID: 8c2f0f0c9f1a
Revises: a7b8c9d0e1f2
Create Date: 2026-06-17 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "8c2f0f0c9f1a"
down_revision: Union[str, None] = "a7b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"wallet_connection_analytics",
sa.Column("wallet_address", sa.String(length=42), nullable=False),
sa.Column(
"connect_count",
sa.Integer(),
server_default="1",
nullable=False,
),
sa.Column("first_observed_ip", sa.String(length=45), nullable=True),
sa.Column("last_observed_ip", sa.String(length=45), nullable=True),
sa.Column("first_user_agent", sa.String(length=512), nullable=True),
sa.Column("last_user_agent", sa.String(length=512), nullable=True),
sa.Column("first_origin", sa.String(length=512), nullable=True),
sa.Column("last_origin", sa.String(length=512), nullable=True),
sa.Column(
"first_connected_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.Column(
"last_connected_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.PrimaryKeyConstraint(
"wallet_address", name="wallet_connection_analytics_pkey"
),
)


def downgrade() -> None:
op.drop_table("wallet_connection_analytics")
36 changes: 36 additions & 0 deletions backend/database_handler/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,39 @@ class ApiKey(Base):
last_used_at: Mapped[Optional[datetime.datetime]] = mapped_column(
DateTime(True), nullable=True, init=False
)


class WalletConnectionAnalytics(Base):
__tablename__ = "wallet_connection_analytics"
__table_args__ = (
PrimaryKeyConstraint("wallet_address", name="wallet_connection_analytics_pkey"),
)

wallet_address: Mapped[str] = mapped_column(String(42), primary_key=True)
connect_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="1", default=1
)
first_observed_ip: Mapped[Optional[str]] = mapped_column(
String(45), nullable=True, default=None
)
last_observed_ip: Mapped[Optional[str]] = mapped_column(
String(45), nullable=True, default=None
)
first_user_agent: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True, default=None
)
last_user_agent: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True, default=None
)
first_origin: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True, default=None
)
last_origin: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True, default=None
)
first_connected_at: Mapped[datetime.datetime] = mapped_column(
DateTime(True), server_default=func.current_timestamp(), init=False
)
last_connected_at: Mapped[datetime.datetime] = mapped_column(
DateTime(True), server_default=func.current_timestamp(), init=False
)
53 changes: 53 additions & 0 deletions backend/protocol_rpc/analytics_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Analytics-only HTTP endpoints."""

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session

from backend.protocol_rpc.client_ip import ClientIPResolver
from backend.protocol_rpc.dependencies import get_db_session
from backend.services.wallet_connection_analytics_service import (
WalletConnectionMetadata,
record_wallet_connection,
)

analytics_router = APIRouter(prefix="/api/analytics", tags=["analytics"])
_client_ip_resolver = ClientIPResolver()


class WalletConnectionRequest(BaseModel):
wallet_address: str


class WalletConnectionResponse(BaseModel):
wallet_address: str
recorded: bool


@analytics_router.post(
"/wallet-connections",
response_model=WalletConnectionResponse,
status_code=status.HTTP_200_OK,
)
def record_wallet_connection_endpoint(
payload: WalletConnectionRequest,
request: Request,
session: Annotated[Session, Depends(get_db_session)],
):
metadata = WalletConnectionMetadata(
observed_ip=_client_ip_resolver.client_ip(request),
user_agent=request.headers.get("User-Agent"),
origin=request.headers.get("Origin"),
)
try:
record = record_wallet_connection(
session=session,
wallet_address=payload.wallet_address,
metadata=metadata,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc

Check failure on line 51 in backend/protocol_rpc/analytics_router.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Document this HTTPException with status code 422 in the "responses" parameter.

See more on https://sonarcloud.io/project/issues?id=yeagerai_genlayer-simulator&issues=AZ7aJgYu1xM_XIKjvTte&open=AZ7aJgYu1xM_XIKjvTte&pullRequest=1685

return WalletConnectionResponse(wallet_address=record.wallet_address, recorded=True)
101 changes: 101 additions & 0 deletions backend/protocol_rpc/client_ip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Helpers for deriving a client IP from trusted proxy headers."""

from __future__ import annotations

import logging
import os
from ipaddress import ip_address, ip_network
from typing import Optional

from starlette.requests import Request

logger = logging.getLogger(__name__)

DEFAULT_TRUSTED_PROXY_CIDRS = (
"127.0.0.0/8",
"10.0.0.0/8", # NOSONAR - RFC1918 private proxy range.
"172.16.0.0/12", # NOSONAR - RFC1918 private proxy range.
"192.168.0.0/16", # NOSONAR - RFC1918 private proxy range.
"::1/128",
"fc00::/7",
)


def load_trusted_proxy_networks(env_var: str = "RATE_LIMIT_TRUSTED_PROXIES"):
raw = os.environ.get(env_var, ",".join(DEFAULT_TRUSTED_PROXY_CIDRS))
networks = []
for value in raw.split(","):
value = value.strip()
if not value:
continue
try:
networks.append(ip_network(value, strict=False))
except ValueError:
logger.warning("Ignoring invalid %s entry", env_var)
return tuple(networks)


class ClientIPResolver:
def __init__(self, trusted_proxy_networks=None):
self._trusted_proxy_networks = (
trusted_proxy_networks
if trusted_proxy_networks is not None
else load_trusted_proxy_networks()
)

def client_ip(self, request: Request) -> str:
peer_host = request.client.host if request.client else "unknown"
if not self._is_trusted_proxy(peer_host):
return peer_host

forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
forwarded_ip = self._forwarded_client_ip(forwarded_for)
if forwarded_ip is not None:
return forwarded_ip

real_ip = self._valid_ip_header(request.headers.get("X-Real-IP"))
if real_ip:
return real_ip

return peer_host

def _forwarded_client_ip(self, forwarded_for: str) -> Optional[str]:
parsed = self._parse_forwarded_for(forwarded_for)
for value, parsed_ip in reversed(parsed):
if not self._is_trusted_ip(parsed_ip):
return value
if parsed:
return parsed[0][0]
return None

def _parse_forwarded_for(self, forwarded_for: str) -> list[tuple[str, object]]:
parsed = []
for value in forwarded_for.split(","):
value = value.strip()
if not value:
continue
try:
parsed.append((value, ip_address(value)))
except ValueError:
continue
return parsed

def _valid_ip_header(self, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip()
try:
ip_address(value)
except ValueError:
return None
return value

def _is_trusted_proxy(self, host: str) -> bool:
try:
return self._is_trusted_ip(ip_address(host))
except ValueError:
return False

def _is_trusted_ip(self, parsed_ip) -> bool:
return any(parsed_ip in network for network in self._trusted_proxy_networks)
4 changes: 4 additions & 0 deletions backend/protocol_rpc/fastapi_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
load_dotenv()

from backend.protocol_rpc.app_lifespan import RPCAppSettings, rpc_app_lifespan
from backend.protocol_rpc.analytics_router import analytics_router
from backend.protocol_rpc.dependencies import (
get_rpc_router_optional,
websocket_broadcast,
Expand Down Expand Up @@ -79,6 +80,9 @@ async def lifespan(app: FastAPI):
# Include explorer API endpoints
app.include_router(explorer_router)

# Include analytics-only endpoints
app.include_router(analytics_router)


# JSON-RPC endpoint (supports single and batch requests)
@app.post("/api")
Expand Down
90 changes: 3 additions & 87 deletions backend/protocol_rpc/rate_limit_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,52 +3,25 @@
from __future__ import annotations

import logging
import os
from ipaddress import ip_address, ip_network
from typing import Optional

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response

from backend.protocol_rpc.client_ip import ClientIPResolver
from backend.protocol_rpc.exceptions import RateLimitExceeded
from backend.protocol_rpc.rate_limiter import RateLimiterService

logger = logging.getLogger(__name__)

DEFAULT_TRUSTED_PROXY_CIDRS = (
"127.0.0.0/8",
"10.0.0.0/8", # NOSONAR - RFC1918 private proxy range.
"172.16.0.0/12", # NOSONAR - RFC1918 private proxy range.
"192.168.0.0/16", # NOSONAR - RFC1918 private proxy range.
"::1/128",
"fc00::/7",
)


def _load_trusted_proxy_networks():
raw = os.environ.get(
"RATE_LIMIT_TRUSTED_PROXIES",
",".join(DEFAULT_TRUSTED_PROXY_CIDRS),
)
networks = []
for value in raw.split(","):
value = value.strip()
if not value:
continue
try:
networks.append(ip_network(value, strict=False))
except ValueError:
logger.warning("Ignoring invalid RATE_LIMIT_TRUSTED_PROXIES entry")
return tuple(networks)


class RateLimitMiddleware(BaseHTTPMiddleware):
"""Intercepts /api requests to enforce tiered rate limits."""

def __init__(self, app, dispatch=None):
super().__init__(app, dispatch=dispatch)
self._trusted_proxy_networks = _load_trusted_proxy_networks()
self._client_ip_resolver = ClientIPResolver()

async def dispatch(self, request: Request, call_next) -> Response:
# Only rate-limit the JSON-RPC endpoint
Expand All @@ -62,7 +35,7 @@ async def dispatch(self, request: Request, call_next) -> Response:
return await call_next(request)

api_key = request.headers.get("X-API-Key")
client_ip = self._client_ip(request)
client_ip = self._client_ip_resolver.client_ip(request)

try:
await rate_limiter.check_rate_limit(api_key, client_ip)
Expand All @@ -86,60 +59,3 @@ async def dispatch(self, request: Request, call_next) -> Response:
)

return await call_next(request)

def _client_ip(self, request: Request) -> str:
peer_host = request.client.host if request.client else "unknown"
if not self._is_trusted_proxy(peer_host):
return peer_host

forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
forwarded_ip = self._forwarded_client_ip(forwarded_for)
if forwarded_ip is not None:
return forwarded_ip

real_ip = self._valid_ip_header(request.headers.get("X-Real-IP"))
if real_ip:
return real_ip

return peer_host

def _forwarded_client_ip(self, forwarded_for: str) -> Optional[str]:
parsed = self._parse_forwarded_for(forwarded_for)
for value, parsed_ip in reversed(parsed):
if not self._is_trusted_ip(parsed_ip):
return value
if parsed:
return parsed[0][0]
return None

def _parse_forwarded_for(self, forwarded_for: str) -> list[tuple[str, object]]:
parsed = []
for value in forwarded_for.split(","):
value = value.strip()
if not value:
continue
try:
parsed.append((value, ip_address(value)))
except ValueError:
continue
return parsed

def _valid_ip_header(self, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip()
try:
ip_address(value)
except ValueError:
return None
return value

def _is_trusted_proxy(self, host: str) -> bool:
try:
return self._is_trusted_ip(ip_address(host))
except ValueError:
return False

def _is_trusted_ip(self, parsed_ip) -> bool:
return any(parsed_ip in network for network in self._trusted_proxy_networks)
Loading
Loading