diff --git a/packages/bridge/src/axelar/index.ts b/packages/bridge/src/axelar/index.ts index 967b2e3299..59c77e4bf1 100644 --- a/packages/bridge/src/axelar/index.ts +++ b/packages/bridge/src/axelar/index.ts @@ -51,6 +51,7 @@ export class AxelarBridgeProvider implements BridgeProvider { protected _queryClient: AxelarQueryAPI | null = null; protected _assetTransferClient: AxelarAssetTransfer | null = null; protected protoRegistry = new Registry(ibcProtoRegistry); + protected axelarChainId: string; protected readonly axelarScanBaseUrl: string; protected readonly axelarApiBaseUrl: string; @@ -64,6 +65,8 @@ export class AxelarBridgeProvider implements BridgeProvider { this.ctx.env === "mainnet" ? "https://api.axelarscan.io" : "https://testnet.api.axelarscan.io"; + this.axelarChainId = + ctx.env === "mainnet" ? "axelar-dojo-1" : "axelar-testnet-lisbon-3"; } async getQuote(params: GetBridgeQuoteParams): Promise { @@ -568,7 +571,7 @@ export class AxelarBridgeProvider implements BridgeProvider { }); const timeoutHeight = await this.ctx.getTimeoutHeight({ - chainId: toChain.chainId.toString(), + chainId: this.axelarChainId, }); const ibcAsset = getAssetFromAssetList({ @@ -578,11 +581,7 @@ export class AxelarBridgeProvider implements BridgeProvider { }); if (!ibcAsset) { - throw new BridgeQuoteError({ - bridgeId: AxelarBridgeProvider.ID, - errorType: "CreateCosmosTxError", - message: "Could not find IBC asset info", - }); + throw new Error("Could not find IBC asset info: " + fromAsset.denom); } const ibcTransferMethod = ibcAsset.rawAsset.transferMethods.find( @@ -590,11 +589,9 @@ export class AxelarBridgeProvider implements BridgeProvider { ) as IbcTransferMethod | undefined; if (!ibcTransferMethod) { - throw new BridgeQuoteError({ - bridgeId: AxelarBridgeProvider.ID, - errorType: "CreateCosmosTxError", - message: "Could not find IBC asset transfer info", - }); + throw new Error( + "Could not find IBC asset transfer info: " + ibcAsset.symbol + ); } const { typeUrl, value: msg } = cosmosMsgOpts.ibcTransfer.messageComposer( diff --git a/packages/bridge/src/interface.ts b/packages/bridge/src/interface.ts index 9b0d898922..f58f539dd1 100644 --- a/packages/bridge/src/interface.ts +++ b/packages/bridge/src/interface.ts @@ -12,8 +12,12 @@ export interface BridgeProviderContext { assetLists: AssetList[]; chainList: Chain[]; - /** Provides current timeout height for a chain of given chainId. */ - getTimeoutHeight(params: { chainId: string }): Promise<{ + /** Provides current timeout height for a chain of given chainId. + * If a destination address is provided, the bech32Prefix will be used to get the chain. */ + getTimeoutHeight(params: { + chainId?: string; + destinationAddress?: string; + }): Promise<{ revisionNumber: string | undefined; revisionHeight: string; }>; diff --git a/packages/bridge/src/skip/__tests__/skip-bridge-provider.spec.ts b/packages/bridge/src/skip/__tests__/skip-bridge-provider.spec.ts index 56c03585b4..7f3a05b315 100644 --- a/packages/bridge/src/skip/__tests__/skip-bridge-provider.spec.ts +++ b/packages/bridge/src/skip/__tests__/skip-bridge-provider.spec.ts @@ -339,7 +339,11 @@ describe("SkipBridgeProvider", () => { const txRequest = (await provider.createTransaction( "1", - "osmosis-1", + { + chainId: "osmosis-1", + chainName: "osmosis", + chainType: "cosmos", + }, "0xabc", messages )) as EvmBridgeTransactionRequest; diff --git a/packages/bridge/src/skip/index.ts b/packages/bridge/src/skip/index.ts index 68c48901a1..a29b81303f 100644 --- a/packages/bridge/src/skip/index.ts +++ b/packages/bridge/src/skip/index.ts @@ -129,6 +129,9 @@ export class SkipBridgeProvider implements BridgeProvider { msg.includes( "Input amount is too low to cover" // Could be Axelar or CCTP + ) || + msg.includes( + "Difference in USD value of route input and output is too large" ) ) { throw new BridgeQuoteError({ @@ -212,7 +215,7 @@ export class SkipBridgeProvider implements BridgeProvider { const transactionRequest = await this.createTransaction( fromChain.chainId.toString(), - toChain.chainId.toString(), + toChain, fromAddress as Address, msgs ); @@ -441,7 +444,7 @@ export class SkipBridgeProvider implements BridgeProvider { async createTransaction( fromChainId: string, - toChainId: string, + toChain: BridgeChain, address: Address, messages: SkipMsg[] ) { @@ -456,7 +459,7 @@ export class SkipBridgeProvider implements BridgeProvider { if ("multi_chain_msg" in message) { return await this.createCosmosTransaction( - toChainId, + toChain, message.multi_chain_msg ); } @@ -464,7 +467,7 @@ export class SkipBridgeProvider implements BridgeProvider { } async createCosmosTransaction( - toChainId: string, + toChain: BridgeChain, message: SkipMultiChainMsg ): Promise { const messageData = JSON.parse(message.msg); @@ -499,9 +502,14 @@ export class SkipBridgeProvider implements BridgeProvider { } else { // is an ibc transfer - const timeoutHeight = await this.ctx.getTimeoutHeight({ - chainId: toChainId, - }); + // If toChain is not cosmos, this IBC transfer is an + // intermediary IBC transfer where we need to get the + // timeout from the bech32 prefix of the receiving address + const timeoutHeight = await this.ctx.getTimeoutHeight( + toChain.chainType === "cosmos" + ? toChain + : { destinationAddress: messageData.receiver } + ); const { typeUrl, value } = cosmosMsgOpts.ibcTransfer.messageComposer({ sourcePort: messageData.source_port, diff --git a/packages/bridge/src/squid/index.ts b/packages/bridge/src/squid/index.ts index e473f1537f..3245a3f097 100644 --- a/packages/bridge/src/squid/index.ts +++ b/packages/bridge/src/squid/index.ts @@ -271,7 +271,7 @@ export class SquidBridgeProvider implements BridgeProvider { : await this.createCosmosTransaction( transactionRequest.data, fromAddress, - toChain.chainId.toString(), + toChain, { denom: fromAsset.address, amount: fromAmount } // TODO: uncomment when we're able to find a way to get gas limit from Squid // or get it ourselves @@ -495,7 +495,7 @@ export class SquidBridgeProvider implements BridgeProvider { async createCosmosTransaction( data: string, fromAddress: string, - toChainId: string, + toChain: BridgeChain, fromCoin: { denom: string; amount: string; @@ -533,9 +533,14 @@ export class SquidBridgeProvider implements BridgeProvider { }; }; - const timeoutHeight = await this.ctx.getTimeoutHeight({ - chainId: toChainId, - }); + // If toChain is not cosmos, this IBC transfer is an + // intermediary IBC transfer where we need to get the + // timeout from the bech32 prefix of the receiving address + const timeoutHeight = await this.ctx.getTimeoutHeight( + toChain.chainType === "cosmos" + ? toChain + : { destinationAddress: ibcData.msg.receiver } + ); const { typeUrl, value: msg } = cosmosMsgOpts.ibcTransfer.messageComposer({ @@ -587,12 +592,9 @@ export class SquidBridgeProvider implements BridgeProvider { }; } - throw new BridgeQuoteError({ - bridgeId: SquidBridgeProvider.ID, - errorType: "CreateCosmosTxError", - message: - "Unknown message type. Osmosis FrontEnd only supports the IBC transfer and cosmwasm executeMsg message type", - }); + throw new Error( + "Unknown message type. Osmosis FrontEnd only supports the IBC transfer and cosmwasm executeMsg message type" + ); } catch (e) { const error = e as Error | BridgeQuoteError; diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index bfa5aed48e..4f7339e67f 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -25,6 +25,7 @@ export const INDEXER_DATA_URL = export const NUMIA_BASE_URL = process.env.NEXT_PUBLIC_NUMIA_BASE_URL ?? "https://public-osmosis-api.numia.xyz"; +export const NUMIA_API_KEY = process.env.NUMIA_API_KEY; // sqs export const SIDECAR_BASE_URL = diff --git a/packages/server/src/queries/complex/concentrated-liquidity/index.ts b/packages/server/src/queries/complex/concentrated-liquidity/index.ts index d87616441e..e91af6e0f3 100644 --- a/packages/server/src/queries/complex/concentrated-liquidity/index.ts +++ b/packages/server/src/queries/complex/concentrated-liquidity/index.ts @@ -6,7 +6,7 @@ import { PricePretty, RatePretty, } from "@keplr-wallet/unit"; -import { maxTick, minTick, tickToSqrtPrice } from "@osmosis-labs/math"; +import { BigDec, maxTick, minTick, tickToSqrtPrice } from "@osmosis-labs/math"; import { AssetList, Chain } from "@osmosis-labs/types"; import { aggregateCoinsByDenom, timeout } from "@osmosis-labs/utils"; import cachified, { CacheEntry } from "cachified"; @@ -395,9 +395,12 @@ export async function mapGetUserPositionDetails({ : undefined; const currentPrice = getPriceFromSqrtPrice({ - sqrtPrice: new Dec( + // Given that we're only calculating for display purposes, + // and not for quoting or provision of liquidity, + // the loss of precision is acceptable. + sqrtPrice: new BigDec( (pool.raw as ConcentratedPoolRawResponse).current_sqrt_price - ), + ).toDec(), baseCoin, quoteCoin, }); diff --git a/packages/server/src/queries/complex/get-timeout-height.ts b/packages/server/src/queries/complex/get-timeout-height.ts index bee9cf0ef6..7309c22a9e 100644 --- a/packages/server/src/queries/complex/get-timeout-height.ts +++ b/packages/server/src/queries/complex/get-timeout-height.ts @@ -7,13 +7,20 @@ import { queryRPCStatus } from "../../queries/cosmos"; export async function getTimeoutHeight({ chainList, chainId, + destinationAddress, }: { chainList: Chain[]; - chainId: string; + chainId?: string; + /** + * WARNING: bech32 prefix may be the same across different chains, + * retulting in the use of an unintended chain. + */ + destinationAddress?: string; }) { const destinationCosmosChain = getChain({ chainList, chainId, + destinationAddress, }); if (!destinationCosmosChain) { diff --git a/packages/server/src/queries/complex/portfolio/index.ts b/packages/server/src/queries/complex/portfolio/index.ts index 8c741c990c..0e7d69859a 100644 --- a/packages/server/src/queries/complex/portfolio/index.ts +++ b/packages/server/src/queries/complex/portfolio/index.ts @@ -1 +1,2 @@ export * from "./allocation"; +export * from "./portfolio"; diff --git a/packages/server/src/queries/complex/portfolio/portfolio.ts b/packages/server/src/queries/complex/portfolio/portfolio.ts new file mode 100644 index 0000000000..2468be6112 --- /dev/null +++ b/packages/server/src/queries/complex/portfolio/portfolio.ts @@ -0,0 +1,49 @@ +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { queryPortfolioOverTime } from "../../../queries/data-services"; +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; + +export type Range = "1d" | "7d" | "1mo" | "1y" | "all"; + +const transactionsCache = new LRUCache(DEFAULT_LRU_OPTIONS); + +export interface ChartPortfolioOverTimeResponse { + time: number; + value: number; +} + +export async function getPortfolioOverTime({ + address, + range, +}: { + address: string; + range: Range; +}): Promise { + return await cachified({ + cache: transactionsCache, + ttl: 1000 * 60, // 60 seconds + key: `portfolio-over-time-${address}-range-${range}`, + getFreshValue: async () => { + const data = await queryPortfolioOverTime({ + address, + range, + }); + + // sort data by timestamp in ascending order for chart + const sortedData = data?.sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + // map data to time and value for chart + const mappedData = sortedData.map((d) => ({ + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#get_the_number_of_seconds_since_the_ecmascript_epoch + time: Math.floor(new Date(d.timestamp).getTime() / 1000), // convert to seconds + value: d.usd, + })); + + return mappedData; + }, + }); +} diff --git a/packages/server/src/queries/data-services/index.ts b/packages/server/src/queries/data-services/index.ts index 31295a31bf..48bdbfd25f 100644 --- a/packages/server/src/queries/data-services/index.ts +++ b/packages/server/src/queries/data-services/index.ts @@ -4,6 +4,7 @@ export * from "./filtered-pools"; export * from "./market-cap"; export * from "./pool-aprs"; export * from "./pools-fees"; +export * from "./portfolio-over-time"; export * from "./position-performance"; export * from "./price-range-apr"; export * from "./staking-apr"; diff --git a/packages/server/src/queries/data-services/portfolio-over-time.ts b/packages/server/src/queries/data-services/portfolio-over-time.ts new file mode 100644 index 0000000000..c4f769ac6a --- /dev/null +++ b/packages/server/src/queries/data-services/portfolio-over-time.ts @@ -0,0 +1,27 @@ +import { apiClient } from "@osmosis-labs/utils"; + +import { NUMIA_BASE_URL } from "../../env"; + +export interface PortfolioOverTimeResponse { + timestamp: string; + usd: number; +} + +export async function queryPortfolioOverTime({ + address, + range, +}: { + address: string; + range: string; +}): Promise { + const url = new URL("/users/portfolio/over_time", NUMIA_BASE_URL); + + url.searchParams.append("address", address); + url.searchParams.append("range", range); + + const headers = { + Authorization: `Bearer ${process.env.NUMIA_API_KEY}`, + }; + + return apiClient(url.toString(), { headers }); +} diff --git a/packages/server/src/queries/data-services/staking-apr.ts b/packages/server/src/queries/data-services/staking-apr.ts index f5421a52e8..b9b31e6016 100644 --- a/packages/server/src/queries/data-services/staking-apr.ts +++ b/packages/server/src/queries/data-services/staking-apr.ts @@ -1,6 +1,6 @@ import { apiClient } from "@osmosis-labs/utils"; -import { NUMIA_BASE_URL } from "../../env"; +import { NUMIA_API_KEY, NUMIA_BASE_URL } from "../../env"; interface StakingAprResponse { labels: string; @@ -20,7 +20,7 @@ export async function queryStakingApr({ url.searchParams.append("end_date", endDate); const headers = { - Authorization: `Bearer ${process.env.NEXT_PUBLIC_NUMIA_API_KEY}`, + Authorization: `Bearer ${NUMIA_API_KEY}`, }; return apiClient(url.toString(), { headers }); diff --git a/packages/server/src/queries/data-services/transactions.ts b/packages/server/src/queries/data-services/transactions.ts index 855bc715ce..6deea79409 100644 --- a/packages/server/src/queries/data-services/transactions.ts +++ b/packages/server/src/queries/data-services/transactions.ts @@ -94,7 +94,7 @@ export async function queryTransactions({ url.searchParams.append("pageSize", pageSize); const headers = { - Authorization: `Bearer ${process.env.NEXT_PUBLIC_NUMIA_API_KEY}`, + Authorization: `Bearer ${process.env.NUMIA_API_KEY}`, }; return apiClient(url.toString(), { headers }); diff --git a/packages/trpc/src/portfolio.ts b/packages/trpc/src/portfolio.ts index b07f9125fe..868baede4d 100644 --- a/packages/trpc/src/portfolio.ts +++ b/packages/trpc/src/portfolio.ts @@ -1,9 +1,29 @@ +import { getPortfolioOverTime } from "@osmosis-labs/server"; import { getAllocation } from "@osmosis-labs/server"; +import { ChartPortfolioOverTimeResponse } from "@osmosis-labs/server/src/queries/complex/portfolio/portfolio"; import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "./api"; export const portfolioRouter = createTRPCRouter({ + getPortfolioOverTime: publicProcedure + .input( + z.object({ + address: z.string(), + range: z.enum(["1d", "7d", "1mo", "1y", "all"]), + }) + ) + .query( + async ({ + input: { address, range }, + }): Promise => { + const res = await getPortfolioOverTime({ + address, + range, + }); + return res; + } + ), getAllocation: publicProcedure .input( z.object({ diff --git a/packages/tx/src/gas.ts b/packages/tx/src/gas.ts index 7ab51e96ae..8b85cd29e8 100644 --- a/packages/tx/src/gas.ts +++ b/packages/tx/src/gas.ts @@ -107,7 +107,7 @@ export async function estimateGasFee({ }).catch((e) => { if (fallbackGasLimit) { console.warn( - "Using fallback gas limit", + "WARNING Using fallback gas limit:", e instanceof Error ? e.message : e ); return { gasUsed: fallbackGasLimit, coinsSpent: [] }; diff --git a/packages/web/components/assets/price.tsx b/packages/web/components/assets/price.tsx index 6d6f4ec139..626c9c8903 100644 --- a/packages/web/components/assets/price.tsx +++ b/packages/web/components/assets/price.tsx @@ -1,4 +1,4 @@ -import { RatePretty } from "@keplr-wallet/unit"; +import { PricePretty, RatePretty } from "@keplr-wallet/unit"; import { CommonPriceChartTimeFrame } from "@osmosis-labs/server"; import classNames from "classnames"; import { FunctionComponent, useMemo } from "react"; @@ -16,14 +16,26 @@ export const PriceChange: FunctionComponent< { priceChange: RatePretty; overrideTextClasses?: string; + value?: PricePretty; } & CustomClasses -> = ({ priceChange, overrideTextClasses = "body1", className }) => { +> = ({ priceChange, overrideTextClasses = "body1", className, value }) => { const isBullish = priceChange.toDec().isPositive(); const isBearish = priceChange.toDec().isNegative(); const isFlat = !isBullish && !isBearish; // remove negative symbol since we're using arrows - if (isBearish) priceChange = priceChange.mul(new RatePretty(-1)); + if (isBearish) { + priceChange = priceChange.mul(new RatePretty(-1)); + value = value?.mul(new RatePretty(-1)); + } + + const priceChangeDisplay = priceChange + .maxDecimals(1) + .inequalitySymbol(false) + .toString(); + + const formattedPriceChangeDisplay = + value !== undefined ? `(${priceChangeDisplay})` : priceChangeDisplay; return (
@@ -53,9 +65,9 @@ export const PriceChange: FunctionComponent< overrideTextClasses )} > - {isFlat - ? "-" - : priceChange.maxDecimals(1).inequalitySymbol(false).toString()} + {value !== undefined ? value.toString() + " " : null} + + {isFlat ? "-" : formattedPriceChangeDisplay}
); diff --git a/packages/web/components/bridge/amount-and-review-screen.tsx b/packages/web/components/bridge/amount-and-review-screen.tsx index e076d0b69d..ab26e163ad 100644 --- a/packages/web/components/bridge/amount-and-review-screen.tsx +++ b/packages/web/components/bridge/amount-and-review-screen.tsx @@ -179,31 +179,45 @@ export const AmountAndReviewScreen = observer( const quote = useBridgeQuotes({ toAddress, toChain: toChain, - toAsset: toAsset - ? { - address: - toChain?.chainType === "evm" - ? getAddress(toAsset.address) - : toAsset.address, - decimals: toAsset.decimals, - denom: toAsset.denom, - imageUrl: assetsInOsmosis?.[0].coinImageUrl, - } - : undefined, + toAsset: (() => { + if (!toAsset) return undefined; + const asset = assetsInOsmosis?.find( + (a) => + a.coinMinimalDenom === toAsset.address || + toAsset.denom === a.coinDenom + ); + return { + address: + toChain?.chainType === "evm" + ? getAddress(toAsset.address) + : toAsset.address, + decimals: toAsset.decimals, + denom: toAsset.denom, + imageUrl: asset?.coinImageUrl ?? assetsInOsmosis?.[0]?.coinImageUrl, + isUnstable: !!asset?.isUnstable, + }; + })(), fromAddress, fromChain: fromChain, - fromAsset: fromAsset - ? { - address: - fromChain?.chainType === "evm" - ? getAddress(fromAsset.address) - : fromAsset.address, - decimals: fromAsset.decimals, - denom: fromAsset.denom, - amount: fromAsset.amount, - imageUrl: assetsInOsmosis?.[0].coinImageUrl, - } - : undefined, + fromAsset: (() => { + if (!fromAsset) return undefined; + const asset = assetsInOsmosis?.find( + (a) => + a.coinMinimalDenom === fromAsset.address || + fromAsset.denom === a.coinDenom + ); + return { + address: + fromChain?.chainType === "evm" + ? getAddress(fromAsset.address) + : fromAsset.address, + decimals: fromAsset.decimals, + denom: fromAsset.denom, + amount: fromAsset.amount, + imageUrl: asset?.coinImageUrl ?? assetsInOsmosis?.[0]?.coinImageUrl, + isUnstable: !!asset?.isUnstable, + }; + })(), direction, onRequestClose: onClose, inputAmount: cryptoAmount, diff --git a/packages/web/components/bridge/amount-screen.tsx b/packages/web/components/bridge/amount-screen.tsx index 0146ae8f3c..f34fa435f0 100644 --- a/packages/web/components/bridge/amount-screen.tsx +++ b/packages/web/components/bridge/amount-screen.tsx @@ -161,6 +161,8 @@ export const AmountScreen = observer( isInsufficientFee, warnUserOfPriceImpact, warnUserOfSlippage, + errorBoxMessage, + warningBoxMessage, } = quote; const [areMoreOptionsVisible, setAreMoreOptionsVisible] = useState(false); @@ -520,9 +522,9 @@ export const AmountScreen = observer( } }, [ counterpartySupportedAssetsByChainId, - toAsset, direction, setToAsset, + toAsset, toChain, ]); @@ -582,6 +584,17 @@ export const AmountScreen = observer( toChain, ]); + /** If an asset is disabled */ + const areAssetTransfersDisabled = useMemo(() => { + return direction === "withdraw" + ? Boolean(canonicalAsset?.areTransfersDisabled) + : Boolean( + assetsInOsmosis?.find( + (a) => a.coinMinimalDenom === toAsset?.address + )?.areTransfersDisabled + ); + }, [direction, canonicalAsset, assetsInOsmosis, toAsset?.address]); + const onChangeCryptoInput = useCallback( (amount: string) => { if (isNil(fromAsset?.decimals)) return; @@ -629,21 +642,12 @@ export const AmountScreen = observer( return ; } - /** If an asset is disabled */ - const isDisabled = - direction === "withdraw" - ? Boolean(canonicalAsset.areTransfersDisabled) - : Boolean( - assetsInOsmosis.find((a) => a.coinMinimalDenom === toAsset?.address) - ?.areTransfersDisabled - ); - // This condition will be met iff: // * An asset is disabled, effectively its FF is turned off for whatever reason // * There's no supportedAssets returned from providers, so we don't know the to/from asset/chain depending on direction // * Quoting is disabled for the current selection, meaning providers can't provide quotes but they may provide external URLs if ( - isDisabled || + areAssetTransfersDisabled || !fromChain || !fromAsset || !toChain || @@ -1269,6 +1273,23 @@ export const AmountScreen = observer( + {(errorBoxMessage || warningBoxMessage) && ( +
+ +
+

+ {errorBoxMessage?.heading ?? warningBoxMessage?.heading} +

+

+ {errorBoxMessage?.description ?? + warningBoxMessage?.description} +

+
+
+ )} - - - - - ) : ( - - )} - - ); -}); - -const UserPositionsSection: FunctionComponent<{ address?: string }> = ({ - address, -}) => { - const { t } = useTranslation(); - const router = useRouter(); - const { - hasPositions, - hasPools, - isLoading: isLoadingPositions, - } = useUserPositionsData(address); - - if (isLoadingPositions) { - return ( -
- -
- ); - } - - if (hasPositions || hasPools) - return ( - <> - {hasPositions && ( -
- - {t("portfolio.yourSuperchargedPositions")} - - -
- )} - {hasPools && ( -
- - {t("portfolio.yourLiquidityPools")} - - -
- )} - - ); - - return ( -
- no positions -
-
{t("portfolio.noPositions")}
-

- {t("portfolio.unlockPotential")} -

- -
-
- ); -}; - -const UserZeroBalanceTableSplash: FunctionComponent = () => { - const { t } = useTranslation(); - const { startBridge, fiatRampSelection } = useBridge(); - - return ( -
- no balances -
{t("portfolio.noAssets", { osmosis: "Osmosis" })}
-

{t("portfolio.getStarted")}

-
- - -
-
- ); -}; - -const GetStartedWithOsmosis: FunctionComponent = () => { - const { chainStore } = useStore(); - const { t } = useTranslation(); - - const { onOpenWalletSelect } = useWalletSelect(); - - return ( -
-

{t("portfolio.connectWallet")}

- -
- ); -}; - -const WalletDisconnectedSplash: FunctionComponent = () => ( -
- home - home -
-); - -function useUserPositionsData(address: string | undefined) { - const { data: positions, isLoading: isLoadingUserPositions } = - api.local.concentratedLiquidity.getUserPositions.useQuery( - { - userOsmoAddress: address ?? "", - }, - { - enabled: Boolean(address), - - // expensive query - trpc: { - context: { - skipBatch: true, - }, - }, - } - ); - - const hasPositions = Boolean(positions?.length); - - const { data: allMyPoolDetails, isLoading: isLoadingMyPoolDetails } = - api.edge.pools.getUserPools.useQuery( - { - userOsmoAddress: address ?? "", - }, - { - enabled: Boolean(address), - - // expensive query - trpc: { - context: { - skipBatch: true, - }, - }, - } - ); - const hasPools = Boolean(allMyPoolDetails?.length); - - return { - hasPositions, - hasPools, - isLoading: isLoadingUserPositions || isLoadingMyPoolDetails, - }; -} diff --git a/packages/web/components/complex/portfolio/assets-overview.tsx b/packages/web/components/complex/portfolio/assets-overview.tsx new file mode 100644 index 0000000000..0fb9fff984 --- /dev/null +++ b/packages/web/components/complex/portfolio/assets-overview.tsx @@ -0,0 +1,216 @@ +import { PricePretty } from "@keplr-wallet/unit"; +import { Dec, RatePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { + ChartPortfolioOverTimeResponse, + Range, +} from "@osmosis-labs/server/src/queries/complex/portfolio/portfolio"; +import classNames from "classnames"; +import dayjs from "dayjs"; +import { AreaData, Time } from "lightweight-charts"; +import { observer } from "mobx-react-lite"; +import { FunctionComponent, useState } from "react"; + +import { Icon } from "~/components/assets"; +import { CreditCardIcon } from "~/components/assets/credit-card-icon"; +import { GetStartedWithOsmosis } from "~/components/complex/portfolio/get-started-with-osmosis"; +import { PortfolioHistoricalChart } from "~/components/complex/portfolio/historical-chart"; +import { PortfolioPerformance } from "~/components/complex/portfolio/performance"; +import { DataPoint } from "~/components/complex/portfolio/types"; +import { SkeletonLoader } from "~/components/loaders/skeleton-loader"; +import { useFormatDate } from "~/components/transactions/transaction-utils"; +import { CustomClasses } from "~/components/types"; +import { Button } from "~/components/ui/button"; +import { useTranslation, useWalletSelect, useWindowSize } from "~/hooks"; +import { useBridge } from "~/hooks/bridge"; +import { useStore } from "~/stores"; +import { api } from "~/utils/trpc"; + +const calculatePortfolioPerformance = ( + data: ChartPortfolioOverTimeResponse[] | undefined, + dataPoint: DataPoint +): { + selectedPercentageRatePretty: RatePretty; + selectedDifferencePricePretty: PricePretty; + totalPriceChange: number; +} => { + const openingPrice = data?.[0]?.value; + const openingPriceWithFallback = !openingPrice ? 1 : openingPrice; // handle first value being 0 or undefined + const selectedDifference = (dataPoint?.value ?? 0) - openingPriceWithFallback; + const selectedPercentage = selectedDifference / openingPriceWithFallback; + const selectedPercentageRatePretty = new RatePretty( + new Dec(selectedPercentage) + ); + + const selectedDifferencePricePretty = new PricePretty( + DEFAULT_VS_CURRENCY, + new Dec(selectedDifference) + ); + + const closingPrice = data?.[data.length - 1]?.value; + const closingPriceWithFallback = !closingPrice ? 1 : closingPrice; // handle last value being 0 or undefined + + const totalPriceChange = closingPriceWithFallback - openingPriceWithFallback; + + return { + selectedPercentageRatePretty, + selectedDifferencePricePretty, + totalPriceChange, + }; +}; + +export const AssetsOverview: FunctionComponent< + { + totalValue: PricePretty; + isTotalValueFetched?: boolean; + } & CustomClasses +> = observer(({ totalValue, isTotalValueFetched }) => { + const { accountStore } = useStore(); + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + const { t } = useTranslation(); + const { startBridge, fiatRampSelection } = useBridge(); + const { isLoading: isWalletLoading } = useWalletSelect(); + const { isMobile } = useWindowSize(); + const formatDate = useFormatDate(); + + const address = wallet?.address ?? ""; + + const [showDate, setShowDate] = useState(false); + + const [dataPoint, setDataPoint] = useState({ + time: dayjs().unix() as Time, + value: 0, + }); + + const [range, setRange] = useState("1mo"); + + const { + data: portfolioOverTimeData, + isFetched: isPortfolioOverTimeDataIsFetched, + error, + } = api.local.portfolio.getPortfolioOverTime.useQuery( + { + address, + range, + }, + { + enabled: Boolean(wallet?.isWalletConnected && wallet?.address), + onSuccess: (data) => { + if (data && data.length > 0) { + const lastItem = data[data.length - 1]; + setDataPoint({ + time: lastItem.time as Time, + value: lastItem.value, + }); + } + }, + } + ); + + const { + selectedDifferencePricePretty, + selectedPercentageRatePretty, + totalPriceChange, + } = calculatePortfolioPerformance(portfolioOverTimeData, dataPoint); + + const formattedDate = dataPoint.time + ? formatDate(dayjs.unix(dataPoint.time as number).format("YYYY-MM-DD")) + : undefined; + + if (isWalletLoading) return null; + + const totalDisplayValue = + new PricePretty(DEFAULT_VS_CURRENCY, new Dec(dataPoint.value || 0)) || + totalValue?.toString(); + + return ( +
+ {wallet && wallet.isWalletConnected && wallet.address ? ( + <> +
+ + {t("assets.totalBalance")} + + + + {isMobile ? ( +
{totalDisplayValue?.toString()}
+ ) : ( +

{totalDisplayValue?.toString()}

+ )} +
+ + + +
+
+ + + +
+ + []} + isFetched={isPortfolioOverTimeDataIsFetched} + setDataPoint={setDataPoint} + range={range} + setRange={setRange} + totalPriceChange={totalPriceChange} + error={error} + setShowDate={setShowDate} + resetDataPoint={() => { + setDataPoint({ + time: dayjs().unix() as Time, + value: +totalValue.toDec().toString(), + }); + setShowDate(false); + }} + /> + + ) : ( + + )} +
+ ); +}); diff --git a/packages/web/components/complex/portfolio/get-started-with-osmosis.tsx b/packages/web/components/complex/portfolio/get-started-with-osmosis.tsx new file mode 100644 index 0000000000..e5edab519e --- /dev/null +++ b/packages/web/components/complex/portfolio/get-started-with-osmosis.tsx @@ -0,0 +1,31 @@ +import { FunctionComponent } from "react"; + +import { Button } from "~/components/ui/button"; +import { useTranslation } from "~/hooks"; +import { useWalletSelect } from "~/hooks/use-wallet-select"; +import { useStore } from "~/stores"; + +export const GetStartedWithOsmosis: FunctionComponent = () => { + const { accountStore } = useStore(); + const { t } = useTranslation(); + + const { onOpenWalletSelect } = useWalletSelect(); + + return ( +
+

{t("portfolio.connectWallet")}

+ +
+ ); +}; diff --git a/packages/web/components/complex/portfolio/historical-chart.tsx b/packages/web/components/complex/portfolio/historical-chart.tsx new file mode 100644 index 0000000000..28bed6f001 --- /dev/null +++ b/packages/web/components/complex/portfolio/historical-chart.tsx @@ -0,0 +1,93 @@ +import { Dec } from "@keplr-wallet/unit"; +import { Range } from "@osmosis-labs/server/src/queries/complex/portfolio/portfolio"; +import { AreaData, Time } from "lightweight-charts"; + +// import { Icon } from "~/components/assets"; +import { + HistoricalChart, + HistoricalChartSkeleton, +} from "~/components/chart/historical-chart"; +import { PortfolioHistoricalRangeButtonGroup } from "~/components/complex/portfolio/historical-range-button-group"; +import { DataPoint } from "~/components/complex/portfolio/types"; +// import { IconButton } from "~/components/ui/button"; +import { useTranslation } from "~/hooks"; + +const getChartStyle = ( + difference: number +): "bullish" | "bearish" | "neutral" => { + const percentageDec = new Dec(difference); + if (percentageDec.isPositive()) { + return "bullish"; + } else if (percentageDec.isNegative()) { + return "bearish"; + } else { + return "neutral"; + } +}; + +export const PortfolioHistoricalChart = ({ + data, + isFetched, + setDataPoint, + resetDataPoint, + range, + setRange, + totalPriceChange, + error, + setShowDate, +}: { + data?: AreaData