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
15 changes: 15 additions & 0 deletions quantara/frontend/test/utils/constants.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';

import { USDC_ASSET } from '../../src/utils/constants';

// The canonical testnet USDC issuer — must match
// web_app/contract_tools/constants.py's USDC_ASSET_ISSUER default
// (issue #412). The backend default is the single source of truth; this
// test guards the frontend default against drifting out of alignment.
const CANONICAL_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NOJ4VBH6THS2G2V';

describe('USDC issuer consistency (issue #412)', () => {
it('defaults to the same issuer as the backend constants', () => {
expect(USDC_ASSET.issuer).toBe(CANONICAL_USDC_ISSUER);
});
});
10 changes: 6 additions & 4 deletions quantara/soroban/adapters/blend_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import aiohttp

from web_app.contract_tools.constants import USDC_ASSET_CODE, USDC_ASSET_ISSUER

from .errors import AdapterRpcError
from .LendingAdapter import LendingAdapter, ReserveData, UserPosition

Expand All @@ -37,11 +39,11 @@ class _TokenResolver:
"decimals": 7,
"symbol": "XLM",
},
# USDC issuer comes from the canonical `USDC_ASSET_ISSUER` env-driven
# constant (web_app.contract_tools.constants) so every layer of the
# stack agrees on which on-chain asset "USDC" is (issue #412).
"USDC": {
"addresses": [
"USDC",
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGCS3FOGTICSJCWV5X2HGM",
],
"addresses": ["USDC", f"{USDC_ASSET_CODE}:{USDC_ASSET_ISSUER}"],
"decimals": 7,
"symbol": "USDC",
},
Expand Down
10 changes: 6 additions & 4 deletions quantara/soroban/adapters/soroswap_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

import aiohttp

from web_app.contract_tools.constants import USDC_ASSET_CODE, USDC_ASSET_ISSUER

from .AMMAdapter import AMMAdapter, PoolKey, PoolPrice, SwapRoute
from .errors import AdapterRpcError

Expand Down Expand Up @@ -59,11 +61,11 @@ class _TokenResolver:
"addresses": ["native", "XLM"],
"decimals": 7,
},
# USDC issuer comes from the canonical `USDC_ASSET_ISSUER` env-driven
# constant (web_app.contract_tools.constants) so every layer of the
# stack agrees on which on-chain asset "USDC" is (issue #412).
"USDC": {
"addresses": [
"USDC",
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGCS3FOGTICSJCWV5X2HGM",
],
"addresses": ["USDC", f"{USDC_ASSET_CODE}:{USDC_ASSET_ISSUER}"],
"decimals": 7,
},
"WETH": {
Expand Down
34 changes: 34 additions & 0 deletions quantara/web_app/config_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import logging
import os
import re
from dataclasses import dataclass, field
from typing import List, Optional

Expand Down Expand Up @@ -73,6 +74,35 @@ def format_errors(self) -> str:
"STELLAR_SOROBAN_RPC_URL",
)

# Stellar public keys are 56 characters, starting with "G" (SEP-23/SEP-5
# strkey encoding). Used to fail fast when USDC_ASSET_ISSUER is misconfigured.
_STELLAR_PUBLIC_KEY_RE = re.compile(r"^G[0-9A-Z]{55}$")


def _validate_usdc_issuer() -> List[ConfigValidationError]:
"""
Validate the USDC_ASSET_ISSUER environment variable when explicitly set.

Every layer (constants, adapters, CollateralManager) resolves USDC from
this single value (issue #412), so a malformed issuer silently breaks
asset resolution everywhere. When the variable is unset, the known-good
default in ``web_app.contract_tools.constants`` applies.
"""
value = os.getenv("USDC_ASSET_ISSUER")
if value is None:
return []
if not _STELLAR_PUBLIC_KEY_RE.match(value):
return [
ConfigValidationError(
variable="USDC_ASSET_ISSUER",
message=(
f"USDC_ASSET_ISSUER '{value}' is not a valid Stellar "
"public key. Expected a G... address (56 chars)."
),
)
]
return []


