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
194 changes: 194 additions & 0 deletions src/common/utils/CurrencyLookup.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ReturnType<typeof vi.fn>>;

// 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");
});
});
32 changes: 32 additions & 0 deletions src/common/utils/CurrencyLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import type { CurrencyInfo } from "@/common/api";
import { useConfigStore } from "../stores/config";
import { usePricesStore } from "../stores/prices";

/**
* Get currency by ticker
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 7 additions & 5 deletions src/modules/assets/components/ReceiveForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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[]>(NETWORK_DATA.list);
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 3 additions & 5 deletions src/modules/assets/components/SendForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -200,7 +199,6 @@ const balancesStore = useBalancesStore();
const configStore = useConfigStore();
const historyStore = useHistoryStore();
const networks = ref<Network[]>(NETWORK_DATA.list);
const pricesStore = usePricesStore();

const selectedNetwork = ref(0);
const networkCurrencies = ref<ExternalCurrency[] | AssetBalance[]>(balancesStore.filteredBalances);
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/modules/assets/components/SwapForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
14 changes: 8 additions & 6 deletions src/modules/assets/components/SwapForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down
Loading