fix(assets): resolve USDC from one canonical issuer across layers - #428
Merged
YaronZaki merged 4 commits intoAug 20, 2026
Merged
Conversation
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
force-pushed
the
fix/issue-412-usdc-issuer-unified
branch
from
August 20, 2026 14:45
72b34b4 to
48339ae
Compare
| 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
force-pushed
the
fix/issue-412-usdc-issuer-unified
branch
from
August 20, 2026 16:04
2e79731 to
4f39f2f
Compare
This reverts commit 4f39f2f.
…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.
Closed
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #412
"USDC" resolved to two different Stellar issuers depending on the layer:
web_app/contract_tools/constants.pyandCollateralManagerused the canonical env-drivenUSDC_ASSET_ISSUER(GBBD47…S2G2V), while the Blend and Soroswap adapters hardcoded a different issuer (GA5ZSE…X2HGM) in their private_TokenResolvertables — so collateral was valued against one token while debt was quoted/borrowed against another. This PR makesUSDC_ASSET_ISSUERinweb_app/contract_tools/constants.pythe single source of truth for every layer: both adapters now derive their USDC token entry from that constant (the same import patternCollateralManageralready uses), andassert_valid_config()fails fast at startup when an explicitly setUSDC_ASSET_ISSUERis 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(andconstants.py) resolveUSDC_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 withGBBD47…while its debt is quoted againstGA5ZSE…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: keepconstants.pycanonical, have the adapters import it (they already live in the same package asCollateralManager, which established theweb_app.contract_toolsimport), and add a startup assertion. Re-hardcoding one issuer everywhere would have recreated the failure the code comments warn about.What was built
quantara/soroban/adapters/blend_adapter.py_TokenResolver._TOKENS["USDC"]now usesf"{USDC_ASSET_CODE}:{USDC_ASSET_ISSUER}"imported fromweb_app.contract_tools.constantsinstead of the hardcodedGA5ZSE…issuerquantara/soroban/adapters/soroswap_adapter.py_TokenResolverquantara/web_app/config_validator.py_validate_usdc_issuer()— rejects an explicitly setUSDC_ASSET_ISSUERthat is not a valid Stellar public key (^G[0-9A-Z]{55}$); wired intovalidate_required_env_vars()soassert_valid_config()fails fast in both production and developmentquantara/web_app/tests/test_usdc_issuer_consistency.py(new)constants.py,CollateralManager, and both adapter resolvers all resolve "USDC" to the identical asset id, and that the divergentGA5ZSE…issuer is gone from the adapter tablesquantara/web_app/tests/test_config_validator.pyquantara/frontend/test/utils/constants.test.js(new)USDC_ASSET.issuerdefault equals the backend's canonical defaultAcceptance criteria coverage
Backend
constants.py,soroswap_adapter.py,blend_adapter.py, andCollateralManager.py. (both adapters importUSDC_ASSET_ISSUER/USDC_ASSET_CODEfromweb_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.pydev + prod rejection tests)Frontend
test/utils/constants.test.js)Tests
CollateralManagerresolve "USDC" to the same issuer. (test_usdc_issuer_consistency.py)cd quantara && poetry run pytest web_app/tests. — 377 passed / 26 failed / 6 errors; the failures are the identical pre-existing environment-dependent set onmain(Postgres unavailable,test_user.py/test_airdrop.pyDB connection errors,test_positions.py::test_get_user_positions_no_positionspatching the wrong method) — no new failures, 8 new passing testsDeliberately 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…G5T4GDeverywhere) so no change was needed there. The adapters'_TokenResolvertables 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.py— 18/18 passing (5 + 3 new tests)poetry run pytest web_app/tests— 377 passed / 26 failed / 6 errors, identical failure set tomain(pre-existing environmental), zero regressionsyarn test:run— 89/89 passing (1 new test)black— new/modified lines are clean; remaining reformat candidates in the touched files are pre-existing lines onmain(e.g._DEFAULT_BLEND_*constants, existing test tuple formatting)eslint+prettieron the new frontend test — cleanUSDC_ASSET_ISSUERset to a garbage value and confirm startup fails fast (covered by unit tests; no manual run needed)Env vars / Notes
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 theGA5ZSE…asset will now resolve to theGBBD47…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).