def _is_production() -> bool:
"""
Expand Down Expand Up @@ -139,6 +169,10 @@ def validate_required_env_vars(
var,
)

# A malformed USDC issuer breaks asset resolution in every layer, so it
# is validated in both production and development.
result.errors.extend(_validate_usdc_issuer())

return result


Expand Down
30 changes: 30 additions & 0 deletions quantara/web_app/tests/test_config_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,36 @@
variables = {err.variable for err in result.errors}
assert "DB_USER" in variables

def test_invalid_usdc_issuer_is_rejected_in_development(self, monkeypatch):
"""A malformed USDC issuer must fail fast in every environment."""
monkeypatch.setenv("ENV_VERSION", "DEV")
monkeypatch.setenv("USDC_ASSET_ISSUER", "not-a-stellar-key")
result = validate_required_env_vars()
variables = {err.variable for err in result.errors}
assert "USDC_ASSET_ISSUER" in variables

def test_invalid_usdc_issuer_is_rejected_in_production(self, monkeypatch):
monkeypatch.setenv("ENV_VERSION", "PROD")
monkeypatch.setenv("USDC_ASSET_ISSUER", "not-a-stellar-key")
for var in (
"DB_USER", "DB_PASSWORD", "DB_HOST", "DB_NAME",
"SESSION_SECRET_KEY", "SENTRY_DSN",
):
monkeypatch.setenv(var, "x")
result = validate_required_env_vars()
variables = {err.variable for err in result.errors}
assert "USDC_ASSET_ISSUER" in variables

def test_valid_explicit_usdc_issuer_passes(self, monkeypatch):
monkeypatch.setenv("ENV_VERSION", "DEV")
monkeypatch.setenv(
"USDC_ASSET_ISSUER",
"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NOJ4VBH6THS2G2V",
)
result = validate_required_env_vars()
variables = {err.variable for err in result.errors}
assert "USDC_ASSET_ISSUER" not in variables


class TestAssertValidConfig:
def test_does_not_raise_in_development(self, monkeypatch):
Expand Down
53 changes: 53 additions & 0 deletions quantara/web_app/tests/test_usdc_issuer_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Tests for issue #412: USDC issuer consistency across the stack.

Every layer — `web_app.contract_tools.constants`, `CollateralManager`, and
the Blend / Soroswap adapter `_TokenResolver`s — must resolve "USDC" to the
same on-chain asset. Before this fix the adapters hardcoded a different
issuer (`GA5ZSE...`) than the canonical env-driven one (`GBBD47...`), so
collateral was valued against one token while debt was quoted against
another.

These tests import the adapters via the `soroban.adapters` namespace path
(the same convention used by `test_collateral_manager_edge_cases.py`).
"""

from soroban.adapters.CollateralManager import CollateralManager
from soroban.adapters.blend_adapter import _TokenResolver as BlendResolver
from soroban.adapters.soroswap_adapter import _TokenResolver as SoroswapResolver
from web_app.contract_tools.constants import (
USDC_ASSET_CODE,
USDC_ASSET_ID,
USDC_ASSET_ISSUER,
)

# The issuer the adapters hardcoded before this fix.
_DIVERGENT_USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGCS3FOGTICSJCWV5X2HGM"


def test_constants_define_canonical_usdc_id():
assert USDC_ASSET_ID == f"{USDC_ASSET_CODE}:{USDC_ASSET_ISSUER}"


def test_collateral_manager_uses_canonical_usdc_id():
assert CollateralManager.get_asset_id("USDC") == USDC_ASSET_ID


def test_blend_resolver_normalizes_usdc_to_canonical_asset():
assert BlendResolver.normalize("USDC") == USDC_ASSET_ID
assert _DIVERGENT_USDC_ISSUER not in BlendResolver._TOKENS["USDC"]["addresses"]


def test_soroswap_resolver_normalizes_usdc_to_canonical_asset():
assert SoroswapResolver.normalize("USDC") == USDC_ASSET_ID
assert _DIVERGENT_USDC_ISSUER not in SoroswapResolver._TOKENS["USDC"]["addresses"]


def test_all_layers_resolve_usdc_to_the_same_asset():
resolved = {
BlendResolver.normalize("USDC"),
SoroswapResolver.normalize("USDC"),
CollateralManager.get_asset_id("USDC"),
USDC_ASSET_ID,
}
assert resolved == {USDC_ASSET_ID}
26 changes: 7 additions & 19 deletions quantara/web_app/tests/test_vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,9 @@

import pytest
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient

from web_app.api.main import app
from web_app.db.crud import UserDBConnector

client = TestClient(app)


@pytest.fixture
async def async_client():
"""Fixture that provides an async client for testing."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac


@pytest.mark.anyio
@pytest.mark.parametrize(
Expand Down Expand Up @@ -55,7 +43,7 @@ async def test_deposit_to_vault(
expected_status,
expected_response,
mock_user_db_connector,
async_client,
client: TestClient,
):
"""Test vault deposit with different scenarios."""
mock_user = MagicMock()
Expand All @@ -73,9 +61,9 @@ async def test_deposit_to_vault(
"web_app.db.crud.DepositDBConnector.create_vault",
return_value=mock_vault,
):
response = await async_client.post("/api/vault/deposit", json=test_data)
response = client.post("/api/vault/deposit", json=test_data)
else:
response = await async_client.post("/api/vault/deposit", json=test_data)
response = client.post("/api/vault/deposit", json=test_data)

assert response.status_code == expected_status
expected = (
Expand Down Expand Up @@ -112,15 +100,15 @@ async def test_get_vault_balance(
balance,
expected_status,
expected_response,
async_client,
client: TestClient,
):
"""Test vault balance retrieval with different scenarios."""
with patch(
"web_app.db.crud.DepositDBConnector.get_vault_balance",
return_value=balance,
):
url = f"/api/vault/balance?wallet_id={wallet_id}&symbol={symbol}"
response = await async_client.get(url)
response = client.get(url)

assert response.status_code == expected_status
expected = (
Expand Down Expand Up @@ -156,7 +144,7 @@ async def test_get_vault_balance(
],
)
async def test_add_vault_balance(
test_data, expected_status, expected_response, async_client
test_data, expected_status, expected_response, client: TestClient
):
"""Test adding to vault balance with different scenarios."""
mock_vault = MagicMock()
Expand All @@ -171,7 +159,7 @@ async def test_add_vault_balance(
"web_app.db.crud.DepositDBConnector.add_vault_balance",
**patch_kwargs,
):
response = await async_client.post("/api/vault/add_balance", json=test_data)
response = client.post("/api/vault/add_balance", json=test_data)

assert response.status_code == expected_status
expected = (
Expand Down
Loading