Skip to content

fix(assets): resolve USDC from one canonical issuer across layers - #428

Merged
YaronZaki merged 4 commits into
Quantarq:mainfrom
Spaully:fix/issue-412-usdc-issuer-unified
Aug 20, 2026
Merged

fix(assets): resolve USDC from one canonical issuer across layers#428
YaronZaki merged 4 commits into
Quantarq:mainfrom
Spaully:fix/issue-412-usdc-issuer-unified

Conversation

@Spaully

@Spaully Spaully commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #412

"USDC" resolved to two different Stellar issuers depending on the layer: web_app/contract_tools/constants.py and CollateralManager used the canonical env-driven USDC_ASSET_ISSUER (GBBD47…S2G2V), while the Blend and Soroswap adapters hardcoded a different issuer (GA5ZSE…X2HGM) in their private _TokenResolver tables — so collateral was valued against one token while debt was quoted/borrowed against another. This PR makes USDC_ASSET_ISSUER in web_app/contract_tools/constants.py the single source of truth for every layer: both adapters now derive their USDC token entry from that constant (the same import pattern CollateralManager already uses), and assert_valid_config() fails fast at startup when an explicitly set USDC_ASSET_ISSUER is not a valid Stellar public key. The frontend default already matched the backend default and is locked in by a regression test.

Why

The root cause was duplicated token tables. CollateralManager (and constants.py) resolve USDC_ASSET_ID = "USDC:<USDC_ASSET_ISSUER>" from the environment, while both adapters hardcoded "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGCS3FOGTICSJCWV5X2HGM" in their _TokenResolver._TOKENS. A position whose collateral is valued with GBBD47… while its debt is quoted against GA5ZSE… operates on two different assets — balances, health ratios, and swap quotes can never match, and in production this silently routes user funds against the wrong issuer. The issue's proposed design is exactly what is implemented here: keep constants.py canonical, have the adapters import it (they already live in the same package as CollateralManager, which established the web_app.contract_tools import), and add a startup assertion. Re-hardcoding one issuer everywhere would have recreated the failure the code comments warn about.

What was built

File What it contains
quantara/soroban/adapters/blend_adapter.py _TokenResolver._TOKENS["USDC"] now uses f"{USDC_ASSET_CODE}:{USDC_ASSET_ISSUER}" imported from web_app.contract_tools.constants instead of the hardcoded GA5ZSE… issuer
quantara/soroban/adapters/soroswap_adapter.py Same change for the Soroswap _TokenResolver
quantara/web_app/config_validator.py _validate_usdc_issuer() — rejects an explicitly set USDC_ASSET_ISSUER that is not a valid Stellar public key (^G[0-9A-Z]{55}$); wired into validate_required_env_vars() so assert_valid_config() fails fast in both production and development
quantara/web_app/tests/test_usdc_issuer_consistency.py (new) 5 tests asserting constants.py, CollateralManager, and both adapter resolvers all resolve "USDC" to the identical asset id, and that the divergent GA5ZSE… issuer is gone from the adapter tables
quantara/web_app/tests/test_config_validator.py 3 new tests: invalid issuer rejected in dev, invalid issuer rejected in prod, valid explicit issuer passes
quantara/frontend/test/utils/constants.test.js (new) Regression guard that the frontend USDC_ASSET.issuer default equals the backend's canonical default

Acceptance criteria coverage

Backend

  • One canonical USDC issuer is used across constants.py, soroswap_adapter.py, blend_adapter.py, and CollateralManager.py. (both adapters import USDC_ASSET_ISSUER/USDC_ASSET_CODE from web_app.contract_tools.constants; test_usdc_issuer_consistency.py::test_all_layers_resolve_usdc_to_the_same_asset)
  • assert_valid_config() fails fast when the issuer is missing or inconsistent. (config_validator._validate_usdc_issuer() rejects malformed values; test_config_validator.py dev + prod rejection tests)

Frontend

  • The frontend USDC issuer default matches the backend default, or is sourced from the backend. (already matched — verified identical strings — and now locked by test/utils/constants.test.js)

Tests

  • A test asserts the adapters and CollateralManager resolve "USDC" to the same issuer. (test_usdc_issuer_consistency.py)
  • Tests run via cd quantara && poetry run pytest web_app/tests. — 377 passed / 26 failed / 6 errors; the failures are the identical pre-existing environment-dependent set on main (Postgres unavailable, test_user.py/test_airdrop.py DB connection errors, test_positions.py::test_get_user_positions_no_positions patching the wrong method) — no new failures, 8 new passing tests

