From 7ecbc24d69ddcfd4f7bb3577f1152a3c167b03ae Mon Sep 17 00:00:00 2001 From: metodi96 Date: Fri, 29 May 2026 12:08:52 +0300 Subject: [PATCH] fix(assets): resolve USDC price via primary-protocol fallback in transfer dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Swap/Deposit/Withdraw dropdowns priced each asset via pricesStore.prices[currency.key], where currency came from tryGetCurrencyByDenom(denom). For shared-IBC-denom assets like USDC, that denom-keyed map resolves to a single arbitrary protocol entry (e.g. USDC@NEUTRON) whose key has no published price, so the value defaulted to 0: USDC showed $0.00 and the stable-value comparator sorted it below zero-balance tokens. The Assets table was unaffected because it resolves via the network's primary protocol, which is priced. Add a shared getPriceForCurrency helper that returns the currency's own price or, when its key is absent from the feed, falls back to the network's primary-protocol key for the same ticker — the same path the Assets table uses — before defaulting to "0". Route all seven price lookups across the three forms through it (dropdown lists and amount estimators) so the modal stays consistent, and drop the now-unused prices-store wiring. --- src/common/utils/CurrencyLookup.test.ts | 194 ++++++++++++++++++ src/common/utils/CurrencyLookup.ts | 32 +++ src/modules/assets/components/ReceiveForm.vue | 12 +- src/modules/assets/components/SendForm.vue | 8 +- .../assets/components/SwapForm.test.ts | 3 +- src/modules/assets/components/SwapForm.vue | 14 +- 6 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 src/common/utils/CurrencyLookup.test.ts diff --git a/src/common/utils/CurrencyLookup.test.ts b/src/common/utils/CurrencyLookup.test.ts new file mode 100644 index 00000000..8910a018 --- /dev/null +++ b/src/common/utils/CurrencyLookup.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; +import { Dec } from "@keplr-wallet/unit"; + +// jsdom doesn't provide matchMedia; some store-adjacent modules read it at init. +vi.hoisted(() => { + if (typeof window !== "undefined" && !window.matchMedia) { + (window as any).matchMedia = () => ({ + matches: false, + media: "", + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false + }); + } +}); + +// Mock @/common/api so the config and prices stores import cleanly without a +// real HTTP/WebSocket client. We drive the config store via fetchConfig / +// fetchCurrencies and set prices directly on the prices store. +vi.mock("@/common/api", () => ({ + BackendApi: { + getConfig: vi.fn(), + getCurrencies: vi.fn(), + getNetworkAssets: vi.fn(), + getGatedProtocols: vi.fn(), + getGasFeeConfig: vi.fn(), + getProtocolCurrencies: vi.fn(), + getPrices: vi.fn() + }, + WebSocketClient: { + subscribePrices: vi.fn(() => () => {}) + } +})); + +import { BackendApi } from "@/common/api"; +import { useConfigStore } from "../stores/config"; +import { usePricesStore } from "../stores/prices"; +import { getPriceForCurrency } from "./CurrencyLookup"; + +const api = BackendApi as unknown as Record>; + +// USDC lives on two protocols sharing one IBC denom. OSMOSIS is the network's +// primary protocol and the only one with a published price; NEUTRON's key is +// unpriced. The denom-keyed map can resolve to either entry. +const USDC_OSMOSIS = { + key: "USDC@OSMOSIS", + ticker: "USDC", + protocol: "OSMOSIS", + symbol: "USDC", + shortName: "USDC", + ibcData: "ibc/USDC_SHARED", + decimal_digits: 6, + group: "lpn", + native: false +}; +const USDC_NEUTRON = { + key: "USDC@NEUTRON", + ticker: "USDC", + protocol: "NEUTRON", + symbol: "USDC", + shortName: "USDC", + ibcData: "ibc/USDC_SHARED", + decimal_digits: 6, + group: "lpn", + native: false +}; +const WBTC_OSMOSIS = { + key: "WBTC@OSMOSIS", + ticker: "WBTC", + protocol: "OSMOSIS", + symbol: "WBTC", + shortName: "WBTC", + ibcData: "ibc/WBTC_OSMO", + decimal_digits: 8, + group: "lease", + native: false +}; + +function configFixture() { + return { + protocols: { + OSMOSIS: { + is_active: true, + network: "Osmosis", + position_type: "long", + contracts: { oracle: "o", lpp: "l", leaser: "le", profit: "p" } + }, + NEUTRON: { + is_active: true, + network: "Neutron", + position_type: "short", + contracts: { oracle: "o", lpp: "l", leaser: "le", profit: "p" } + } + }, + networks: [ + { + key: "OSMOSIS", + chain_id: "osmosis-1", + name: "Osmosis", + prefix: "osmo", + value: "osmosis", + primary_protocol: "OSMOSIS", + icon: "/icons/osmosis.svg", + native: false + } + ], + native_asset: { ticker: "NLS", denom: "unls", decimals: 6 } + }; +} + +function currenciesFixture() { + return { + currencies: { + "USDC@OSMOSIS": USDC_OSMOSIS, + "USDC@NEUTRON": USDC_NEUTRON, + "WBTC@OSMOSIS": WBTC_OSMOSIS + }, + lpn: [USDC_OSMOSIS], + lease_currencies: ["WBTC"], + map: {} + }; +} + +async function primedStores() { + api.getConfig.mockResolvedValueOnce(configFixture()); + api.getCurrencies.mockResolvedValueOnce(currenciesFixture()); + + const configStore = useConfigStore(); + await configStore.fetchConfig(); + await configStore.fetchCurrencies(); + configStore.setProtocolFilter("OSMOSIS"); + + const pricesStore = usePricesStore(); + // Only the primary-protocol key is priced; the NEUTRON key is absent. + pricesStore.prices = { "USDC@OSMOSIS": { price: "1.0001", symbol: "USDC" } }; + + return { configStore, pricesStore }; +} + +describe("getPriceForCurrency", () => { + beforeEach(() => { + setActivePinia(createPinia()); + localStorage.clear(); + for (const fn of Object.values(api)) { + fn.mockReset(); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the currency's own price when its key is published", async () => { + await primedStores(); + expect(getPriceForCurrency(USDC_OSMOSIS as any)).toBe("1.0001"); + }); + + it("falls back to the network's primary-protocol key when the denom-resolved key is unpriced", async () => { + await primedStores(); + // USDC resolved via the shared IBC denom landed on the NEUTRON entry, which + // has no published price. The Assets table resolves USDC via the primary + // OSMOSIS protocol, so the dropdown must land on the same price. + expect(getPriceForCurrency(USDC_NEUTRON as any)).toBe("1.0001"); + }); + + it('returns "0" when neither the currency key nor the primary key is priced', async () => { + const { pricesStore } = await primedStores(); + pricesStore.prices = {}; + expect(getPriceForCurrency(USDC_NEUTRON as any)).toBe("0"); + }); + + it("keeps a held USDC balance above zero-balance assets in the dropdown sort", async () => { + await primedStores(); + + // Mirror the dropdown's per-asset value + comparator. USDC is resolved via + // its unpriced NEUTRON key; WBTC holds no balance. + const usdcValue = new Dec("4764612", 6); // 4.764612 USDC + const usdcStable = new Dec(getPriceForCurrency(USDC_NEUTRON as any)).mul(usdcValue); + const wbtcStable = new Dec(getPriceForCurrency(WBTC_OSMOSIS as any)).mul(new Dec("0", 8)); + + expect(usdcStable.isZero()).toBe(false); + + const sorted = [ + { ticker: "WBTC", stable: wbtcStable }, + { ticker: "USDC", stable: usdcStable } + ].sort((a, b) => Number(b.stable.sub(a.stable).toString(8))); + + expect(sorted[0].ticker).toBe("USDC"); + }); +}); diff --git a/src/common/utils/CurrencyLookup.ts b/src/common/utils/CurrencyLookup.ts index a16873c3..5e064949 100644 --- a/src/common/utils/CurrencyLookup.ts +++ b/src/common/utils/CurrencyLookup.ts @@ -7,6 +7,7 @@ import type { CurrencyInfo } from "@/common/api"; import { useConfigStore } from "../stores/config"; +import { usePricesStore } from "../stores/prices"; /** * Get currency by ticker @@ -106,6 +107,37 @@ export function getCurrencyByTickerForProtocol(ticker: string, protocol: string) return currency; } +/** + * Resolve a currency's USD price string, with a network-aware fallback. + * + * The price feed is keyed by `TICKER@PROTOCOL`. Shared IBC denoms (e.g. USDC) + * resolve via `tryGetCurrencyByDenom` to a single, arbitrary protocol entry + * whose `key` may have no published price — which would otherwise default the + * value to 0, display `$0.00`, and mis-sort the asset to the bottom of the + * Swap/Deposit/Withdraw dropdowns. When the currency's own key is absent from + * the feed, fall back to the network's primary-protocol key for the same + * ticker — the same entry the Assets table resolves via + * `getCurrencyByTickerForNetwork` — before finally defaulting to "0". + * + * Returns a decimal price string suitable for `new Dec(...)`. + */ +export function getPriceForCurrency(currency: CurrencyInfo): string { + const pricesStore = usePricesStore(); + + const direct = pricesStore.prices[currency.key]?.price; + if (direct != null) { + return direct; + } + + const configStore = useConfigStore(); + const primary = configStore.getCurrencyByTickerForNetwork(currency.ticker, configStore.protocolFilter); + if (primary && primary.key !== currency.key) { + return pricesStore.prices[primary.key]?.price ?? "0"; + } + + return "0"; +} + /** * Get currency by ticker for the current network filter. * Prefers protocols belonging to the selected network. diff --git a/src/modules/assets/components/ReceiveForm.vue b/src/modules/assets/components/ReceiveForm.vue index 0083b710..2d4e8fd1 100644 --- a/src/modules/assets/components/ReceiveForm.vue +++ b/src/modules/assets/components/ReceiveForm.vue @@ -115,14 +115,17 @@ import { computed, onUnmounted, ref, watch, h, inject } from "vue"; import { useI18n } from "vue-i18n"; import { externalWallet, Logger, walletOperation, WalletUtils } from "@/common/utils"; import { formatDecAsUsd, formatUsd, formatTokenBalance } from "@/common/utils/NumberFormatUtils"; -import { getCurrencyByTickerForNetwork, tryGetCurrencyByDenom } from "@/common/utils/CurrencyLookup"; +import { + getCurrencyByTickerForNetwork, + getPriceForCurrency, + tryGetCurrencyByDenom +} from "@/common/utils/CurrencyLookup"; import { getSkipRouteConfig } from "@/common/utils/ConfigService"; import { coin } from "@cosmjs/stargate"; import { Decimal } from "@cosmjs/math"; import { SkipRouter, type SkipTxResult } from "@/common/utils/SkipRoute"; import type { NetworkInfo } from "@/common/api/types/config"; import { Dec } from "@keplr-wallet/unit"; -import { usePricesStore } from "@/common/stores/prices"; import { useConfigStore } from "@/common/stores/config"; import { useHistoryStore } from "@/common/stores/history"; import { HISTORY_ACTIONS } from "@/modules/history/types"; @@ -141,7 +144,7 @@ const assets = computed(() => { const balance = formatTokenBalance(value); const exactBalance = value.isZero() ? "0" : value.toString(asset.decimal_digits).replace(/\.?0+$/, ""); - const price = new Dec(pricesStore.prices[currency.key]?.price ?? 0); + const price = new Dec(getPriceForCurrency(currency)); const stable = price.mul(value); data.push({ name: currency.name, @@ -176,7 +179,6 @@ let route: RouteResponse | null; const walletStore = useWalletStore(); const balancesStore = useBalancesStore(); -const pricesStore = usePricesStore(); const configStore = useConfigStore(); const historyStore = useHistoryStore(); const networks = ref(NETWORK_DATA.list); @@ -266,7 +268,7 @@ const calculatedBalance = computed(() => { const currency = tryGetCurrencyByDenom(asset.from!); if (!currency) return formatUsd(0); - const price = new Dec(pricesStore.prices[currency.key!]?.price ?? 0); + const price = new Dec(getPriceForCurrency(currency)); const v = amount?.value?.length ? amount?.value : "0"; const stable = price.mul(new Dec(v)); return formatDecAsUsd(stable); diff --git a/src/modules/assets/components/SendForm.vue b/src/modules/assets/components/SendForm.vue index 9fcbcff6..4d7ec22a 100644 --- a/src/modules/assets/components/SendForm.vue +++ b/src/modules/assets/components/SendForm.vue @@ -131,13 +131,12 @@ import { } from "@/common/utils"; import { getSkipRouteConfig } from "@/common/utils/ConfigService"; import { formatDecAsUsd, formatUsd, formatTokenBalance } from "@/common/utils/NumberFormatUtils"; -import { tryGetCurrencyByDenom } from "@/common/utils/CurrencyLookup"; +import { getPriceForCurrency, tryGetCurrencyByDenom } from "@/common/utils/CurrencyLookup"; import { coin } from "@cosmjs/stargate"; import { Decimal } from "@cosmjs/math"; import { SkipRouter, type SkipTxResult } from "@/common/utils/SkipRoute"; import type { NetworkInfo } from "@/common/api/types/config"; import { Dec } from "@keplr-wallet/unit"; -import { usePricesStore } from "@/common/stores/prices"; import { ErrorCodes } from "@/config/global"; import { useConfigStore } from "@/common/stores/config"; import { HISTORY_ACTIONS } from "@/modules/history/types"; @@ -155,7 +154,7 @@ const assets = computed(() => { const value = new Dec(asset.balance?.amount.toString() ?? 0, asset.decimal_digits); const balance = formatTokenBalance(value); const exactBalance = value.isZero() ? "0" : value.toString(asset.decimal_digits).replace(/\.?0+$/, ""); - const price = new Dec(pricesStore.prices[currency.key]?.price ?? 0); + const price = new Dec(getPriceForCurrency(currency)); const stable = price.mul(value); data.push({ @@ -200,7 +199,6 @@ const balancesStore = useBalancesStore(); const configStore = useConfigStore(); const historyStore = useHistoryStore(); const networks = ref(NETWORK_DATA.list); -const pricesStore = usePricesStore(); const selectedNetwork = ref(0); const networkCurrencies = ref(balancesStore.filteredBalances); @@ -296,7 +294,7 @@ const calculatedBalance = computed(() => { const currency = tryGetCurrencyByDenom(denom); if (!currency) return formatUsd(0); - const price = new Dec(pricesStore.prices[currency.key!]?.price ?? 0); + const price = new Dec(getPriceForCurrency(currency)); const v = amount?.value?.length ? amount?.value : "0"; const stable = price.mul(new Dec(v)); return formatDecAsUsd(stable); diff --git a/src/modules/assets/components/SwapForm.test.ts b/src/modules/assets/components/SwapForm.test.ts index 468f08cd..d189c4d5 100644 --- a/src/modules/assets/components/SwapForm.test.ts +++ b/src/modules/assets/components/SwapForm.test.ts @@ -111,7 +111,8 @@ vi.mock("@/common/utils/ConfigService", () => ({ })); vi.mock("@/common/utils/CurrencyLookup", () => ({ - tryGetCurrencyByDenom: hoisted.tryGetCurrencyByDenomMock + tryGetCurrencyByDenom: hoisted.tryGetCurrencyByDenomMock, + getPriceForCurrency: (currency: { key: string }) => hoisted.pricesState[currency.key]?.price ?? "0" })); vi.mock("@/common/utils/NumberFormatUtils", () => ({ diff --git a/src/modules/assets/components/SwapForm.vue b/src/modules/assets/components/SwapForm.vue index aaa7a094..8311d28e 100644 --- a/src/modules/assets/components/SwapForm.vue +++ b/src/modules/assets/components/SwapForm.vue @@ -123,10 +123,9 @@ import { useWalletStore } from "@/common/stores/wallet"; import { useBalancesStore } from "@/common/stores/balances"; import { externalWallet, Logger, validateAmountV2, walletOperation, WalletUtils } from "@/common/utils"; import { getSkipRouteConfig } from "@/common/utils/ConfigService"; -import { tryGetCurrencyByDenom } from "@/common/utils/CurrencyLookup"; +import { getPriceForCurrency, tryGetCurrencyByDenom } from "@/common/utils/CurrencyLookup"; import { formatDecAsUsd, formatTokenBalance } from "@/common/utils/NumberFormatUtils"; import { Coin, Dec, Int } from "@keplr-wallet/unit"; -import { usePricesStore } from "@/common/stores/prices"; import { h } from "vue"; import { CurrencyUtils } from "@nolus/nolusjs"; import { MultipleCurrencyEventType, type SkipRouteConfigType } from "@/common/types"; @@ -149,7 +148,6 @@ const id = Date.now(); const wallet = useWalletStore(); const balancesStore = useBalancesStore(); -const pricesStore = usePricesStore(); const configStore = useConfigStore(); const historyStore = useHistoryStore(); const i18n = useI18n(); @@ -203,7 +201,7 @@ const assets = computed(() => { const value = new Dec(amount, currency.decimal_digits); const balance = formatTokenBalance(value); - const price = new Dec(pricesStore.prices[currency.key]?.price ?? 0); + const price = new Dec(getPriceForCurrency(currency)); const stable = price.mul(value); data.push({ @@ -229,14 +227,18 @@ const assets = computed(() => { }); const firstCalculatedBalance = computed(() => { - const price = new Dec(pricesStore.prices[selectedFirstCurrencyOption.value?.value as string]?.price ?? 0); + const ibcData = selectedFirstCurrencyOption.value?.ibcData; + const currency = ibcData ? tryGetCurrencyByDenom(ibcData) : null; + const price = new Dec(currency ? getPriceForCurrency(currency) : "0"); const v = amount?.value?.length ? amount?.value : "0"; const stable = price.mul(new Dec(v)); return formatDecAsUsd(stable); }); const secondCalculatedBalance = computed(() => { - const price = new Dec(pricesStore.prices[selectedSecondCurrencyOption.value?.value as string]?.price ?? 0); + const ibcData = selectedSecondCurrencyOption.value?.ibcData; + const currency = ibcData ? tryGetCurrencyByDenom(ibcData) : null; + const price = new Dec(currency ? getPriceForCurrency(currency) : "0"); const v = swapToAmount?.value?.length ? swapToAmount?.value : "0"; const stable = price.mul(new Dec(v)); return formatDecAsUsd(stable);