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
9 changes: 9 additions & 0 deletions docs/environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:
Expand Down
18 changes: 15 additions & 3 deletions quantara/web_app/contract_tools/airdrop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
This module defines the contract tools for the airdrop data.
"""

import os
import logging

import aiohttp
Expand All @@ -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:
"""
Expand All @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion quantara/web_app/tasks/claim_airdrops.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import asyncio
import inspect
import logging
from typing import List

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion quantara/web_app/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -16,7 +18,7 @@ def mock_api_response() -> list:
"amount": "1000000000000000000",
"proof": ["0xabcd", "0x1234"],
"is_claimed": False,
"recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"recipient": "GABCD1234ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678ABCDEFGH5678",
}
]

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 = []
Expand Down Expand Up @@ -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)