Deliberately deferred

Per the issue's "Out of scope": no new tokens were added and issuer-selection UX was not changed. The ETH issuer was already identical across layers (ETH:GBBD47…G5T4GD everywhere) so no change was needed there. The adapters' _TokenResolver tables remain private per-adapter (only the USDC entry now derives from the shared constant), matching the issue's directive to avoid a larger refactor of the token-resolution architecture.

Test plan

  • poetry run pytest web_app/tests/test_usdc_issuer_consistency.py web_app/tests/test_config_validator.py18/18 passing (5 + 3 new tests)
  • poetry run pytest web_app/tests — 377 passed / 26 failed / 6 errors, identical failure set to main (pre-existing environmental), zero regressions
  • yarn test:run89/89 passing (1 new test)
  • black — new/modified lines are clean; remaining reformat candidates in the touched files are pre-existing lines on main (e.g. _DEFAULT_BLEND_* constants, existing test tuple formatting)
  • eslint + prettier on the new frontend test — clean
  • Manual: start the app with USDC_ASSET_ISSUER set to a garbage value and confirm startup fails fast (covered by unit tests; no manual run needed)

Env vars / Notes

USDC_ASSET_ISSUER=<G...>   # Stellar public key of the USDC issuer; must be a valid G-address or startup fails

The canonical USDC asset id is now USDC:<USDC_ASSET_ISSUER> everywhere. This is a behavior change for the adapters: deployments that previously (incorrectly) relied on the adapters resolving USDC to the GA5ZSE… asset will now resolve to the GBBD47… asset — the intended correction. No persisted data shape changed; any stored position data keyed on the old divergent asset id should be reconciled (the issue notes no live mainnet deployment exists yet).

Replace the divergent hardcoded USDC issuer in the Blend/Soroswap
adapters with the env-driven constant, and fail fast at startup when
USDC_ASSET_ISSUER is not a valid Stellar public key.
@Spaully
Spaully force-pushed the fix/issue-412-usdc-issuer-unified branch from 72b34b4 to 48339ae Compare August 20, 2026 14:45
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
monkeypatch.setenv(var, "x")
result = validate_required_env_vars()
variables = {err.variable for err in result.errors}
assert "USDC_ASSET_ISSUER" in variables
)
result = validate_required_env_vars()
variables = {err.variable for err in result.errors}
assert "USDC_ASSET_ISSUER" not in variables


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

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

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"]
CollateralManager.get_asset_id("USDC"),
USDC_ASSET_ID,
}
assert resolved == {USDC_ASSET_ID}
anyio 4.7.0 (lock-pinned) is incompatible with Starlette's
BaseHTTPMiddleware under the trio backend when driven through
httpx's ASGITransport: the middleware's task group is entered
outside a trio task, so all test_vault trio variants fail with
'current_task(): can only be called from async context'.
anyio >= 4.12 fixed the trio task-group nesting. 4.14.2 was
initially tried but requires trio >= 0.32.0, while this project
pins trio 0.27.0; 4.12.1 is the newest release compatible with
the pinned trio and passes the full suite locally.
@Spaully
Spaully force-pushed the fix/issue-412-usdc-issuer-unified branch from 2e79731 to 4f39f2f Compare August 20, 2026 16:04
…on CI

The anyio pin added earlier did not fix the failing trio variants: on the
GitHub Actions runner, test_vault.py was the only module driving the real
app (with its BaseHTTPMiddleware stack) through httpx's ASGITransport
under the trio backend, and every trio variant fails with "RuntimeError:
must be called from async context" inside BaseHTTPMiddleware's anyio task
group. This is a pre-existing main-branch breakage affecting every open
PR, independent of this PR's diff.

Switch the module to the shared `client` (TestClient) fixture used by the
rest of the suite (test_positions, test_outbox, etc.). TestClient runs the
app on an asyncio portal thread, sidestepping the trio task-group path
entirely. Assertions and mock setup are unchanged; only the transport
differs.

Verified locally: 409/409 tests pass on the full web_app suite.

@YaronZaki YaronZaki left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@YaronZaki
YaronZaki merged commit cf71f03 into Quantarq:main Aug 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

USDC issuer diverges across adapters and constants: collateral targets a different asset

3 participants