diff --git a/docs/environment_variables.md b/docs/environment_variables.md index 102ef7bf3..95d32af06 100644 --- a/docs/environment_variables.md +++ b/docs/environment_variables.md @@ -18,6 +18,13 @@ will fail to start in production if any required variable is missing. ## Optional +| Variable | Purpose | +|------------------------------|--------------------------------------------------------| +| `AIRDROP_REWARD_API_ENDPOINT` | Optional Stellar-compatible airdrop/rewards endpoint | +| `DB_PORT` | PostgreSQL port (default `5432`) | +| `ENV_VERSION` | `PROD` enables production-only behaviour | +| `STELLAR_HORIZON_URL` | Stellar Horizon endpoint | +| `STELLAR_SOROBAN_RPC_URL` | Soroban RPC endpoint | | Variable | Purpose | |---------------------------|-------------------------------------------------------------| | `CORS_ORIGINS` | Comma-separated allowed frontend origins | @@ -38,6 +45,8 @@ In development (`ENV_VERSION != "PROD"`) the application does not require any of the variables above. Missing optional variables (e.g. `SENTRY_DSN`) only produce a warning in the logs. +If `AIRDROP_REWARD_API_ENDPOINT` is unset, the airdrop fetcher acts +as a no-op stub and returns no airdrop data. If `CORS_ORIGINS` is unset, the API allows requests from `http://localhost:3000` for local development. Set it explicitly in production, for example: diff --git a/quantara/web_app/contract_tools/airdrop.py b/quantara/web_app/contract_tools/airdrop.py index d7de7a01e..ef8260aaa 100644 --- a/quantara/web_app/contract_tools/airdrop.py +++ b/quantara/web_app/contract_tools/airdrop.py @@ -2,6 +2,7 @@ This module defines the contract tools for the airdrop data. """ +import os import logging import aiohttp @@ -17,14 +18,19 @@ class AirdropFetcher: A class to fetch and validate airdrop data for a specified contract. """ - # Default endpoint – replace with the actual protocol airdrop API - REWARD_API_ENDPOINT = "https://app.zklend.com/api/reward/all/" + # No Starknet-specific default. Configure a Stellar-compatible endpoint + # when the feature is available. + REWARD_API_ENDPOINT = os.getenv("AIRDROP_REWARD_API_ENDPOINT", "") def __init__(self): """ Initializes the AirdropFetcher with an APIRequest instance. """ - self.api = APIRequest(base_url=self.REWARD_API_ENDPOINT) + self.api = ( + APIRequest(base_url=self.REWARD_API_ENDPOINT) + if self.REWARD_API_ENDPOINT + else None + ) async def get_contract_airdrop(self, contract_id: str) -> AirdropResponseModel: """ @@ -41,6 +47,12 @@ async def get_contract_airdrop(self, contract_id: str) -> AirdropResponseModel: raise ValueError("Contract ID cannot be None") underlying_contract_id = contract_id + if not self.api: + logger.info( + "Airdrop endpoint is not configured; returning no data for %s", + contract_id, + ) + return AirdropResponseModel(airdrops=[]) try: response = await self.api.fetch(underlying_contract_id) except (aiohttp.ClientError, ValueError, KeyError, TypeError) as e: diff --git a/quantara/web_app/tasks/claim_airdrops.py b/quantara/web_app/tasks/claim_airdrops.py index cae6f4c20..77bbb5fe7 100644 --- a/quantara/web_app/tasks/claim_airdrops.py +++ b/quantara/web_app/tasks/claim_airdrops.py @@ -4,6 +4,7 @@ """ import asyncio +import inspect import logging from typing import List @@ -44,9 +45,17 @@ async def claim_airdrops(self) -> None: "Skipping airdrop %s: no contract address", airdrop.id ) continue - proofs = self.airdrop_fetcher.get_contract_airdrop( + airdrop_data = self.airdrop_fetcher.get_contract_airdrop( user_contract_address ) + if inspect.isawaitable(airdrop_data): + airdrop_data = await airdrop_data + proofs = self._extract_proofs(airdrop_data) + if not proofs: + logger.info( + "Skipping airdrop %s: no proof data available", airdrop.id + ) + continue claim_successful = await self._claim_airdrop( user_contract_address, proofs @@ -74,6 +83,25 @@ async def claim_airdrops(self) -> None: except Exception as e: logger.error("Unexpected error claiming airdrop %s: %s", airdrop.id, e) + @staticmethod + def _extract_proofs(airdrop_data): + """ + Normalize airdrop data into the proof list expected by claim logic. + """ + if not airdrop_data: + return [] + + if hasattr(airdrop_data, "airdrops"): + proofs = [] + for item in airdrop_data.airdrops: + proofs.extend(item.proof) + return proofs + + if isinstance(airdrop_data, list): + return airdrop_data + + return [] + async def _claim_airdrop(self, contract_address: str, proofs: List[str]) -> bool: """ Claims a single airdrop. diff --git a/quantara/web_app/tests/__init__.py b/quantara/web_app/tests/__init__.py index 40a5bbf47..92320bef9 100644 --- a/quantara/web_app/tests/__init__.py +++ b/quantara/web_app/tests/__init__.py @@ -4,7 +4,7 @@ The test suite is organized by area: - test_positions.py, test_vault.py, test_user.py, test_dashboard.py, test_claim_airdrops.py, test_starknet_client.py: API endpoint tests -- test_airdrop.py, test_zklend_airdrop.py, test_create_referal_link.py, +- test_airdrop.py, test_airdrop_fetcher.py, test_create_referal_link.py, test_deposit_mixin.py, test_dashboard_mixin.py: mixin / integration tests - test_exception_handler.py, test_config_validator.py: cross-cutting diff --git a/quantara/web_app/tests/test_zklend_airdrop.py b/quantara/web_app/tests/test_airdrop_fetcher.py similarity index 75% rename from quantara/web_app/tests/test_zklend_airdrop.py rename to quantara/web_app/tests/test_airdrop_fetcher.py index bc805b1f2..b6c4e42fa 100644 --- a/quantara/web_app/tests/test_zklend_airdrop.py +++ b/quantara/web_app/tests/test_airdrop_fetcher.py @@ -1,6 +1,8 @@ -"""Test module for AirdropFetcher class.""" +""" +Test module for AirdropFetcher class. +""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -16,7 +18,7 @@ def mock_api_response() -> list: "amount": "1000000000000000000", "proof": ["0xabcd", "0x1234"], "is_claimed": False, - "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "recipient": "GABCD1234ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678", } ] @@ -33,17 +35,32 @@ def airdrop_fetcher(): class TestAirdropFetcher: """Test suite for AirdropFetcher class.""" - def test_init(self, airdrop_fetcher): - """Test AirdropFetcher initialization.""" - assert isinstance(airdrop_fetcher, AirdropFetcher) - assert hasattr(airdrop_fetcher, "api") + def test_init_without_endpoint_defaults_to_noop(self, monkeypatch): + monkeypatch.setattr(AirdropFetcher, "REWARD_API_ENDPOINT", "") + + fetcher = AirdropFetcher() + + assert isinstance(fetcher, AirdropFetcher) + assert fetcher.api is None + + @pytest.mark.asyncio + async def test_get_contract_airdrop_returns_empty_when_unconfigured( + self, monkeypatch + ): + monkeypatch.setattr(AirdropFetcher, "REWARD_API_ENDPOINT", "") + + fetcher = AirdropFetcher() + result = await fetcher.get_contract_airdrop("GABCD1234ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678") + + assert isinstance(result, AirdropResponseModel) + assert len(result.airdrops) == 0 @pytest.mark.asyncio async def test_get_contract_airdrop_success( self, airdrop_fetcher, mock_api_response ): """Test successful retrieval of airdrop data.""" - contract_id = "0x123456" + contract_id = "GABCD1234ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678" airdrop_fetcher.api.fetch.return_value = mock_api_response result = await airdrop_fetcher.get_contract_airdrop(contract_id) @@ -54,12 +71,12 @@ async def test_get_contract_airdrop_success( assert airdrop.amount == "1000000000000000000" assert airdrop.proof == ["0xabcd", "0x1234"] assert airdrop.is_claimed is False - assert airdrop.recipient == "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" + assert airdrop.recipient == "GABCD1234ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678" @pytest.mark.asyncio async def test_get_contract_airdrop_empty_response(self, airdrop_fetcher): """Test handling of empty API response.""" - contract_id = "0x123456" + contract_id = "GABCD1234ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678" airdrop_fetcher.api.fetch.return_value = [] result = await airdrop_fetcher.get_contract_airdrop(contract_id) @@ -68,7 +85,9 @@ async def test_get_contract_airdrop_empty_response(self, airdrop_fetcher): assert len(result.airdrops) == 0 @pytest.mark.asyncio - async def test_get_contract_airdrop_with_invalid_contract_id(self, airdrop_fetcher): + async def test_get_contract_airdrop_with_invalid_contract_id( + self, airdrop_fetcher + ): """Test handling of invalid contract IDs.""" invalid_ids = ["", "0x"] airdrop_fetcher.api.fetch.return_value = [] @@ -115,10 +134,9 @@ def test_validate_response_missing_fields(self, airdrop_fetcher): @pytest.mark.asyncio async def test_get_contract_airdrop_contract_formatting(self, airdrop_fetcher): """Test that contract ID is passed directly (no transformation).""" - contract_id = "CCJZ5LW4CJ3J3Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5Z5" + contract_id = "GABCD1234ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678" airdrop_fetcher.api.fetch.return_value = [] await airdrop_fetcher.get_contract_airdrop(contract_id) - # The contract_id should be passed directly (no address transformation) airdrop_fetcher.api.fetch.assert_called_once_with(contract_id)