From aab29460c8ddad1192cf234f04bc4b466e2c8fcf Mon Sep 17 00:00:00 2001 From: Connor Barr Date: Thu, 8 Aug 2024 00:44:25 +0100 Subject: [PATCH] Limit Orders and Swap Flow Redesign (#3683) * feat: create generic disclaimer tooltip * fix: enable propagation on generic disclaimer * feat: re-arrange and update market trade details * feat: add free state to trade details * feat: re-arrange limit order fees * feat: add gas icon * feat: move and update review-swap into review-order * feat: re-implement review-order modal * feat: added gas fee esimate to place limit state * feat: reduced fee estimate calls * feat: add button disabling on review modal * fix: removed repeating asset price query * fix: adjusted placed quantity to be exact input * fix: remove unused var * refactor: remove old limit order review modal and extract recap row * feat: add high slippage warning to review order * feat: inject maxSlippage into usePlaceLimit hook * chore: remove useless style from GenericDisclaimer * feat: add gas icon to limit trade details network fees recap row * feat: add tooltips to trade details row * build: fixed translation json error * redesign swap widget (#3591) * fix: fixed isTyping field for inAmountInput * Improve rate info (#3588) * rate * fix build lint * improve buy/sell view * Update packages/web/components/swap-tool/trade-details.tsx Co-authored-by: Connor Barr * undo inlang change --------- Co-authored-by: Connor Barr * fix: fix missing attributes * feat: redesign widget base layout & top selectors * chore: remove unused old commented code * chore: remove tab uqs * feat: update token selection & input/output designs * feat: redesign quote selection * feat: WIP redesign limit price selector and update page widget size * fix: invert triangles direction and comment out unused code * fix: fix button click disabled when tooltip open * feat: re-implement open orders card * feat: WIP init implementing focus switching * feat: add switching button UI * feat: change default percent adjustment and label render logic * feat: re-add trade details * chore: remove unused code * refactor: extract AssetFieldset * chore: comment out unused code * feat: wire-up trade details limit variant * feat: WIP implement new swap tool designs * refactor: moved order mapping to caching function * build: fixed build errors * feat: added refetch to cancel/claim [WARNING DO NOT TEST] * fix: fixed refetching on order table * feat: finish implementing swap tool redesign * feat: add popover on limit order disabled & tweak GenericDisclaimer customization * feat: refetch orders on first load * feat: add "add funds" button to asset fieldset header balance * feat: add no balance logics to place limit tool & swap tool * fix: inputs adjust value of other input for limit orders * feat: change all site bg & fix earn page radio buttons opacity * fix: removed unnecessary button disabled state * fix: removed unnecessary button disabled state * feat: add trade bg * chore: removed unused code * fix: fixed price input error on 0 input * fix: fixed rounding issue with max button * fix: fix faulting overflow-hidden on alt swap tool * fix: fix bar width * fix: fix alt swap tool labels * fix: fix scroll on home fixed container * fix: fix "add funds" display on wallet disconnected * fix: fix alt headers and remove old code * fix: fix spacing on tool * fix: fix trade details bugged animation * feat: add prefixes/suffixes empty states * fix: disable ticker on sell tab balance * fix: fix sell balance precision & fieldset footer height * fix: fix buy available usdc amount formatting * feat: update selectableQuotes to cover wallet not connected case * fix: hide add funds button when wallet is disconnected * fix: hide add funds cta on price selector and fix lint * fix: filter in UI default quotes on defaultQuotesWithBalances * feat: add empty states to swap widget footers * feat: make "stablecoin" tooltip trigger underlined * fix: update decimal places and add "<$0.01" to trade details fees * build: fixed build errors * fix: update display of gasUsdValueToPay * feat: update rendering of gasAmount and wire it up on swap tool * refactor: updated button disabled & loading states * fix: update gas display on review modal * chore: readded insufficient balance display to hidden error messages * fix: update chart icon size and variant display logic * feat: remove triangle indicator when percentAdjusted is zero * fix: update token-details-shadow color * chore: sentence case adjustments * fix: fixed review modal changing price and incorrect values * feat: WIP add scaling to asset input * fix: fix rounding issues on order type selector and wrapping on parent container * fix: move isValidNumericalRawInput validation to useLimitPrice * refactor: move orderType prop computation to ReviewOrder and remove unused prop * fix: fixed low price value display in limit price selector * feat: WIP add diffGteSlippage handling * fix: update isManualSlippageTooHigh treshold * feat: truncate last slippage number if it's gt 50 * feat: add isManualSlippageTooLow disclaimer and update designs * fix: fix max button spinner distortion * fix: fix sticky scroll bg color to thead * feat: add tooltip on order history page * feat: update order history columns * fix: fixed overflow on trade details and insufficient funds not disabling review button * fix: added label for limit price input and fixed hover state for denom selection * feat: button hover states for price selection * fix: removed place limit msg generation for market orders * build: fixed build error * fix: adjusted text overflow for expected output * feat: :sparkles: add limit orders localizations * feat: added no gas fee estimation state to trade details * feat: added gas estimate changes to review order * chore: machine translate * chore: added more localisations * feat: added refetchOnMount to all/claimable orders queries * fix: filtered unwanted quote assets * feat: added amount formatting for quote denom selection * refactor: refactored balance queries in placelimit hook * fix: :lipstick: fix order history table overflow (#3649) * fix: fixed modal not being attached to top of screen for denom selection * fix: :lipstick: fix trade widget overlap with sidebar * feat: clear denom selection on modal close * fix: undid regression * feat: :sparkles: add missing translation and fix analytics event page * fix: :bug: fix build error * fix: removed limit price display in review modal on swap tab * fix: fixed available amount formatting for buy/sell tab * feat: moved error display * fix: disabled button on no funds * fix: limited decimal input to 3 decimal places * fix: removed gas fee estimation from trade details * feat: added state for no routes in trade details * feat: focus input on swap for buy/sell * feat: automatically display base denom in trade details * fix: non-focused percentage 3 decimal places * refactor: altered trade details outAsBase state and added more localisations * [WIP] feat: price selection has default value * fix: hid routes chevron when no routes found * fix: fixed swap tool layout on pools page modal * fix: background colors for pool page * fix: background colour changes * fix: review order terminology changes * fix: trade-tokens terminology and padding changes * fix: adjusted ttl for all orders cache * refactor: changed cache times for orders * refactor: altered cache times and added logs for active orders query * feat: small improvement to all orders query * chore: cleaned up logs * fix: active orders optimisation * fix: fixed bug with input form not resetting * fix: refetch orders when order placed * fix: caching trade tool active orders query to orders history page * fix: readded claim all orders function * refactor: refactored price input to be simpler * fix: updating price while in percent mode for price selection * feat: extreme value handling * feat: extreme value handling * fix: small bug fixes * fix: price adjustment as spot price changes in price selection, removed unused log * feat: spinner/disabled look for claim & close button * fix: fixed above/below display for price selector * fix: show order history link when user has any order history * fix: fixed font scaling for inputs * fix: reworked order history page * chore: removed old order history page * build: fixed build error * fix: small changes to order history * build: fix build errors * feat: moved orderbook denom query to sqs pools * fix: more active orders query changes * feat: added no wallet state for order history * fix: wallet loading state order history page * feat: limits can now be placed on opposite side of market price * chore: localisations * fix: fixed error on high price amount inputs * fix: small percentage adjustments for price input * chore: removed logs, small formatting adjustment * fix: toggle background colours for limit order ff * fix: percent fill rounding * feat: added geolocation based feature flags for limit orders * fix: improved feel of cancel/claim buttons * fix: correct swap tool depending on feature flags * fix: missed backgrounds for feature flags * build: fix build errors * fix: remove swr and invalidate orderbook queries on tx * refactor: moved historical orders query, fixed schema issue * chore: removed unused code * chore: reduced use of useMemo where possible * chore: remove unused code * test: moved tickToPrice to jest in case * chore: added debug logs for claim/cancel buttons when no wallet found * feat: added intersection component to orders history * feat: valid/default limit quote denoms uses MainnetAssetSymbols * fix: feature flag bg colour in main * feat: added placeholder prop to ScaledCurrencyInput * build: fixed build errors * feat: undid order history first column changes from yesterday * test: added unit tests for new formatter functions * refactor: moved countDecimals test to correct file * refactor: :recycle: add useKeyboardNavigation instead of custom implementation * chore: added comments to disabled hooks in various places * fix: :bug: fix build error * lint * clean up unused localizations * remove tests for old swap tool (deprecated) --------- Co-authored-by: fabryscript Co-authored-by: Fabrizio Emanuele Piperno Co-authored-by: Sunny Aggarwal Co-authored-by: Davide Segullo Co-authored-by: Jon Ator --- packages/math/package.json | 3 + .../pool/concentrated/__tests__/tick.spec.ts | 39 +- packages/math/src/pool/concentrated/tick.ts | 47 + packages/server/src/queries/complex/index.ts | 1 + .../complex/orderbooks/active-orders.ts | 158 ++++ .../src/queries/complex/orderbooks/denoms.ts | 45 + .../complex/orderbooks/historical-orders.ts | 162 ++++ .../src/queries/complex/orderbooks/index.ts | 8 + .../queries/complex/orderbooks/maker-fee.ts | 27 + .../complex/orderbooks/orderbook-state.ts | 29 + .../src/queries/complex/orderbooks/pools.ts | 35 + .../queries/complex/orderbooks/tick-state.ts | 57 ++ .../src/queries/complex/orderbooks/types.ts | 29 + .../data-services/historical-limit-orders.ts | 28 + .../server/src/queries/data-services/index.ts | 1 + packages/server/src/queries/osmosis/index.ts | 1 + .../server/src/queries/osmosis/orderbooks.ts | 194 ++++ .../server/src/queries/sidecar/orderbooks.ts | 15 + packages/server/src/queries/sidecar/router.ts | 14 +- packages/stores/src/account/cosmwasm/index.ts | 40 + .../stores/src/ui-config/slippage-config.ts | 14 +- packages/trpc/src/index.ts | 1 + packages/trpc/src/orderbook-router.ts | 175 ++++ packages/trpc/src/parameter-types.ts | 6 +- packages/web/__tests__/index-page.spec.tsx | 176 ---- .../web/components/complex/asset-fieldset.tsx | 291 ++++++ .../web/components/complex/assets-page-v1.tsx | 15 +- .../web/components/complex/assets-page-v2.tsx | 2 +- .../complex/orders-history/cells/actions.tsx | 157 ++++ .../orders-history/cells/filled-progress.tsx | 52 ++ .../complex/orders-history/index.tsx | 531 +++++++++++ .../web/components/complex/pools-table.tsx | 13 +- .../components/control/token-select-limit.tsx | 186 ++++ .../supercharge-pool.tsx | 2 +- .../input/scaled-currency-input.tsx | 5 +- packages/web/components/layouts/main.tsx | 15 +- packages/web/components/navbar-osmo-price.tsx | 1 - packages/web/components/navbar/index.tsx | 13 +- .../web/components/place-limit-tool/index.tsx | 631 +++++++++++++ .../place-limit-tool/limit-price-selector.tsx | 330 +++++++ .../web/components/pool-detail/base-pool.tsx | 7 +- .../components/pool-detail/concentrated.tsx | 49 +- packages/web/components/pool-detail/share.tsx | 11 +- .../radio-with-options/radio-with-options.tsx | 6 +- .../swap-tool/__tests__/swap-tool.spec.tsx | 238 ----- packages/web/components/swap-tool/alt.tsx | 661 ++++++++++++++ packages/web/components/swap-tool/index.tsx | 6 +- .../swap-tool/order-type-selector.tsx | 125 +++ .../components/swap-tool/price-selector.tsx | 490 ++++++++++ .../web/components/swap-tool/split-route.tsx | 20 +- .../components/swap-tool/swap-tool-tabs.tsx | 74 ++ .../components/swap-tool/trade-details.tsx | 501 +++++++++++ .../web/components/table/assets-table-v1.tsx | 5 +- .../components/tooltip/generic-disclaimer.tsx | 40 + packages/web/components/trade-tool/index.tsx | 138 +++ .../transactions/transaction-buttons.tsx | 168 +--- .../transactions/transaction-content.tsx | 142 ++- packages/web/components/ui/progress-bar.tsx | 47 + packages/web/components/ui/recap-row.tsx | 24 + packages/web/config/analytics-events.ts | 14 + packages/web/hooks/input/use-amount-input.ts | 20 +- packages/web/hooks/limit-orders/index.ts | 1 + .../web/hooks/limit-orders/use-orderbook.ts | 396 ++++++++ .../web/hooks/limit-orders/use-place-limit.ts | 844 ++++++++++++++++++ packages/web/hooks/use-feature-flags.ts | 31 +- packages/web/hooks/use-swap.tsx | 40 +- ...izations.test.ts => localizations.spec.ts} | 15 +- packages/web/localizations/de.json | 114 ++- packages/web/localizations/en.json | 114 ++- packages/web/localizations/es.json | 114 ++- packages/web/localizations/fa.json | 114 ++- packages/web/localizations/fr.json | 114 ++- packages/web/localizations/gu.json | 114 ++- packages/web/localizations/hi.json | 114 ++- packages/web/localizations/ja.json | 114 ++- packages/web/localizations/ko.json | 114 ++- packages/web/localizations/pl.json | 114 ++- packages/web/localizations/pt-br.json | 114 ++- packages/web/localizations/ro.json | 114 ++- packages/web/localizations/ru.json | 114 ++- .../web/localizations/scripts/remove-key.js | 14 +- packages/web/localizations/tr.json | 114 ++- packages/web/localizations/zh-cn.json | 114 ++- packages/web/localizations/zh-hk.json | 114 ++- packages/web/localizations/zh-tw.json | 114 ++- packages/web/modals/add-funds.tsx | 373 ++++++++ packages/web/modals/base.tsx | 5 +- packages/web/modals/review-order.tsx | 672 ++++++++++++++ .../web/modals/token-select-modal-limit.tsx | 427 +++++++++ packages/web/modals/trade-tokens.tsx | 63 +- packages/web/pages/_app.tsx | 23 +- packages/web/pages/apps.tsx | 2 +- packages/web/pages/assets/[denom].tsx | 36 +- packages/web/pages/index.tsx | 44 +- packages/web/pages/pools.tsx | 4 +- packages/web/pages/stake.tsx | 14 +- packages/web/pages/transactions.tsx | 16 +- packages/web/public/icons/sprite.svg | 54 +- .../web/public/images/osmosis-home-bg-alt.svg | 50 ++ .../images/quote-swap-from-another-asset.png | Bin 0 -> 11679 bytes packages/web/server/api/edge-router.ts | 2 + packages/web/stores/index.tsx | 2 + packages/web/tailwind.config.js | 6 +- .../web/utils/__tests__/formatter.spec.ts | 48 +- packages/web/utils/__tests__/number.spec.ts | 30 + packages/web/utils/formatter.ts | 21 + packages/web/utils/number.ts | 8 + 107 files changed, 10723 insertions(+), 791 deletions(-) create mode 100644 packages/server/src/queries/complex/orderbooks/active-orders.ts create mode 100644 packages/server/src/queries/complex/orderbooks/denoms.ts create mode 100644 packages/server/src/queries/complex/orderbooks/historical-orders.ts create mode 100644 packages/server/src/queries/complex/orderbooks/index.ts create mode 100644 packages/server/src/queries/complex/orderbooks/maker-fee.ts create mode 100644 packages/server/src/queries/complex/orderbooks/orderbook-state.ts create mode 100644 packages/server/src/queries/complex/orderbooks/pools.ts create mode 100644 packages/server/src/queries/complex/orderbooks/tick-state.ts create mode 100644 packages/server/src/queries/complex/orderbooks/types.ts create mode 100644 packages/server/src/queries/data-services/historical-limit-orders.ts create mode 100644 packages/server/src/queries/osmosis/orderbooks.ts create mode 100644 packages/server/src/queries/sidecar/orderbooks.ts create mode 100644 packages/trpc/src/orderbook-router.ts delete mode 100644 packages/web/__tests__/index-page.spec.tsx create mode 100644 packages/web/components/complex/asset-fieldset.tsx create mode 100644 packages/web/components/complex/orders-history/cells/actions.tsx create mode 100644 packages/web/components/complex/orders-history/cells/filled-progress.tsx create mode 100644 packages/web/components/complex/orders-history/index.tsx create mode 100644 packages/web/components/control/token-select-limit.tsx create mode 100644 packages/web/components/place-limit-tool/index.tsx create mode 100644 packages/web/components/place-limit-tool/limit-price-selector.tsx delete mode 100644 packages/web/components/swap-tool/__tests__/swap-tool.spec.tsx create mode 100644 packages/web/components/swap-tool/alt.tsx create mode 100644 packages/web/components/swap-tool/order-type-selector.tsx create mode 100644 packages/web/components/swap-tool/price-selector.tsx create mode 100644 packages/web/components/swap-tool/swap-tool-tabs.tsx create mode 100644 packages/web/components/swap-tool/trade-details.tsx create mode 100644 packages/web/components/tooltip/generic-disclaimer.tsx create mode 100644 packages/web/components/trade-tool/index.tsx create mode 100644 packages/web/components/ui/progress-bar.tsx create mode 100644 packages/web/components/ui/recap-row.tsx create mode 100644 packages/web/hooks/limit-orders/index.ts create mode 100644 packages/web/hooks/limit-orders/use-orderbook.ts create mode 100644 packages/web/hooks/limit-orders/use-place-limit.ts rename packages/web/localizations/__tests__/{localizations.test.ts => localizations.spec.ts} (93%) create mode 100644 packages/web/modals/add-funds.tsx create mode 100644 packages/web/modals/review-order.tsx create mode 100644 packages/web/modals/token-select-modal-limit.tsx create mode 100644 packages/web/public/images/osmosis-home-bg-alt.svg create mode 100644 packages/web/public/images/quote-swap-from-another-asset.png diff --git a/packages/math/package.json b/packages/math/package.json index ac2b5da0af..ea2749274b 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -40,5 +40,8 @@ "@keplr-wallet/types": "0.10.24-ibc.go.v7.hot.fix", "@keplr-wallet/unit": "0.10.24-ibc.go.v7.hot.fix", "big-integer": "^1.6.48" + }, + "devDependencies": { + "jest-in-case": "^1.0.2" } } diff --git a/packages/math/src/pool/concentrated/__tests__/tick.spec.ts b/packages/math/src/pool/concentrated/__tests__/tick.spec.ts index ad2537902f..9ce7caae5c 100644 --- a/packages/math/src/pool/concentrated/__tests__/tick.spec.ts +++ b/packages/math/src/pool/concentrated/__tests__/tick.spec.ts @@ -1,8 +1,10 @@ import { Dec, Int } from "@keplr-wallet/unit"; +// eslint-disable-next-line import/no-extraneous-dependencies +import cases from "jest-in-case"; import { approxSqrt } from "../../../utils"; import { maxSpotPrice, maxTick, minSpotPrice } from "../const"; -import { priceToTick, tickToSqrtPrice } from "../tick"; +import { priceToTick, tickToPrice, tickToSqrtPrice } from "../tick"; // https://github.com/osmosis-labs/osmosis/blob/0f9eb3c1259078035445b3e3269659469b95fd9f/x/concentrated-liquidity/math/tick_test.go#L30 describe("tickToSqrtPrice", () => { @@ -217,6 +219,41 @@ describe("priceToTick", () => { }); }); +cases( + "tickToPrice", + ({ tick, priceExpected }) => { + const price = tickToPrice(tick); + expect(price.toString()).toEqual(priceExpected.toString()); + }, + [ + { + name: "Tick Zero", + tick: new Int("0"), + priceExpected: new Dec("1"), + }, + { + name: "Large Positive Tick", + tick: new Int("1000000"), + priceExpected: new Dec("2"), + }, + { + name: "Large Negative Tick", + tick: new Int("-5000000"), + priceExpected: new Dec("0.5"), + }, + { + name: "Max Tick", + tick: new Int("182402823"), + priceExpected: new Dec("340282300000000000000"), + }, + { + name: "Min Tick", + tick: new Int("-108000000"), + priceExpected: new Dec("0.000000000001"), + }, + ] +); + // TEMORARY: commenting out until we confirm adding a buffer around current tick avoids invalid queries // describe("estimateInitialTickBound", () => { // // src: https://github.com/osmosis-labs/osmosis/blob/0b199ee187fbff02f68c2dc503d60efe617a67b2/x/concentrated-liquidity/tick_test.go#L1865 diff --git a/packages/math/src/pool/concentrated/tick.ts b/packages/math/src/pool/concentrated/tick.ts index f9f3d0e0b9..bd10c3e859 100644 --- a/packages/math/src/pool/concentrated/tick.ts +++ b/packages/math/src/pool/concentrated/tick.ts @@ -66,6 +66,53 @@ export function tickToSqrtPrice(tickIndex: Int): Dec { return approxSqrt(price); } +export function tickToPrice(tickIndex: Int): Dec { + if (tickIndex.isZero()) { + return new Dec(1); + } + + const geometricExponentIncrementDistanceInTicks = nine.mul( + powTenBigDec(new Int(exponentAtPriceOne).neg()).toDec() + ); + + if (tickIndex.lt(minTick) || tickIndex.gt(maxTick)) { + throw new Error( + `tickIndex is out of range: ${tickIndex.toString()}, min: ${minTick.toString()}, max: ${maxTick.toString()}` + ); + } + + const geometricExponentDelta = new Dec(tickIndex) + .quoTruncate(new Dec(geometricExponentIncrementDistanceInTicks.truncate())) + .truncate(); + + let exponentAtCurTick = new Int(exponentAtPriceOne).add( + geometricExponentDelta + ); + if (tickIndex.lt(new Int(0))) { + exponentAtCurTick = exponentAtCurTick.sub(new Int(1)); + } + + const currentAdditiveIncrementInTicks = powTenBigDec(exponentAtCurTick); + + const numAdditiveTicks = tickIndex.sub( + geometricExponentDelta.mul( + geometricExponentIncrementDistanceInTicks.truncate() + ) + ); + + const price = powTenBigDec(geometricExponentDelta) + .add(new BigDec(numAdditiveTicks).mul(currentAdditiveIncrementInTicks)) + .toDec(); + + if (price.gt(maxSpotPrice) || price.lt(minSpotPrice)) { + throw new Error( + `price is out of range: ${price.toString()}, min: ${minSpotPrice.toString()}, max: ${maxSpotPrice.toString()}` + ); + } + + return price; +} + /** PriceToTick takes a price and returns the corresponding tick index * This function does not take into consideration tick spacing. */ diff --git a/packages/server/src/queries/complex/index.ts b/packages/server/src/queries/complex/index.ts index 72dccfe885..426bd6d761 100644 --- a/packages/server/src/queries/complex/index.ts +++ b/packages/server/src/queries/complex/index.ts @@ -4,6 +4,7 @@ export * from "./chains"; export * from "./concentrated-liquidity"; export * from "./earn"; export * from "./get-timeout-height"; +export * from "./orderbooks"; export * from "./osmosis"; export * from "./pools"; export * from "./portfolio"; diff --git a/packages/server/src/queries/complex/orderbooks/active-orders.ts b/packages/server/src/queries/complex/orderbooks/active-orders.ts new file mode 100644 index 0000000000..4fb989d525 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/active-orders.ts @@ -0,0 +1,158 @@ +import { Dec, Int } from "@keplr-wallet/unit"; +import { tickToPrice } from "@osmosis-labs/math"; +import { Chain } from "@osmosis-labs/types"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import cachified, { CacheEntry } from "cachified"; +import dayjs from "dayjs"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { LimitOrder, queryOrderbookActiveOrders } from "../../osmosis"; +import { + getOrderbookTickState, + getOrderbookTickUnrealizedCancels, +} from "./tick-state"; +import type { MappedLimitOrder, OrderStatus } from "./types"; + +const activeOrdersCache = new LRUCache(DEFAULT_LRU_OPTIONS); + +export function getOrderbookActiveOrders({ + orderbookAddress, + userOsmoAddress, + chainList, + baseAsset, + quoteAsset, +}: { + orderbookAddress: string; + userOsmoAddress: string; + chainList: Chain[]; + baseAsset: ReturnType; + quoteAsset: ReturnType; +}) { + return cachified({ + cache: activeOrdersCache, + key: `orderbookActiveOrders-${orderbookAddress}-${userOsmoAddress}`, + ttl: 1500, // 1.5 seconds + getFreshValue: () => + queryOrderbookActiveOrders({ + orderbookAddress, + userAddress: userOsmoAddress, + chainList, + }).then( + async ({ data }: { data: { count: number; orders: LimitOrder[] } }) => { + const resp = await getTickInfoAndTransformOrders( + orderbookAddress, + data.orders, + chainList, + quoteAsset, + baseAsset + ); + return resp; + } + ), + }); +} + +function mapOrderStatus(order: LimitOrder, percentFilled: Dec): OrderStatus { + const quantInt = parseInt(order.quantity); + if (quantInt === 0 || percentFilled.equals(new Dec(1))) return "filled"; + if (percentFilled.isZero()) return "open"; + if (percentFilled.lt(new Dec(1))) return "partiallyFilled"; + + return "open"; +} + +async function getTickInfoAndTransformOrders( + orderbookAddress: string, + orders: LimitOrder[], + chainList: Chain[], + quoteAsset: ReturnType, + baseAsset: ReturnType +): Promise { + if (orders.length === 0) return []; + + const tickIds = [...new Set(orders.map((o) => o.tick_id))]; + + const [tickStates, unrealizedTickCancels] = await Promise.all([ + getOrderbookTickState({ + orderbookAddress, + chainList, + tickIds, + }), + getOrderbookTickUnrealizedCancels({ + orderbookAddress, + chainList, + tickIds, + }), + ]); + + const fullTickState = tickStates.map(({ tick_id, tick_state }) => ({ + tickId: tick_id, + tickState: tick_state, + unrealizedCancels: unrealizedTickCancels.find((c) => c.tick_id === tick_id), + })); + + return orders.map((o) => { + const { tickState, unrealizedCancels } = fullTickState.find( + ({ tickId }) => tickId === o.tick_id + ) ?? { tickState: undefined, unrealizedCancels: undefined }; + + const quantity = parseInt(o.quantity); + const placedQuantity = parseInt(o.placed_quantity); + + const percentClaimed = new Dec( + (placedQuantity - quantity) / placedQuantity + ); + + const normalizationFactor = new Dec(10).pow( + new Int((quoteAsset?.decimals ?? 0) - (baseAsset?.decimals ?? 0)) + ); + const [tickEtas, tickUnrealizedCancelled] = + o.order_direction === "bid" + ? [ + parseInt( + tickState?.bid_values.effective_total_amount_swapped ?? "0" + ), + parseInt( + unrealizedCancels?.unrealized_cancels.bid_unrealized_cancels ?? + "0" + ), + ] + : [ + parseInt( + tickState?.ask_values.effective_total_amount_swapped ?? "0" + ), + parseInt( + unrealizedCancels?.unrealized_cancels.ask_unrealized_cancels ?? + "0" + ), + ]; + const tickTotalEtas = tickEtas + tickUnrealizedCancelled; + const totalFilled = Math.max( + tickTotalEtas - (parseInt(o.etas) - (placedQuantity - quantity)), + 0 + ); + const percentFilled = new Dec(Math.min(totalFilled / placedQuantity, 1)); + const price = tickToPrice(new Int(o.tick_id)); + const status = mapOrderStatus(o, percentFilled); + const output = + o.order_direction === "bid" + ? new Dec(placedQuantity).quo(price) + : new Dec(placedQuantity).mul(price); + return { + ...o, + price: price.quo(normalizationFactor), + quantity, + placed_quantity: placedQuantity, + percentClaimed, + totalFilled, + percentFilled, + orderbookAddress, + status, + output, + quoteAsset, + baseAsset, + placed_at: dayjs(parseInt(o.placed_at) / 1_000).unix(), + }; + }); +} diff --git a/packages/server/src/queries/complex/orderbooks/denoms.ts b/packages/server/src/queries/complex/orderbooks/denoms.ts new file mode 100644 index 0000000000..23b2b4e412 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/denoms.ts @@ -0,0 +1,45 @@ +import { AssetList, Chain } from "@osmosis-labs/types"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { getOrderbookPools } from "./pools"; + +const orderbookDenomsCache = new LRUCache( + DEFAULT_LRU_OPTIONS +); + +export function getOrderbookDenoms({ + orderbookAddress, + assetLists, +}: { + orderbookAddress: string; + chainList: Chain[]; + assetLists: AssetList[]; +}) { + return cachified({ + cache: orderbookDenomsCache, + key: `orderbookDenoms-${orderbookAddress}`, + ttl: 1000 * 60 * 60 * 24 * 30, // 30 days (unlikely to change) + getFreshValue: () => + getOrderbookPools().then((pools) => { + const pool = pools.find((p) => p.contractAddress === orderbookAddress); + if (!pool) + return { + quoteAsset: undefined, + baseAsset: undefined, + }; + const quoteAsset = getAssetFromAssetList({ + coinMinimalDenom: pool.quoteDenom, + assetLists, + }); + const baseAsset = getAssetFromAssetList({ + coinMinimalDenom: pool.baseDenom, + assetLists, + }); + + return { quoteAsset, baseAsset }; + }), + }); +} diff --git a/packages/server/src/queries/complex/orderbooks/historical-orders.ts b/packages/server/src/queries/complex/orderbooks/historical-orders.ts new file mode 100644 index 0000000000..a23071fad4 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/historical-orders.ts @@ -0,0 +1,162 @@ +import { Dec, Int } from "@keplr-wallet/unit"; +import { tickToPrice } from "@osmosis-labs/math"; +import { AssetList, Chain } from "@osmosis-labs/types"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import cachified, { CacheEntry } from "cachified"; +import dayjs from "dayjs"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { + HistoricalLimitOrder, + queryHistoricalOrders, +} from "../../data-services"; +import { getOrderbookDenoms } from "./denoms"; +import type { MappedLimitOrder, OrderStatus } from "./types"; + +const orderbookHistoricalOrdersCache = new LRUCache( + DEFAULT_LRU_OPTIONS +); + +export function getOrderbookHistoricalOrders({ + userOsmoAddress, + assetLists, + chainList, +}: { + userOsmoAddress: string; + assetLists: AssetList[]; + chainList: Chain[]; +}) { + return cachified({ + cache: orderbookHistoricalOrdersCache, + key: `orderbookHistoricalOrders-${userOsmoAddress}`, + ttl: 1000 * 2, // 2 seconds + getFreshValue: () => + queryHistoricalOrders(userOsmoAddress).then(async (data) => { + const orders = data; + orders.forEach((o) => { + if (o.status === "cancelled" && o.claimed_quantity !== "0") { + const newOrder: HistoricalLimitOrder = { + ...o, + quantity: o.claimed_quantity, + status: "fullyClaimed", + }; + orders.push(newOrder); + } + }); + + return await mapHistoricalToMapped( + orders, + userOsmoAddress, + assetLists, + chainList + ); + }), + }); +} + +/** + * Gets an object containing a mapping between an orderbook address and it's quote and base asset. + * Each orderbook address is fetched once and only those present in the provided orders are queried. + */ +async function getRelevantOrderbookDenoms( + historicalOrders: HistoricalLimitOrder[], + assetLists: AssetList[], + chainList: Chain[] +): Promise< + Record< + string, + { + quoteAsset: ReturnType; + baseAsset: ReturnType; + } + > +> { + const orderbookAddresses = [ + ...new Set(historicalOrders.map(({ contract }) => contract)), + ]; + + const promises = orderbookAddresses.map(async (orderbookAddress) => { + const denoms = await getOrderbookDenoms({ + orderbookAddress, + assetLists, + chainList, + }); + return [orderbookAddress, denoms]; + }); + + const orderbookDenoms: Record< + string, + { + quoteAsset: ReturnType; + baseAsset: ReturnType; + } + > = {}; + const orderbookDenomsArray = await Promise.all(promises); + + for (let i = 0; i < orderbookDenomsArray.length; i++) { + const [contract, denoms]: any = orderbookDenomsArray[i]; + orderbookDenoms[contract] = denoms; + } + + return orderbookDenoms; +} + +/** + * Data returned from the Numia query does not exactly match the interface used by the webapp. + * This function maps the Numia data to the webapp interface. + */ +async function mapHistoricalToMapped( + historicalOrders: HistoricalLimitOrder[], + userAddress: string, + assetLists: AssetList[], + chainList: Chain[] +): Promise { + const orderbookDenoms = await getRelevantOrderbookDenoms( + historicalOrders, + assetLists, + chainList + ); + return historicalOrders.map((o) => { + const { quoteAsset, baseAsset } = orderbookDenoms[o.contract]; + const quantityMin = parseInt(o.quantity); + const placedQuantityMin = parseInt(o.quantity); + const price = tickToPrice(new Int(o.tick_id)); + const percentClaimed = new Dec(1); + const output = + o.order_direction === "bid" + ? new Dec(placedQuantityMin).quo(price) + : new Dec(placedQuantityMin).mul(price); + + const normalizationFactor = new Dec(10).pow( + new Int((quoteAsset?.decimals ?? 0) - (baseAsset?.decimals ?? 0)) + ); + + return { + quoteAsset, + baseAsset, + etas: "0", + order_direction: o.order_direction, + order_id: parseInt(o.order_id), + owner: userAddress, + placed_at: + dayjs( + o.place_timestamp && o.place_timestamp.length > 0 + ? o.place_timestamp + : 0 + ).unix() * 1000, + placed_quantity: parseInt(o.quantity), + placedQuantityMin, + quantityMin, + quantity: parseInt(o.quantity), + price: price.quo(normalizationFactor), + status: o.status as OrderStatus, + tick_id: parseInt(o.tick_id), + output, + percentClaimed, + percentFilled: new Dec(1), + totalFilled: parseInt(o.quantity), + orderbookAddress: o.contract, + }; + }); +} diff --git a/packages/server/src/queries/complex/orderbooks/index.ts b/packages/server/src/queries/complex/orderbooks/index.ts new file mode 100644 index 0000000000..60ed3de79c --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/index.ts @@ -0,0 +1,8 @@ +export * from "./active-orders"; +export * from "./denoms"; +export * from "./historical-orders"; +export * from "./maker-fee"; +export * from "./orderbook-state"; +export * from "./pools"; +export * from "./tick-state"; +export * from "./types"; diff --git a/packages/server/src/queries/complex/orderbooks/maker-fee.ts b/packages/server/src/queries/complex/orderbooks/maker-fee.ts new file mode 100644 index 0000000000..bac557dc62 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/maker-fee.ts @@ -0,0 +1,27 @@ +import { Dec } from "@keplr-wallet/unit"; +import { Chain } from "@osmosis-labs/types"; +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { queryOrderbookMakerFee } from "../../osmosis"; + +const makerFeeCache = new LRUCache(DEFAULT_LRU_OPTIONS); + +export function getOrderbookMakerFee({ + orderbookAddress, + chainList, +}: { + orderbookAddress: string; + chainList: Chain[]; +}) { + return cachified({ + cache: makerFeeCache, + key: `orderbookMakerFee-${orderbookAddress}`, + ttl: 1000 * 60 * 60 * 4, // 4 hours + getFreshValue: () => + queryOrderbookMakerFee({ orderbookAddress, chainList }).then( + ({ data }: { data: string }) => new Dec(data) + ), + }); +} diff --git a/packages/server/src/queries/complex/orderbooks/orderbook-state.ts b/packages/server/src/queries/complex/orderbooks/orderbook-state.ts new file mode 100644 index 0000000000..b7245040a2 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/orderbook-state.ts @@ -0,0 +1,29 @@ +import { Chain } from "@osmosis-labs/types"; +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { queryOrderbookState } from "../../osmosis"; + +const orderbookStateCache = new LRUCache( + DEFAULT_LRU_OPTIONS +); + +export function getOrderbookState({ + orderbookAddress, + chainList, +}: { + orderbookAddress: string; + chainList: Chain[]; +}) { + return cachified({ + cache: orderbookStateCache, + key: `orderbookState-${orderbookAddress}`, + ttl: 1000 * 3, // 3 seconds + getFreshValue: () => + queryOrderbookState({ + orderbookAddress, + chainList, + }).then(({ data }) => data), + }); +} diff --git a/packages/server/src/queries/complex/orderbooks/pools.ts b/packages/server/src/queries/complex/orderbooks/pools.ts new file mode 100644 index 0000000000..3119270d51 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/pools.ts @@ -0,0 +1,35 @@ +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { queryCanonicalOrderbooks } from "../../sidecar/orderbooks"; + +const orderbookPoolsCache = new LRUCache( + DEFAULT_LRU_OPTIONS +); + +export interface Orderbook { + baseDenom: string; + quoteDenom: string; + contractAddress: string; + poolId: string; +} + +export function getOrderbookPools() { + return cachified({ + cache: orderbookPoolsCache, + key: `orderbookPools`, + ttl: 1000 * 60 * 60, // 1 hour + getFreshValue: () => + queryCanonicalOrderbooks().then(async (data) => { + return data.map((orderbook) => { + return { + baseDenom: orderbook.base, + quoteDenom: orderbook.quote, + contractAddress: orderbook.contract_address, + poolId: orderbook.pool_id.toString(), + }; + }) as Orderbook[]; + }), + }); +} diff --git a/packages/server/src/queries/complex/orderbooks/tick-state.ts b/packages/server/src/queries/complex/orderbooks/tick-state.ts new file mode 100644 index 0000000000..96116fe415 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/tick-state.ts @@ -0,0 +1,57 @@ +import { Chain } from "@osmosis-labs/types"; +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { + queryOrderbookTicks, + queryOrderbookTickUnrealizedCancelsById, +} from "../../osmosis"; + +const tickInfoCache = new LRUCache(DEFAULT_LRU_OPTIONS); + +export function getOrderbookTickState({ + orderbookAddress, + chainList, + tickIds, +}: { + orderbookAddress: string; + chainList: Chain[]; + tickIds: number[]; +}) { + return cachified({ + cache: tickInfoCache, + key: `orderbookTickInfo-${orderbookAddress}-${tickIds + .sort((a, b) => a - b) + .join(",")}`, + ttl: 1000 * 6, // 6 seconds + getFreshValue: () => + queryOrderbookTicks({ orderbookAddress, chainList, tickIds }).then( + ({ data }) => data.ticks + ), + }); +} + +export function getOrderbookTickUnrealizedCancels({ + orderbookAddress, + chainList, + tickIds, +}: { + orderbookAddress: string; + chainList: Chain[]; + tickIds: number[]; +}) { + return cachified({ + cache: tickInfoCache, + key: `orderbookTickUnrealizedCancels-${orderbookAddress}-${tickIds + .sort((a, b) => a - b) + .join(",")}`, + ttl: 1000 * 6, // 6 seconds + getFreshValue: () => + queryOrderbookTickUnrealizedCancelsById({ + orderbookAddress, + chainList, + tickIds, + }).then(({ data }) => data.ticks), + }); +} diff --git a/packages/server/src/queries/complex/orderbooks/types.ts b/packages/server/src/queries/complex/orderbooks/types.ts new file mode 100644 index 0000000000..de0617dbfb --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/types.ts @@ -0,0 +1,29 @@ +import type { Dec } from "@keplr-wallet/unit"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; + +import type { LimitOrder } from "../../osmosis"; + +export type OrderStatus = + | "open" + | "partiallyFilled" + | "filled" + | "fullyClaimed" + | "cancelled"; + +export type MappedLimitOrder = Omit< + LimitOrder, + "quantity" | "placed_quantity" | "placed_at" +> & { + quantity: number; + placed_quantity: number; + percentClaimed: Dec; + totalFilled: number; + percentFilled: Dec; + orderbookAddress: string; + price: Dec; + status: OrderStatus; + output: Dec; + quoteAsset: ReturnType; + baseAsset: ReturnType; + placed_at: number; +}; diff --git a/packages/server/src/queries/data-services/historical-limit-orders.ts b/packages/server/src/queries/data-services/historical-limit-orders.ts new file mode 100644 index 0000000000..5c75a1b7f5 --- /dev/null +++ b/packages/server/src/queries/data-services/historical-limit-orders.ts @@ -0,0 +1,28 @@ +import { apiClient } from "@osmosis-labs/utils"; + +import { NUMIA_BASE_URL } from "../../env"; + +export interface HistoricalLimitOrder { + place_timestamp: string; + place_tx_hash: string; + order_denom: string; + output_denom: string; + quantity: string; + tick_id: string; + order_id: string; + order_direction: "ask" | "bid"; + price: string; + status: string; + contract: string; + claimed_quantity: string; +} + +export function queryHistoricalOrders( + userOsmoAddress: string +): Promise { + const url = new URL( + `/users/limit_orders/history/closed?address=${userOsmoAddress}`, + NUMIA_BASE_URL + ); + return apiClient(url.toString()); +} diff --git a/packages/server/src/queries/data-services/index.ts b/packages/server/src/queries/data-services/index.ts index 48bdbfd25f..2b70315a99 100644 --- a/packages/server/src/queries/data-services/index.ts +++ b/packages/server/src/queries/data-services/index.ts @@ -1,6 +1,7 @@ export * from "../sidecar/allocation"; export * from "./earn"; export * from "./filtered-pools"; +export * from "./historical-limit-orders"; export * from "./market-cap"; export * from "./pool-aprs"; export * from "./pools-fees"; diff --git a/packages/server/src/queries/osmosis/index.ts b/packages/server/src/queries/osmosis/index.ts index d232414dba..360048be8f 100644 --- a/packages/server/src/queries/osmosis/index.ts +++ b/packages/server/src/queries/osmosis/index.ts @@ -5,6 +5,7 @@ export * from "./icns"; export * from "./incentives"; export * from "./lockup"; export * from "./mint"; +export * from "./orderbooks"; export * from "./poolmanager"; export * from "./superfluid"; export * from "./txfees"; diff --git a/packages/server/src/queries/osmosis/orderbooks.ts b/packages/server/src/queries/osmosis/orderbooks.ts new file mode 100644 index 0000000000..7d4b6752ab --- /dev/null +++ b/packages/server/src/queries/osmosis/orderbooks.ts @@ -0,0 +1,194 @@ +import { createNodeQuery } from "../create-node-query"; + +interface OrderbookMakerFeeResponse { + data: string; +} + +export const queryOrderbookMakerFee = createNodeQuery< + OrderbookMakerFeeResponse, + { + orderbookAddress: string; + } +>({ + path: ({ orderbookAddress }: { orderbookAddress: string }) => { + const msg = JSON.stringify({ + get_maker_fee: {}, + }); + const encodedMsg = Buffer.from(msg).toString("base64"); + + return `/cosmwasm/wasm/v1/contract/${orderbookAddress}/smart/${encodedMsg}`; + }, +}); + +export interface LimitOrder { + tick_id: number; + order_id: number; + order_direction: "ask" | "bid"; + owner: string; + quantity: string; + etas: string; + claim_bounty?: string; + placed_quantity: string; + placed_at: string; +} + +interface OrderbookActiveOrdersResponse { + data: { orders: LimitOrder[]; count: number }; +} + +export const queryOrderbookActiveOrders = createNodeQuery< + OrderbookActiveOrdersResponse, + { + orderbookAddress: string; + userAddress: string; + } +>({ + path: ({ orderbookAddress, userAddress }) => { + const msg = JSON.stringify({ + orders_by_owner: { + owner: userAddress, + }, + }); + const encodedMsg = Buffer.from(msg).toString("base64"); + return `/cosmwasm/wasm/v1/contract/${orderbookAddress}/smart/${encodedMsg}`; + }, +}); +interface TickValues { + total_amount_of_liquidity: string; + cumulative_total_value: string; + effective_total_amount_swapped: string; + cumulative_realized_cancels: string; + last_tick_sync_etas: string; +} + +export interface TickState { + ask_values: TickValues; + bid_values: TickValues; +} + +interface OrderbookTicksResponse { + data: { + ticks: { tick_id: number; tick_state: TickState }[]; + }; +} + +export const queryOrderbookTicks = createNodeQuery< + OrderbookTicksResponse, + { + orderbookAddress: string; + tickIds: number[]; + } +>({ + path: ({ tickIds, orderbookAddress }) => { + const msg = JSON.stringify({ + ticks_by_id: { + tick_ids: tickIds, + }, + }); + const encodedMsg = Buffer.from(msg).toString("base64"); + return `/cosmwasm/wasm/v1/contract/${orderbookAddress}/smart/${encodedMsg}`; + }, +}); + +export interface TickUnrealizedCancelsState { + ask_unrealized_cancels: string; + bid_unrealized_cancels: string; +} +interface OrderbookTickUnrealizedCancelsResponse { + data: { + ticks: { + tick_id: number; + unrealized_cancels: TickUnrealizedCancelsState; + }[]; + }; +} + +export const queryOrderbookTickUnrealizedCancelsById = createNodeQuery< + OrderbookTickUnrealizedCancelsResponse, + { + orderbookAddress: string; + tickIds: number[]; + } +>({ + path: ({ tickIds, orderbookAddress }) => { + const msg = JSON.stringify({ + get_unrealized_cancels: { + tick_ids: tickIds, + }, + }); + const encodedMsg = Buffer.from(msg).toString("base64"); + return `/cosmwasm/wasm/v1/contract/${orderbookAddress}/smart/${encodedMsg}`; + }, +}); + +interface OrderbookSpotPriceResponse { + data: { + spot_price: string; + }; +} + +export const queryOrderbookSpotPrice = createNodeQuery< + OrderbookSpotPriceResponse, + { + orderbookAddress: string; + quoteAssetDenom: string; + baseAssetDenom: string; + } +>({ + path: ({ orderbookAddress, quoteAssetDenom, baseAssetDenom }) => { + const msg = JSON.stringify({ + spot_price: { + quote_asset_denom: quoteAssetDenom, + base_asset_denom: baseAssetDenom, + }, + }); + const encodedMsg = Buffer.from(msg).toString("base64"); + return `/cosmwasm/wasm/v1/contract/${orderbookAddress}/smart/${encodedMsg}`; + }, +}); + +interface OrderbookDenomsResponse { + data: { + quote_denom: string; + base_denom: string; + }; +} + +export const queryOrderbookDenoms = createNodeQuery< + OrderbookDenomsResponse, + { + orderbookAddress: string; + } +>({ + path: ({ orderbookAddress }) => { + const msg = JSON.stringify({ + denoms: {}, + }); + const encodedMsg = Buffer.from(msg).toString("base64"); + return `/cosmwasm/wasm/v1/contract/${orderbookAddress}/smart/${encodedMsg}`; + }, +}); + +interface OrderbookStateResponse { + data: { + quote_denom: string; + base_denom: string; + next_bid_tick: number; + next_ask_tick: number; + }; +} + +export const queryOrderbookState = createNodeQuery< + OrderbookStateResponse, + { + orderbookAddress: string; + } +>({ + path: ({ orderbookAddress }) => { + const msg = JSON.stringify({ + orderbook_state: {}, + }); + const encodedMsg = Buffer.from(msg).toString("base64"); + return `/cosmwasm/wasm/v1/contract/${orderbookAddress}/smart/${encodedMsg}`; + }, +}); diff --git a/packages/server/src/queries/sidecar/orderbooks.ts b/packages/server/src/queries/sidecar/orderbooks.ts new file mode 100644 index 0000000000..e9fa8af8ac --- /dev/null +++ b/packages/server/src/queries/sidecar/orderbooks.ts @@ -0,0 +1,15 @@ +import { apiClient } from "@osmosis-labs/utils"; + +import { SIDECAR_BASE_URL } from "../../env"; + +export type CanonicalOrderbooksResponse = { + base: string; + quote: string; + pool_id: number; + contract_address: string; +}[]; + +export async function queryCanonicalOrderbooks() { + const url = new URL("/pools/canonical-orderbooks", SIDECAR_BASE_URL); + return await apiClient(url.toString()); +} diff --git a/packages/server/src/queries/sidecar/router.ts b/packages/server/src/queries/sidecar/router.ts index 1433346386..a229a8e966 100644 --- a/packages/server/src/queries/sidecar/router.ts +++ b/packages/server/src/queries/sidecar/router.ts @@ -1,5 +1,9 @@ import { Dec, Int } from "@keplr-wallet/unit"; -import { NotEnoughQuotedError, PoolType } from "@osmosis-labs/pools"; +import { + NotEnoughLiquidityError, + NotEnoughQuotedError, + PoolType, +} from "@osmosis-labs/pools"; import { NoRouteError, SplitTokenInQuote, @@ -74,13 +78,17 @@ export class OsmosisSidecarRemoteRouter implements TokenOutGivenInRouter { errorMessage?.includes("no routes were provided") || errorMessage?.includes("no candidate routes found") ) { - throw new NoRouteError(); + throw new NoRouteError(errorMessage); + } + + if (errorMessage?.includes("not enough liquidity")) { + throw new NotEnoughLiquidityError(errorMessage); } if ( errorMessage?.includes("amount out is zero, try increasing amount in") ) { - throw new NotEnoughQuotedError(); + throw new NotEnoughQuotedError(errorMessage); } throw new Error(errorMessage ?? "Unexpected sidecar router error " + e); diff --git a/packages/stores/src/account/cosmwasm/index.ts b/packages/stores/src/account/cosmwasm/index.ts index 4d4f101d94..ec9e70e67c 100644 --- a/packages/stores/src/account/cosmwasm/index.ts +++ b/packages/stores/src/account/cosmwasm/index.ts @@ -114,6 +114,46 @@ export class CosmwasmAccountImpl { onTxEvents ); } + + async sendMultiExecuteContractMsg( + type: keyof typeof this.msgOpts | "unknown" = "executeWasm", + msgs: { + contractAddress: string; + msg: object; + funds: CoinPrimitive[]; + }[], + backupFee?: Optional, + onTxEvents?: + | ((tx: DeliverTxResponse) => void) + | { + onBroadcasted?: (txHash: Uint8Array) => void; + onFulfill?: (tx: DeliverTxResponse) => void; + } + ) { + const mappedMsgs = msgs.map(({ msg, funds, contractAddress }) => { + return this.msgOpts.executeWasm.messageComposer({ + sender: this.address, + contract: contractAddress, + msg: Buffer.from(JSON.stringify(msg)), + funds, + }); + }); + + await this.base.signAndBroadcast( + this.chainId, + type, + mappedMsgs, + "", + backupFee + ? { + amount: backupFee?.amount ?? [], + gas: backupFee.gas, + } + : undefined, + undefined, + onTxEvents + ); + } } export * from "./types"; diff --git a/packages/stores/src/ui-config/slippage-config.ts b/packages/stores/src/ui-config/slippage-config.ts index 371e84cd94..57861d7912 100644 --- a/packages/stores/src/ui-config/slippage-config.ts +++ b/packages/stores/src/ui-config/slippage-config.ts @@ -5,7 +5,7 @@ import { InvalidSlippageError, NegativeSlippageError } from "./errors"; export class ObservableSlippageConfig { static readonly defaultSelectableSlippages: ReadonlyArray = [ - // 0.05% + // 0.5% new Dec("0.005"), // 1% new Dec("0.01"), @@ -13,6 +13,9 @@ export class ObservableSlippageConfig { new Dec("0.03"), ]; + @observable + protected _defaultManualSlippage: string = "0.5"; + @observable.shallow protected _selectableSlippages: ReadonlyArray = ObservableSlippageConfig.defaultSelectableSlippages; @@ -21,10 +24,10 @@ export class ObservableSlippageConfig { protected _selectedIndex: number = 0; @observable - protected _isManualSlippage: boolean = false; + protected _isManualSlippage: boolean = true; @observable - protected _manualSlippage: string = "5.0"; + protected _manualSlippage: string = "0.5"; constructor() { makeObservable(this); @@ -83,6 +86,11 @@ export class ObservableSlippageConfig { return this._manualSlippage; } + @computed + get defaultManualSlippage(): string { + return this._defaultManualSlippage; + } + @computed get manualSlippage(): RatePretty { if (!this._isManualSlippage || this._manualSlippage === "") { diff --git a/packages/trpc/src/index.ts b/packages/trpc/src/index.ts index 8b8e389bbe..9d4cc1a8b0 100644 --- a/packages/trpc/src/index.ts +++ b/packages/trpc/src/index.ts @@ -7,6 +7,7 @@ export * from "./concentrated-liquidity"; export * from "./earn"; export * from "./middleware"; export * from "./one-click-trading"; +export * from "./orderbook-router"; export * from "./parameter-types"; export * from "./params"; export * from "./pools"; diff --git a/packages/trpc/src/orderbook-router.ts b/packages/trpc/src/orderbook-router.ts new file mode 100644 index 0000000000..fc53dcf376 --- /dev/null +++ b/packages/trpc/src/orderbook-router.ts @@ -0,0 +1,175 @@ +import { Dec, Int } from "@keplr-wallet/unit"; +import { tickToPrice } from "@osmosis-labs/math"; +import { + CursorPaginationSchema, + getOrderbookActiveOrders, + getOrderbookDenoms, + getOrderbookHistoricalOrders, + getOrderbookMakerFee, + getOrderbookPools, + getOrderbookState, + MappedLimitOrder, + maybeCachePaginatedItems, + OrderStatus, +} from "@osmosis-labs/server"; +import { z } from "zod"; + +import { createTRPCRouter, publicProcedure } from "./api"; +import { OsmoAddressSchema, UserOsmoAddressSchema } from "./parameter-types"; + +const GetInfiniteLimitOrdersInputSchema = CursorPaginationSchema.merge( + UserOsmoAddressSchema.required() +); + +const orderStatusOrder: Record = { + filled: 0, + open: 1, + partiallyFilled: 1, + fullyClaimed: 2, + cancelled: 2, +}; + +function defaultSortOrders( + orderA: MappedLimitOrder, + orderB: MappedLimitOrder +): number { + if (orderA.status === orderB.status) { + return orderB.placed_at - orderA.placed_at; + } + if (orderStatusOrder[orderA.status] < orderStatusOrder[orderB.status]) + return -1; + if (orderStatusOrder[orderA.status] > orderStatusOrder[orderB.status]) + return 1; + + return orderB.placed_at - orderA.placed_at; +} + +export const orderbookRouter = createTRPCRouter({ + getMakerFee: publicProcedure + .input(OsmoAddressSchema.required()) + .query(async ({ input, ctx }) => { + const { osmoAddress } = input; + const makerFee = await getOrderbookMakerFee({ + orderbookAddress: osmoAddress, + chainList: ctx.chainList, + }); + return { + makerFee, + }; + }), + getAllOrders: publicProcedure + .input( + GetInfiniteLimitOrdersInputSchema.merge( + z.object({ contractAddresses: z.array(z.string().startsWith("osmo")) }) + ) + ) + .query(async ({ input, ctx }) => { + return maybeCachePaginatedItems({ + getFreshItems: async () => { + const { contractAddresses, userOsmoAddress } = input; + if (contractAddresses.length === 0 || userOsmoAddress.length === 0) + return []; + const historicalOrders = await getOrderbookHistoricalOrders({ + userOsmoAddress: input.userOsmoAddress, + assetLists: ctx.assetLists, + chainList: ctx.chainList, + }); + + const promises = contractAddresses.map( + async (contractOsmoAddress: string) => { + const { quoteAsset, baseAsset } = await getOrderbookDenoms({ + orderbookAddress: contractOsmoAddress, + chainList: ctx.chainList, + assetLists: ctx.assetLists, + }); + const orders = await getOrderbookActiveOrders({ + orderbookAddress: contractOsmoAddress, + userOsmoAddress: userOsmoAddress, + chainList: ctx.chainList, + baseAsset, + quoteAsset, + }); + const historicalOrdersForContract = historicalOrders.filter( + (o) => o.orderbookAddress === contractOsmoAddress + ); + return [...orders, ...historicalOrdersForContract]; + } + ); + + const ordersByContracts = await Promise.all(promises); + const allOrders = ordersByContracts.flat(); + return allOrders.sort(defaultSortOrders); + }, + cacheKey: `all-active-orders-${input.contractAddresses.join(",")}-${ + input.userOsmoAddress + }`, + ttl: 2000, + cursor: input.cursor, + limit: input.limit, + }); + }), + getOrderbookState: publicProcedure + .input(OsmoAddressSchema.required()) + .query(async ({ input, ctx }) => { + const { osmoAddress } = input; + const orderbookState = await getOrderbookState({ + orderbookAddress: osmoAddress, + chainList: ctx.chainList, + }); + const askSpotPrice = tickToPrice(new Int(orderbookState.next_ask_tick)); + const bidSpotPrice = tickToPrice(new Int(orderbookState.next_bid_tick)); + return { + ...orderbookState, + askSpotPrice, + bidSpotPrice, + }; + }), + getClaimableOrders: publicProcedure + .input( + z + .object({ contractAddresses: z.array(z.string().startsWith("osmo")) }) + .required() + .and(UserOsmoAddressSchema.required()) + ) + .query(async ({ input, ctx }) => { + const { contractAddresses, userOsmoAddress } = input; + const promises = contractAddresses.map( + async (contractOsmoAddress: string) => { + const { quoteAsset, baseAsset } = await getOrderbookDenoms({ + orderbookAddress: contractOsmoAddress, + chainList: ctx.chainList, + assetLists: ctx.assetLists, + }); + const orders = await getOrderbookActiveOrders({ + orderbookAddress: contractOsmoAddress, + userOsmoAddress: userOsmoAddress, + chainList: ctx.chainList, + baseAsset, + quoteAsset, + }); + + if (orders.length === 0) return []; + + return orders.filter((o) => o.percentFilled.gte(new Dec(1))); + } + ); + const ordersByContracts = await Promise.all(promises); + const allOrders = ordersByContracts.flatMap((p) => p); + return allOrders; + }), + getHistoricalOrders: publicProcedure + .input(UserOsmoAddressSchema.required()) + .query(async ({ input, ctx }) => { + const { userOsmoAddress } = input; + const historicalOrders = await getOrderbookHistoricalOrders({ + userOsmoAddress, + assetLists: ctx.assetLists, + chainList: ctx.chainList, + }); + return historicalOrders; + }), + getPools: publicProcedure.query(async () => { + const pools = await getOrderbookPools(); + return pools; + }), +}); diff --git a/packages/trpc/src/parameter-types.ts b/packages/trpc/src/parameter-types.ts index 9b52702e10..39bad441a8 100644 --- a/packages/trpc/src/parameter-types.ts +++ b/packages/trpc/src/parameter-types.ts @@ -3,7 +3,11 @@ import { z } from "zod"; // Generic and reused types // Avoid adding single use types here -export type UserOsmoAddress = z.infer; +export type UserOsmoAddress = z.infer; +export const OsmoAddressSchema = z.object({ + osmoAddress: z.string().startsWith("osmo").optional(), +}); + export const UserOsmoAddressSchema = z.object({ userOsmoAddress: z.string().startsWith("osmo").optional(), }); diff --git a/packages/web/__tests__/index-page.spec.tsx b/packages/web/__tests__/index-page.spec.tsx deleted file mode 100644 index 7d67760b24..0000000000 --- a/packages/web/__tests__/index-page.spec.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -import { Dec, PricePretty } from "@keplr-wallet/unit"; -import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; -import { getAssetFromAssetList } from "@osmosis-labs/utils"; -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import mockRouter from "next-router-mock"; - -import { server, trpcMsw } from "~/__tests__/msw"; -import { renderWithProviders } from "~/__tests__/test-utils"; -import { AssetLists } from "~/config/generated/asset-lists"; -import HomePage, { PreviousTrade, SwapPreviousTradeKey } from "~/pages"; - -jest.mock("next/router", () => jest.requireActual("next-router-mock")); - -// Mock the ResizeObserver -const ResizeObserverMock = jest.fn(() => ({ - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn(), -})); - -// Stub the global ResizeObserver -global.ResizeObserver = ResizeObserverMock; - -const atomAsset = getAssetFromAssetList({ - assetLists: AssetLists, - sourceDenom: "uatom", -})!; -const osmoAsset = getAssetFromAssetList({ - assetLists: AssetLists, - coinMinimalDenom: "uosmo", -})!; -const usdcAsset = getAssetFromAssetList({ - symbol: "USDC", - assetLists: AssetLists, -})!; -const usdtAsset = getAssetFromAssetList({ - symbol: "USDT", - assetLists: AssetLists, -})!; - -afterEach(() => { - mockRouter.setCurrentUrl("/"); - localStorage.removeItem(SwapPreviousTradeKey); -}); - -beforeEach(() => { - server.use( - trpcMsw.edge.assets.getUserAssets.query((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.data({ items: [], nextCursor: undefined }) - ); - }), - trpcMsw.edge.assets.getAssetPrice.query((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.data(new PricePretty(DEFAULT_VS_CURRENCY, new Dec(1))) - ); - }) - ); -}); - -it("should display initial tokens when there are no previous trades", async () => { - renderWithProviders(); - - await screen.findByRole("heading", { name: "Swap" }); - - expect(mockRouter).toMatchObject({ - pathname: "/", - query: { - from: atomAsset.symbol, - to: osmoAsset.symbol, - }, - }); - - screen.getByRole("heading", { name: atomAsset.symbol }); - screen.getByText(atomAsset.rawAsset.name); - - screen.getByRole("heading", { name: osmoAsset.symbol }); - screen.getByText(osmoAsset.rawAsset.name); -}); - -it("If there's a previous trade and no query params, swap tool should select those tokens", async () => { - localStorage.setItem( - SwapPreviousTradeKey, - JSON.stringify({ - sendTokenDenom: usdtAsset.symbol, - outTokenDenom: usdcAsset.symbol, - } as PreviousTrade) - ); - - renderWithProviders(); - - await screen.findByRole("heading", { name: "Swap" }); - - expect(mockRouter).toMatchObject({ - pathname: "/", - query: { - from: usdtAsset.symbol, - to: usdcAsset.symbol, - }, - }); - - screen.getByRole("heading", { name: usdtAsset.symbol }); - screen.getAllByText(usdtAsset.rawAsset.name); - - screen.getByRole("heading", { name: usdcAsset.symbol }); - screen.getAllByText(usdcAsset.rawAsset.name); -}); - -it("If the previous trade is not available, swap tool should select default tokens", async () => { - localStorage.setItem( - SwapPreviousTradeKey, - JSON.stringify({ - sendTokenDenom: "NOTEXIST", - outTokenDenom: "NOTEXIST2", - } as PreviousTrade) - ); - - renderWithProviders(); - - await waitFor(() => { - expect(mockRouter).toMatchObject({ - pathname: "/", - query: { - from: atomAsset.symbol, - to: osmoAsset.symbol, - }, - }); - }); - - screen.getByRole("heading", { name: "Swap" }); - - screen.getByRole("heading", { name: atomAsset.symbol }); - screen.getByText(atomAsset.rawAsset.name); - - screen.getByRole("heading", { name: osmoAsset.symbol }); - screen.getByText(osmoAsset.rawAsset.name); -}); - -it("If there's no previous trade and no query params, swap tool should select default tokens and can switch between them", async () => { - renderWithProviders(); - - await waitFor(() => { - expect(mockRouter).toMatchObject({ - pathname: "/", - query: { - from: atomAsset.symbol, - to: osmoAsset.symbol, - }, - }); - }); - - screen.getByRole("heading", { name: "Swap" }); - - screen.getByRole("heading", { name: atomAsset.symbol }); - screen.getByText(atomAsset.rawAsset.name); - - screen.getByRole("heading", { name: osmoAsset.symbol }); - screen.getByText(osmoAsset.rawAsset.name); - - // Switch assets - await userEvent.click(screen.getByLabelText("Switch assets")); - - await waitFor(() => { - expect(mockRouter).toMatchObject({ - pathname: "/", - query: { - from: osmoAsset.symbol, - to: atomAsset.symbol, - }, - }); - }); -}); diff --git a/packages/web/components/complex/asset-fieldset.tsx b/packages/web/components/complex/asset-fieldset.tsx new file mode 100644 index 0000000000..a7246ea884 --- /dev/null +++ b/packages/web/components/complex/asset-fieldset.tsx @@ -0,0 +1,291 @@ +import { MinimalAsset } from "@osmosis-labs/types"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { + ChangeEventHandler, + forwardRef, + PropsWithChildren, + ReactNode, +} from "react"; + +import { Icon } from "~/components/assets"; +import { Spinner } from "~/components/loaders"; +import { EventName, EventPage } from "~/config"; +import { + useAmplitudeAnalytics, + useDisclosure, + useTranslation, + useWindowSize, +} from "~/hooks"; +import { TokenSelectModalLimit } from "~/modals/token-select-modal-limit"; +import { useStore } from "~/stores"; + +const AssetFieldset = ({ children }: PropsWithChildren) => ( +
{children}
+); + +const AssetFieldsetHeader = ({ children }: PropsWithChildren) => ( +
+ {children} +
+); + +const AssetFieldsetHeaderLabel = ({ children }: PropsWithChildren) => + children; + +const AssetFieldsetHeaderBalance = observer( + ({ + onMax, + availableBalance, + className, + showAddFundsButton, + openAddFundsModal, + isMaxButtonDisabled, + isLoadingMaxButton, + }: { + onMax?: () => void; + availableBalance?: ReactNode; + className?: string; + showAddFundsButton?: boolean; + openAddFundsModal?: () => void; + isMaxButtonDisabled?: boolean; + isLoadingMaxButton?: boolean; + }) => { + const { t } = useTranslation(); + const { accountStore } = useStore(); + + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + + return ( +
+ {wallet?.isWalletConnected ? ( + showAddFundsButton ? ( + + ) : ( + <> + + {availableBalance} {t("pool.available").toLowerCase()} + + {onMax && ( + + )} + + ) + ) : null} +
+ ); + } +); + +const calcFontSize = (numChars: number, isMobile: boolean): string => { + const sizeMapping: { [key: number]: string } = isMobile + ? { + 9: "48px", + 15: "38px", + 24: "32px", + 100: "16px", + } + : { + 7: "48px", + 12: "38px", + 16: "28px", + 33: "24px", + 100: "16px", + }; + + for (const [key, value] of Object.entries(sizeMapping)) { + if (numChars <= Number(key)) { + return value; + } + } + + return "48px"; +}; + +interface AssetFieldsetInputProps { + inputPrefix?: ReactNode; + onInputChange?: ChangeEventHandler; + inputValue?: string; + outputValue?: ReactNode; + page?: EventPage; +} + +const AssetFieldsetInput = forwardRef< + HTMLInputElement, + AssetFieldsetInputProps +>(({ inputPrefix, inputValue, onInputChange, outputValue }, ref) => { + const { isMobile } = useWindowSize(); + + const fontSize = calcFontSize((inputValue ?? "").length, isMobile); + + return ( +
+ {inputPrefix} + {outputValue || ( +
+ +
+ )} +
+ ); +}); + +const AssetFieldsetFooter = ({ children }: PropsWithChildren) => ( +
+ {children} +
+); + +interface TokenSelectProps { + selectableAssets?: (MinimalAsset | undefined)[]; + selectedCoinImageUrl?: string; + selectedCoinDenom?: string; + orderDirection?: string; + onSelect?: (denom: string) => void; + onSelectorClick?: () => void; + isModalExternal?: boolean; + fetchNextPageAssets?: () => void; + hasNextPageAssets?: boolean; + isFetchingNextPageAssets?: boolean; + page?: EventPage; +} + +const AssetFieldsetTokenSelector = ({ + selectableAssets, + selectedCoinImageUrl, + selectedCoinDenom, + orderDirection, + onSelect: onOriginalSelect, + isModalExternal, + onSelectorClick, + fetchNextPageAssets, + hasNextPageAssets, + isFetchingNextPageAssets, + page = "Swap Page", +}: TokenSelectProps) => { + const { t } = useTranslation(); + const { logEvent } = useAmplitudeAnalytics(); + + const { + isOpen: isSelectOpen, + onOpen: openSelect, + onClose: closeSelect, + } = useDisclosure(); + + const router = useRouter(); + + const onSelect = (tokenDenom: string) => { + logEvent([ + EventName.Swap.dropdownAssetSelected, + { + tokenName: tokenDenom, + isOnHome: router.pathname === "/", + page, + }, + ]); + onOriginalSelect?.(tokenDenom); + }; + + return ( + <> + + {!isModalExternal && selectableAssets && ( + + )} + + ); +}; + +export { + AssetFieldset, + AssetFieldsetFooter, + AssetFieldsetHeader, + AssetFieldsetHeaderBalance, + AssetFieldsetHeaderLabel, + AssetFieldsetInput, + AssetFieldsetTokenSelector, +}; diff --git a/packages/web/components/complex/assets-page-v1.tsx b/packages/web/components/complex/assets-page-v1.tsx index 5bdd5b26b0..1656a2c0ca 100644 --- a/packages/web/components/complex/assets-page-v1.tsx +++ b/packages/web/components/complex/assets-page-v1.tsx @@ -1,5 +1,6 @@ import { PricePretty, RatePretty } from "@keplr-wallet/unit"; import { ObservableQueryPool } from "@osmosis-labs/stores"; +import classNames from "classnames"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { FunctionComponent, useCallback, useEffect, useState } from "react"; @@ -11,11 +12,11 @@ import { DesktopOnlyPrivateText } from "~/components/privacy"; import { AssetsTableV1 } from "~/components/table/assets-table-v1"; import type { Metric } from "~/components/types"; import { Button, ShowMoreButton } from "~/components/ui/button"; -import { useTranslation } from "~/hooks"; import { useAmplitudeAnalytics, useHideDustUserSetting, useNavBar, + useTranslation, useWindowSize, } from "~/hooks"; import { useBridge } from "~/hooks/bridge"; @@ -98,7 +99,7 @@ export const AssetsPageV1: FunctionComponent = observer(() => { const flags = useFeatureFlags(); return ( -
+
{flags.transactionsPage && } @@ -121,6 +122,7 @@ const AssetsOverview: FunctionComponent = observer(() => { const { assetsStore, queriesStore, chainStore, priceStore } = useStore(); const { width } = useWindowSize(); const { t } = useTranslation(); + const flags = useFeatureFlags(); const osmosisQueries = queriesStore.get(chainStore.osmosis.chainId).osmosis!; @@ -158,7 +160,14 @@ const AssetsOverview: FunctionComponent = observer(() => { }; return ( -
+
} diff --git a/packages/web/components/complex/assets-page-v2.tsx b/packages/web/components/complex/assets-page-v2.tsx index a423a0e757..6b56d0471f 100644 --- a/packages/web/components/complex/assets-page-v2.tsx +++ b/packages/web/components/complex/assets-page-v2.tsx @@ -4,7 +4,7 @@ import { AssetsInfoTable } from "../table/asset-info"; export const AssetsPageV2: FunctionComponent = () => { return ( -
+
); diff --git a/packages/web/components/complex/orders-history/cells/actions.tsx b/packages/web/components/complex/orders-history/cells/actions.tsx new file mode 100644 index 0000000000..aba30a5fdd --- /dev/null +++ b/packages/web/components/complex/orders-history/cells/actions.tsx @@ -0,0 +1,157 @@ +import { MappedLimitOrder } from "@osmosis-labs/server"; +import { observer } from "mobx-react-lite"; +import { useCallback, useState } from "react"; + +import { Spinner } from "~/components/loaders"; +import { t } from "~/hooks"; +import { useStore } from "~/stores"; + +export function ActionsCell({ + order, + refetch, +}: { + order: MappedLimitOrder; + refetch: () => Promise; +}) { + const component = (() => { + switch (order.status) { + case "open": + return ; + case "partiallyFilled": + // TODO: swap to cancel button for partially filled but entirely claimed orders + return ; + case "filled": + return ( + + {t("limitOrders.claimable")} + + ); + default: + return null; + } + })(); + return
{component}
; +} + +const ClaimAndCloseButton = observer( + ({ + order, + refetch, + }: { + order: MappedLimitOrder; + refetch: () => Promise; + }) => { + const { accountStore } = useStore(); + const account = accountStore.getWallet(accountStore.osmosisChainId); + const [claiming, setClaiming] = useState(false); + + const claimAndClose = useCallback(async () => { + if (!account) { + console.error( + "Attempted to claim and close orders without wallet connected" + ); + return; + } + const { tick_id, order_id, orderbookAddress } = order; + const claimMsg = { + msg: { + claim_limit: { order_id, tick_id }, + }, + contractAddress: orderbookAddress, + funds: [], + }; + const cancelMsg = { + msg: { cancel_limit: { order_id, tick_id } }, + contractAddress: orderbookAddress, + funds: [], + }; + const msgs = []; + if (order.percentFilled > order.percentClaimed) { + msgs.push(claimMsg); + } + + msgs.push(cancelMsg); + + try { + setClaiming(true); + await account.cosmwasm.sendMultiExecuteContractMsg( + "executeWasm", + msgs, + undefined + ); + await refetch(); + } catch (error) { + console.error(error); + setClaiming(false); + } + }, [account, order, refetch]); + + return ( + + ); + } +); + +const CancelButton = observer( + ({ + order, + refetch, + }: { + order: MappedLimitOrder; + refetch: () => Promise; + }) => { + const { accountStore } = useStore(); + const account = accountStore.getWallet(accountStore.osmosisChainId); + const [cancelling, setCancelling] = useState(false); + + const cancel = useCallback(async () => { + if (!account) { + console.error("Attempted to cancel orders without wallet connected"); + return; + } + const { tick_id, order_id, orderbookAddress } = order; + const claimMsg = { + msg: { + cancel_limit: { order_id, tick_id }, + }, + contractAddress: orderbookAddress, + funds: [], + }; + + try { + setCancelling(true); + await account.cosmwasm.sendMultiExecuteContractMsg( + "executeWasm", + [claimMsg], + undefined + ); + await refetch(); + } catch (error) { + console.error(error); + setCancelling(false); + } + }, [account, order, refetch]); + + return ( + + ); + } +); diff --git a/packages/web/components/complex/orders-history/cells/filled-progress.tsx b/packages/web/components/complex/orders-history/cells/filled-progress.tsx new file mode 100644 index 0000000000..4d18f068fe --- /dev/null +++ b/packages/web/components/complex/orders-history/cells/filled-progress.tsx @@ -0,0 +1,52 @@ +import { Dec, Int } from "@keplr-wallet/unit"; +import type { MappedLimitOrder } from "@osmosis-labs/server"; +import classNames from "classnames"; +import React, { useMemo } from "react"; + +import { ProgressBar } from "~/components/ui/progress-bar"; +import { formatPretty } from "~/utils/formatter"; + +interface OrderProgressBarProps { + order: MappedLimitOrder; +} + +export const OrderProgressBar: React.FC = ({ + order, +}) => { + const { percentFilled, status } = order; + + const roundedAmountFilled = useMemo(() => { + if (percentFilled.lt(new Dec(0.01)) && !percentFilled.isZero()) { + return new Int(1); + } + return percentFilled.mul(new Dec(100)).truncate(); + }, [percentFilled]); + + const progressSegments = useMemo( + () => [ + { + percentage: roundedAmountFilled.toString(), + classNames: "bg-bullish-400", + }, + ], + [roundedAmountFilled] + ); + + if (status !== "partiallyFilled" && status !== "open") { + return; + } + + return ( + + ); +}; diff --git a/packages/web/components/complex/orders-history/index.tsx b/packages/web/components/complex/orders-history/index.tsx new file mode 100644 index 0000000000..88cd559078 --- /dev/null +++ b/packages/web/components/complex/orders-history/index.tsx @@ -0,0 +1,531 @@ +import { CoinPretty, Dec, Int, PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY, MappedLimitOrder } from "@osmosis-labs/server"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import classNames from "classnames"; +import dayjs from "dayjs"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import Link from "next/link"; +import React, { memo, useCallback, useMemo, useRef, useState } from "react"; + +import { Icon } from "~/components/assets"; +import { ActionsCell } from "~/components/complex/orders-history/cells/actions"; +import { OrderProgressBar } from "~/components/complex/orders-history/cells/filled-progress"; +import { Intersection } from "~/components/intersection"; +import { Spinner } from "~/components/loaders"; +import { GenericDisclaimer } from "~/components/tooltip/generic-disclaimer"; +import { Button } from "~/components/ui/button"; +import { EventName } from "~/config"; +import { + useAmplitudeAnalytics, + useFeatureFlags, + useTranslation, + useWalletSelect, +} from "~/hooks"; +import { + useOrderbookAllActiveOrders, + useOrderbookClaimableOrders, +} from "~/hooks/limit-orders/use-orderbook"; +import { useStore } from "~/stores"; +import { + formatFiatPrice, + formatPretty, + getPriceExtendedFormatOptions, +} from "~/utils/formatter"; + +function groupOrdersByStatus(orders: MappedLimitOrder[]) { + const filledOrders = orders.filter((o) => o.status === "filled"); + const pendingOrders = orders.filter( + (o) => o.status === "partiallyFilled" || o.status === "open" + ); + const pastOrders = orders.filter( + (o) => o.status === "cancelled" || o.status === "fullyClaimed" + ); + + return { + filled: filledOrders, + pending: pendingOrders, + past: pastOrders, + }; +} + +const headers = ["order", "amount", "price", "orderPlaced", "status"]; + +export const OrderHistory = observer(() => { + const { logEvent } = useAmplitudeAnalytics({ + onLoadEvent: [EventName.LimitOrder.pageViewed], + }); + const featureFlags = useFeatureFlags(); + const { accountStore } = useStore(); + const { t } = useTranslation(); + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + const listRef = useRef(null); + const { onOpenWalletSelect, isLoading: isWalletLoading } = useWalletSelect(); + + const { + orders, + isLoading, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + refetch, + } = useOrderbookAllActiveOrders({ + userAddress: wallet?.address ?? "", + pageSize: 10, + }); + + const groupedOrders = useMemo(() => groupOrdersByStatus(orders), [orders]); + const groups = useMemo( + () => + Object.keys(groupedOrders).filter( + (group) => groupedOrders[group as keyof typeof groupedOrders].length > 0 + ), + [groupedOrders] + ); + const rows = useMemo( + () => + groups.reduce>( + (acc, group) => [ + ...acc, + group, + ...groupedOrders[group as keyof typeof groupedOrders], + ], + [] + ), + [groups, groupedOrders] + ); + + const rowVirtualizer = useWindowVirtualizer({ + count: hasNextPage ? rows.length + 1 : rows.length, + estimateSize: () => 84, + paddingStart: -220, + overscan: 10, + scrollMargin: listRef.current?.offsetTop ?? 0, + }); + + const { claimAllOrders, count: filledOrdersCount } = + useOrderbookClaimableOrders({ + userAddress: wallet?.address ?? "", + disabled: isLoading || orders.length === 0, + }); + + const claimOrders = useCallback(async () => { + try { + logEvent([EventName.LimitOrder.claimOrdersStarted]); + await claimAllOrders(); + await refetch(); + logEvent([EventName.LimitOrder.claimOrdersCompleted]); + } catch (error) { + if (error instanceof Error && error.message === "Request rejected") { + // don't log when the user rejects in wallet + return; + } + const { message } = error as Error; + logEvent([ + EventName.LimitOrder.claimOrdersFailed, + { errorMessage: message }, + ]); + } + }, [claimAllOrders, logEvent, refetch]); + + const showConnectWallet = !wallet?.isWalletConnected && !isWalletLoading; + + if (showConnectWallet) { + return ( +
+ ion thumbs up +
+
{t("limitOrders.historyTable.emptyState.connectTitle")}
+

+ {t("limitOrders.historyTable.emptyState.connectSubtitle")} +

+
+
+ +
+
+ ); + } + + if (orders.length === 0 && !isLoading) { + return ( +
+ ion thumbs up +
{t("limitOrders.historyTable.emptyState.title")}
+

+ {t("limitOrders.historyTable.emptyState.subtitle")} + + {t("limitOrders.startTrading")} + +

+
+ ); + } + + return ( +
+ + {!isLoading && ( + + + {headers.map((header) => ( + + ))} + + + )} + + {isLoading ? ( +
+ +
+ ) : ( + rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + + if (!row) return; + + const style = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)`, + }; + + if (typeof row === "string") { + return ( + + ); + } + + const order = row as MappedLimitOrder; + return ( + refetch()} + /> + ); + }) + )} + +
+ + {t(`limitOrders.historyTable.columns.${header}`)} + +
+ { + if (hasNextPage && !isFetchingNextPage && !isLoading) { + fetchNextPage(); + } + }} + /> +
+ ); +}); + +const TableGroupHeader = ({ + group, + style, + filledOrdersCount, + claimOrders, +}: { + group: string; + style: Object; + filledOrdersCount: number; + claimOrders: () => Promise; +}) => { + const { t } = useTranslation(); + const [claiming, setClaiming] = useState(false); + + const claim = useCallback(async () => { + setClaiming(true); + await claimOrders(); + setClaiming(false); + }, [claimOrders]); + + if (group === "filled") { + return ( + + +
+
+
+
{t("limitOrders.filledOrdersToClaim")}
+
+ {filledOrdersCount} +
+
+ +
+ +
+
+
+ +
+ + + ); + } + + return ( + +
{t(`limitOrders.${group}`)}
{" "} + + ); +}; + +const TableOrderRow = memo( + ({ + order, + style, + refetch, + }: { + order: MappedLimitOrder; + style: Object; + refetch: () => Promise; + }) => { + const { t } = useTranslation(); + + const { + order_direction, + quoteAsset, + baseAsset, + placed_quantity, + output, + price, + placed_at, + status, + } = order; + + const baseAssetLogo = + baseAsset?.rawAsset.logoURIs.svg ?? + baseAsset?.rawAsset.logoURIs.png ?? + ""; + + const placedAt = dayjs(placed_at); + const formattedTime = placedAt.format("h:mm A"); + const formattedDate = placedAt.format("MMM D"); + + const statusString = (() => { + switch (status) { + case "open": + case "partiallyFilled": + return t("limitOrders.open"); + case "filled": + case "fullyClaimed": + return t("limitOrders.filled"); + case "cancelled": + return t("limitOrders.cancelled"); + } + })(); + + const statusComponent = (() => { + switch (status) { + case "open": + case "partiallyFilled": + return ; + case "cancelled": + const dayDiff = dayjs(new Date()).diff(dayjs(placed_at), "d"); + const hourDiff = dayjs(new Date()).diff(dayjs(placed_at), "h"); + + return ( + + {dayDiff > 0 + ? t("limitOrders.daysAgo", { + days: dayDiff.toString(), + }) + : hourDiff > 0 + ? t("limitOrders.hoursAgo", { + hours: hourDiff.toString(), + }) + : "<1 hour ago"} + + ); + default: + return; + } + })(); + return ( + + + + {order_direction === "bid" + ? t("limitOrders.buy") + : t("limitOrders.sell")} + + + +
+
+

+ + {formatPretty( + new CoinPretty( + { + coinDecimals: baseAsset?.decimals ?? 0, + coinDenom: baseAsset?.symbol ?? "", + coinMinimalDenom: baseAsset?.coinMinimalDenom ?? "", + }, + order_direction === "ask" ? placed_quantity : output + ) + )} + + + + {formatPretty( + new CoinPretty( + { + coinDecimals: quoteAsset?.decimals ?? 0, + coinDenom: quoteAsset?.symbol ?? "", + coinMinimalDenom: quoteAsset?.coinMinimalDenom ?? "", + }, + order_direction === "ask" ? output : placed_quantity + ) + )} + +

+
+ + {formatFiatPrice( + new PricePretty( + DEFAULT_VS_CURRENCY, + order_direction === "bid" + ? placed_quantity / + Number( + new Dec(10) + .pow(new Int(quoteAsset?.decimals ?? 0)) + .toString() + ) + : output.quo( + new Dec(10).pow(new Int(quoteAsset?.decimals ?? 0)) + ) + ), + 2 + )}{" "} + {t("limitOrders.of")} + + {`${baseAsset?.symbol} + {baseAsset?.symbol} +
+
+
+ + +
+

+ {baseAsset?.symbol} · {t("limitOrders.limit")} +

+

+ {formatPretty(new PricePretty(DEFAULT_VS_CURRENCY, price), { + ...getPriceExtendedFormatOptions(price), + })} +

+
+ + +
+

{formattedTime}

+

{formattedDate}

+
+ + +
+ {statusComponent} + + {statusString} + +
+ + + + + + ); + } +); diff --git a/packages/web/components/complex/pools-table.tsx b/packages/web/components/complex/pools-table.tsx index 117a264336..7852a4b785 100644 --- a/packages/web/components/complex/pools-table.tsx +++ b/packages/web/components/complex/pools-table.tsx @@ -26,7 +26,12 @@ import { Spinner } from "~/components/loaders"; import { PoolQuickActionCell } from "~/components/table/cells"; import { SortHeader } from "~/components/table/headers/sort"; import { AprDisclaimerTooltip } from "~/components/tooltip/apr-disclaimer"; -import { Breakpoint, useTranslation, useWindowSize } from "~/hooks"; +import { + Breakpoint, + useFeatureFlags, + useTranslation, + useWindowSize, +} from "~/hooks"; import { api, RouterOutputs } from "~/utils/trpc"; import { Tooltip } from "../tooltip"; @@ -91,6 +96,7 @@ export interface PoolsTableProps { export const PoolsTable = (props: PropsWithChildren) => { const { t } = useTranslation(); const { width } = useWindowSize(); + const featureFlags = useFeatureFlags(); const router = useRouter(); const { topOffset, @@ -399,7 +405,10 @@ export const PoolsTable = (props: PropsWithChildren) => { "table-auto", isPreviousData && isFetching && - "animate-[deepPulse_2s_ease-in-out_infinite] cursor-progress" + "animate-[deepPulse_2s_ease-in-out_infinite] cursor-progress", + { + "[&>thead>tr]:!bg-osmoverse-1000": featureFlags.limitOrders, + } )} > diff --git a/packages/web/components/control/token-select-limit.tsx b/packages/web/components/control/token-select-limit.tsx new file mode 100644 index 0000000000..2950ff6bab --- /dev/null +++ b/packages/web/components/control/token-select-limit.tsx @@ -0,0 +1,186 @@ +import { CoinPretty, PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { FunctionComponent, useMemo } from "react"; + +import { Icon } from "~/components/assets"; +import { PriceSelector } from "~/components/swap-tool/price-selector"; +import { Disableable } from "~/components/types"; +import { EventName, EventPage } from "~/config"; +import { useAmplitudeAnalytics, useTranslation, useWindowSize } from "~/hooks"; +import { OrderDirection } from "~/hooks/limit-orders"; +import { usePrice } from "~/hooks/queries/assets/use-price"; +import { useControllableState } from "~/hooks/use-controllable-state"; +import type { SwapAsset } from "~/hooks/use-swap"; +import { TokenSelectModalLimit } from "~/modals/token-select-modal-limit"; +import { useStore } from "~/stores"; +import { formatPretty } from "~/utils/formatter"; + +export interface TokenSelectLimitProps { + dropdownOpen?: boolean; + setDropdownOpen?: (value: boolean) => void; + selectableAssets: SwapAsset[]; + baseAsset: SwapAsset & + Partial<{ + amount: CoinPretty; + usdValue: PricePretty; + }>; + quoteAsset: SwapAsset & + Partial<{ + amount: CoinPretty; + usdValue: PricePretty; + }>; + baseBalance: CoinPretty; + quoteBalance: CoinPretty; + onTokenSelect: (tokenDenom: string) => void; + canSelectTokens?: boolean; + orderDirection: OrderDirection; + page?: EventPage; +} + +export const TokenSelectLimit: FunctionComponent< + TokenSelectLimitProps & Disableable +> = observer( + ({ + dropdownOpen, + setDropdownOpen, + selectableAssets, + onTokenSelect, + canSelectTokens = true, + baseAsset, + baseBalance, + disabled, + orderDirection, + page = "Swap Page", + }) => { + const { t } = useTranslation(); + const { isMobile } = useWindowSize(); + const router = useRouter(); + const { logEvent } = useAmplitudeAnalytics(); + const { accountStore } = useStore(); + + const isWalletConnected = accountStore.getWallet( + accountStore.osmosisChainId + )?.isWalletConnected; + + // parent overrideable state + const [isSelectOpen, setIsSelectOpen] = useControllableState({ + defaultValue: false, + onChange: setDropdownOpen, + value: dropdownOpen, + }); + + const preSortedTokens = selectableAssets; + + const tokenSelectionAvailable = + canSelectTokens && preSortedTokens.length >= 1; + + const onSelect = (tokenDenom: string) => { + logEvent([ + EventName.Swap.dropdownAssetSelected, + { + tokenName: tokenDenom, + isOnHome: router.pathname === "/", + page, + }, + ]); + onTokenSelect(tokenDenom); + }; + + const { price: baseCoinPrice, isLoading: isLoadingBasePrice } = usePrice({ + coinMinimalDenom: baseAsset.coinMinimalDenom, + }); + + const baseFiatBalance = useMemo( + () => + !isLoadingBasePrice && baseCoinPrice && baseBalance + ? new PricePretty(DEFAULT_VS_CURRENCY, baseCoinPrice.mul(baseBalance)) + : new PricePretty(DEFAULT_VS_CURRENCY, 0), + [baseCoinPrice, baseBalance, isLoadingBasePrice] + ); + + const showBaseBalance = orderDirection === "ask" && isWalletConnected; + + return ( +
+ + + setIsSelectOpen(false)} + onSelect={onSelect} + showSearchBox + selectableAssets={preSortedTokens} + /> +
+ ); + } +); diff --git a/packages/web/components/funnels/concentrated-liquidity/supercharge-pool.tsx b/packages/web/components/funnels/concentrated-liquidity/supercharge-pool.tsx index da7aa911b9..05d8aee184 100644 --- a/packages/web/components/funnels/concentrated-liquidity/supercharge-pool.tsx +++ b/packages/web/components/funnels/concentrated-liquidity/supercharge-pool.tsx @@ -17,7 +17,7 @@ interface Props extends CustomClasses { export const SuperchargePool: FunctionComponent = (props) => (
diff --git a/packages/web/components/input/scaled-currency-input.tsx b/packages/web/components/input/scaled-currency-input.tsx index a0bf7ddb91..8f814bf037 100644 --- a/packages/web/components/input/scaled-currency-input.tsx +++ b/packages/web/components/input/scaled-currency-input.tsx @@ -11,7 +11,7 @@ interface ScaledCurrencyInputProps { value?: string; onChange?: (value: string) => void; classes?: Partial>; - + placeholder?: string; inputRef?: React.RefObject; } @@ -22,6 +22,7 @@ export function ScaledCurrencyInput({ onChange, classes, inputRef, + placeholder = "0", }: ScaledCurrencyInputProps) { const { isMobile } = useWindowSize(); const [inputValue, setInputValue] = useControllableState({ @@ -95,7 +96,7 @@ export function ScaledCurrencyInput({ "absolute m-0 h-full w-full bg-transparent p-0 placeholder-osmoverse-500 outline-0", classes?.input )} - placeholder="0" + placeholder={placeholder} data-expand="true" minLength={1} style={{ diff --git a/packages/web/components/layouts/main.tsx b/packages/web/components/layouts/main.tsx index d614c496be..62e74298c9 100644 --- a/packages/web/components/layouts/main.tsx +++ b/packages/web/components/layouts/main.tsx @@ -8,7 +8,7 @@ import { MainLayoutMenu, MainMenu } from "~/components/main-menu"; import { NavBar } from "~/components/navbar"; import { NavbarOsmoPrice } from "~/components/navbar-osmo-price"; import { NavbarOsmosisUpdate } from "~/components/navbar-osmosis-update"; -import { useCurrentLanguage, useWindowSize } from "~/hooks"; +import { useCurrentLanguage, useFeatureFlags, useWindowSize } from "~/hooks"; export const MainLayout = observer( ({ @@ -20,6 +20,7 @@ export const MainLayout = observer( secondaryMenuItems: MainLayoutMenu[]; }>) => { const router = useRouter(); + const featureFlags = useFeatureFlags(); useCurrentLanguage(); const { height, isMobile } = useWindowSize(); @@ -40,7 +41,15 @@ export const MainLayout = observer( router.push("/")} />
)} -
+
{showBlockLogo && (
router.push("/")} /> @@ -64,7 +73,7 @@ export const MainLayout = observer( menus={menus} secondaryMenuItems={secondaryMenuItems} /> -
+
{children}
diff --git a/packages/web/components/navbar-osmo-price.tsx b/packages/web/components/navbar-osmo-price.tsx index bac91e1cd5..f70e2e04f5 100644 --- a/packages/web/components/navbar-osmo-price.tsx +++ b/packages/web/components/navbar-osmo-price.tsx @@ -26,7 +26,6 @@ export const NavbarOsmoPrice = observer(() => { const { t } = useTranslation(); const flags = useFeatureFlags(); const { fiatRampSelection } = useBridge(); - const { chainId } = chainStore.osmosis; const wallet = accountStore.getWallet(chainId); diff --git a/packages/web/components/navbar/index.tsx b/packages/web/components/navbar/index.tsx index 54880fb598..9e026323b0 100644 --- a/packages/web/components/navbar/index.tsx +++ b/packages/web/components/navbar/index.tsx @@ -32,8 +32,7 @@ import { Tooltip } from "~/components/tooltip"; import { CustomClasses } from "~/components/types"; import { Button } from "~/components/ui/button"; import { EventName } from "~/config"; -import { useTranslation } from "~/hooks"; -import { useAmplitudeAnalytics, useDisclosure } from "~/hooks"; +import { useAmplitudeAnalytics, useDisclosure, useTranslation } from "~/hooks"; import { useOneClickTradingSession } from "~/hooks/one-click-trading/use-one-click-trading-session"; import { useICNSName } from "~/hooks/queries/osmosis/use-icns-name"; import { useFeatureFlags } from "~/hooks/use-feature-flags"; @@ -173,7 +172,10 @@ export const NavBar: FunctionComponent<
@@ -301,7 +303,10 @@ export const NavBar: FunctionComponent< className={classNames( "bg-osmoverse-900", showBanner ? "h-[124px]" : "h-navbar md:h-navbar-mobile", - backElementClassNames + backElementClassNames, + { + "!bg-osmoverse-1000": featureFlags.limitOrders, + } )} /> {showBanner && ( diff --git a/packages/web/components/place-limit-tool/index.tsx b/packages/web/components/place-limit-tool/index.tsx new file mode 100644 index 0000000000..74b2a32188 --- /dev/null +++ b/packages/web/components/place-limit-tool/index.tsx @@ -0,0 +1,631 @@ +import { Dec, IntPretty, PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { isValidNumericalRawInput } from "@osmosis-labs/utils"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import { parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; +import { + FunctionComponent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { Icon } from "~/components/assets/icon"; +import { + AssetFieldset, + AssetFieldsetFooter, + AssetFieldsetHeader, + AssetFieldsetHeaderBalance, + AssetFieldsetHeaderLabel, + AssetFieldsetInput, + AssetFieldsetTokenSelector, +} from "~/components/complex/asset-fieldset"; +import { LimitPriceSelector } from "~/components/place-limit-tool/limit-price-selector"; +import { TRADE_TYPES } from "~/components/swap-tool/order-type-selector"; +import { PriceSelector } from "~/components/swap-tool/price-selector"; +import { TradeDetails } from "~/components/swap-tool/trade-details"; +import { Button } from "~/components/ui/button"; +import { EventPage } from "~/config"; +import { + useDisclosure, + useSlippageConfig, + useTranslation, + useWalletSelect, +} from "~/hooks"; +import { usePlaceLimit } from "~/hooks/limit-orders"; +import { AddFundsModal } from "~/modals/add-funds"; +import { ReviewOrder } from "~/modals/review-order"; +import { useStore } from "~/stores"; +import { formatPretty } from "~/utils/formatter"; +import { countDecimals, trimPlaceholderZeros } from "~/utils/number"; + +export interface PlaceLimitToolProps { + page: EventPage; + refetchOrders: () => Promise; +} + +const fixDecimalCount = (value: string, decimalCount = 18) => { + const split = value.split("."); + const result = + split[0] + + (decimalCount > 0 ? "." + split[1].substring(0, decimalCount) : ""); + return result; +}; + +const transformAmount = (value: string, decimalCount = 18) => { + let updatedValue = value; + if (value.endsWith(".") && value.length === 1) { + updatedValue = value + "0"; + } + + if (value.startsWith(".")) { + updatedValue = "0" + value; + } + + const decimals = countDecimals(updatedValue); + return decimals > decimalCount + ? fixDecimalCount(updatedValue, decimalCount) + : updatedValue; +}; + +// Certain errors we do not wish to show on the button +const NON_DISPLAY_ERRORS = ["errors.zeroAmount", "errors.emptyAmount"]; + +export const PlaceLimitTool: FunctionComponent = observer( + ({ page, refetchOrders }: PlaceLimitToolProps) => { + const { accountStore } = useStore(); + const { t } = useTranslation(); + const [reviewOpen, setReviewOpen] = useState(false); + const { + isOpen: isAddFundsModalOpen, + onClose: closeAddFundsModal, + onOpen: openAddFundsModal, + } = useDisclosure(); + const inputRef = useRef(null); + + const [{ from, quote, tab, type }, set] = useQueryStates({ + from: parseAsString.withDefault("ATOM"), + quote: parseAsString.withDefault("USDC"), + type: parseAsStringLiteral(TRADE_TYPES).withDefault("market"), + tab: parseAsString, + to: parseAsString, + }); + const [isSendingTx, setIsSendingTx] = useState(false); + + const [focused, setFocused] = useState<"fiat" | "token">( + tab === "buy" ? "fiat" : "token" + ); + + const [fiatAmount, setFiatAmount] = useState(""); + + const setBase = useCallback((base: string) => set({ from: base }), [set]); + + if (from === quote) { + if (quote === "USDC") { + set({ quote: "USDT" }); + } else { + set({ quote: "USDC" }); + } + } + + const orderDirection = useMemo( + () => (tab === "buy" ? "bid" : "ask"), + [tab] + ); + + const { onOpenWalletSelect } = useWalletSelect(); + + const slippageConfig = useSlippageConfig(); + + const swapState = usePlaceLimit({ + osmosisChainId: accountStore.osmosisChainId, + orderDirection, + useQueryParams: false, + baseDenom: from, + quoteDenom: quote, + type, + page, + maxSlippage: slippageConfig.slippage.toDec(), + }); + + // Adjust price to base price if the type changes to "market" + useEffect(() => { + if ( + type === "market" && + swapState.priceState.percentAdjusted.abs().gt(new Dec(0)) + ) { + swapState.priceState.reset(); + } + }, [swapState.priceState, type]); + + useEffect(() => { + if (type === "market" && focused === "token" && tab === "buy") { + setFocused("fiat"); + } + + if (type === "market" && focused === "fiat" && tab === "sell") { + setFocused("token"); + } + }, [focused, tab, type]); + + const account = accountStore.getWallet(accountStore.osmosisChainId); + + // const isSwapToolLoading = false; + const hasFunds = useMemo( + () => + (tab === "buy" + ? swapState.quoteTokenBalance + : swapState.baseTokenBalance + ) + ?.toDec() + .gt(new Dec(0)), + [swapState.baseTokenBalance, swapState.quoteTokenBalance, tab] + ); + + const tokenAmount = useMemo( + () => swapState.inAmountInput.inputAmount, + [swapState.inAmountInput.inputAmount] + ); + + const isMarketLoading = useMemo(() => { + return ( + swapState.isMarket && + (swapState.marketState.isQuoteLoading || + Boolean(swapState.marketState.isLoadingNetworkFee)) && + !Boolean(swapState.marketState.error) + ); + }, [ + swapState.isMarket, + swapState.marketState.isLoadingNetworkFee, + swapState.marketState.isQuoteLoading, + swapState.marketState.error, + ]); + + const selectableBaseAssets = useMemo(() => { + return swapState.marketState.selectableAssets.filter( + (asset) => asset.coinDenom !== swapState.quoteAsset!.coinDenom + ); + }, [swapState.marketState.selectableAssets, swapState.quoteAsset]); + + const { outAmountLessSlippage, outFiatAmountLessSlippage } = useMemo(() => { + // Compute ratio of 1 - slippage + const oneMinusSlippage = new Dec(1).sub(slippageConfig.slippage.toDec()); + + // Compute out amount less slippage + const outAmountLessSlippage = + swapState.marketState.quote && swapState.marketState.toAsset + ? new IntPretty( + swapState.marketState.quote.amount.toDec().mul(oneMinusSlippage) + ) + : undefined; + + // Compute out fiat amount less slippage + const outFiatAmountLessSlippage = swapState.marketState.tokenOutFiatValue + ? new PricePretty( + DEFAULT_VS_CURRENCY, + swapState.marketState.tokenOutFiatValue + ?.toDec() + .mul(oneMinusSlippage) + ) + : undefined; + + return { outAmountLessSlippage, outFiatAmountLessSlippage }; + }, [ + slippageConfig.slippage, + swapState.marketState.quote, + swapState.marketState.toAsset, + swapState.marketState.tokenOutFiatValue, + ]); + + const setAmountSafe = useCallback( + (amountType: "fiat" | "token", value?: string) => { + const update = + amountType === "fiat" + ? setFiatAmount + : swapState.inAmountInput.setAmount; + const setMarketAmount = swapState.marketState.inAmountInput.setAmount; + + // If value is empty clear values + if (!value?.trim()) { + if (amountType === "fiat") { + setMarketAmount(""); + } + return update(""); + } + + const updatedValue = transformAmount( + value, + amountType === "fiat" ? 2 : swapState.baseAsset?.coinDecimals + ).trim(); + + if ( + !isValidNumericalRawInput(updatedValue) || + updatedValue.length > 26 || + (updatedValue.length > 0 && updatedValue.startsWith("-")) + ) { + return; + } + + const isFocused = focused === amountType; + + // Hacky solution to deal with rounding + // TODO: Investigate a way to improve this + if (amountType === "fiat" && tab === "buy") { + setMarketAmount( + new Dec(updatedValue) + .quo(swapState.quoteAssetPrice.toDec()) + .toString() + ); + } + + const formattedValue = + parseFloat(updatedValue) !== 0 && !isFocused + ? trimPlaceholderZeros(updatedValue) + : updatedValue; + + update(formattedValue); + }, + [ + focused, + swapState.baseAsset?.coinDecimals, + swapState.inAmountInput, + swapState.marketState.inAmountInput.setAmount, + swapState.quoteAssetPrice, + tab, + ] + ); + + // Adjusts the token value when the user updates the fiat value + useEffect(() => { + if (focused !== "token" || !swapState.priceState.price) return; + + const value = tokenAmount.length > 0 ? new Dec(tokenAmount) : undefined; + const fiatValue = value + ? swapState.priceState.price.mul(value) + : undefined; + + setAmountSafe("fiat", fiatValue ? fiatValue.toString() : undefined); + }, [ + focused, + setAmountSafe, + swapState.priceState.price, + tokenAmount, + swapState.marketState.inAmountInput, + tab, + ]); + + // Adjusts the token value when the user updates the fiat value + useEffect(() => { + if (focused !== "fiat" || !swapState.priceState.price) return; + + const value = + fiatAmount && fiatAmount.length > 0 ? fiatAmount : undefined; + const tokenValue = value + ? new Dec(value).quo(swapState.priceState.price) + : undefined; + setAmountSafe("token", tokenValue ? tokenValue.toString() : undefined); + }, [fiatAmount, setAmountSafe, focused, swapState.priceState.price]); + + const expectedOutput = useMemo( + () => swapState.marketState.quote?.amount.toDec(), + [swapState.marketState.quote?.amount] + ); + + const toggleMax = useCallback(() => { + if (tab === "buy") { + // Tab is buy so use quote amount + + // Determine amount based on current input + const amount = + focused === "fiat" + ? swapState.quoteTokenBalance?.toDec().toString() + : swapState.quoteTokenBalance + ?.toDec() + .quo(swapState.priceState.price) + .toString(); + + setAmountSafe(focused, amount); + + return; + } + + // Tab must be sell so we use base amount + + // Determine amount based on current input + const amount = + focused === "token" + ? swapState.baseTokenBalance?.toDec().toString() + : swapState.baseTokenBalance + ?.toDec() + .mul(swapState.priceState.price) + .toString(); + return setAmountSafe(focused, amount); + }, [ + tab, + setAmountSafe, + swapState.baseTokenBalance, + swapState.quoteTokenBalance, + focused, + swapState.priceState.price, + ]); + + const inputValue = useMemo( + () => + focused === "fiat" + ? type === "market" && tab === "sell" + ? trimPlaceholderZeros((expectedOutput ?? new Dec(0)).toString()) + : fiatAmount + : type === "market" && tab === "buy" + ? trimPlaceholderZeros((expectedOutput ?? new Dec(0)).toString()) + : tokenAmount, + [expectedOutput, fiatAmount, focused, tab, tokenAmount, type] + ); + + const buttonText = useMemo(() => { + return orderDirection === "bid" + ? t("portfolio.buy") + : t("limitOrders.sell"); + }, [orderDirection, t]); + + const isButtonDisabled = useMemo(() => { + if (swapState.insufficientFunds) { + return true; + } + + if (swapState.isMarket) { + return ( + swapState.marketState.inAmountInput.isEmpty || + swapState.marketState.inAmountInput.amount?.toDec().isZero() || + isMarketLoading || + Boolean(swapState.marketState.error) + ); + } + return Boolean(swapState.error) || !swapState.isBalancesFetched; + }, [ + swapState.error, + swapState.isBalancesFetched, + swapState.isMarket, + swapState.marketState.inAmountInput.amount, + swapState.marketState.inAmountInput.isEmpty, + isMarketLoading, + swapState.insufficientFunds, + swapState.marketState.error, + ]); + + const isButtonLoading = useMemo(() => { + return !swapState.isBalancesFetched; + }, [swapState.isBalancesFetched]); + + const errorDisplay = useMemo(() => { + if (swapState.error && !NON_DISPLAY_ERRORS.includes(swapState.error)) { + return t(swapState.error); + } + }, [swapState.error, t]); + + return ( + <> +
+ + + + + {errorDisplay ? ( + errorDisplay + ) : ( + <> + {t("limitOrders.enterAnAmountTo")}{" "} + {orderDirection === "bid" + ? t("portfolio.buy").toLowerCase() + : t("limitOrders.sell").toLowerCase()} + + )} + + + + +
+ + $ + + ) + } + ref={inputRef} + inputValue={inputValue} + onInputChange={(e) => setAmountSafe(focused, e.target.value)} + /> + +
+ + + + +
+ {type === "limit" && ( + + )} +
+ {!account?.isWalletConnected ? ( + + ) : ( + + )} +
+ +
+ { + setIsSendingTx(true); + await swapState.placeLimit(); + refetchOrders(); + swapState.reset(); + setAmountSafe("fiat", ""); + setReviewOpen(false); + setIsSendingTx(false); + }} + outAmountLessSlippage={outAmountLessSlippage} + outFiatAmountLessSlippage={outFiatAmountLessSlippage} + isConfirmationDisabled={isSendingTx} + isOpen={reviewOpen} + onClose={() => setReviewOpen(false)} + expectedOutput={swapState.expectedTokenAmountOut} + expectedOutputFiat={swapState.expectedFiatAmountOut} + percentAdjusted={swapState.priceState.percentAdjusted} + limitPriceFiat={swapState.priceState.priceFiat} + baseDenom={swapState.baseAsset?.coinDenom} + slippageConfig={slippageConfig} + gasAmount={swapState.gas.gasAmountFiat} + isGasLoading={swapState.gas.isLoading} + gasError={swapState.gas.error} + limitSetPriceLock={swapState.priceState.setPriceLock} + inAmountToken={swapState.paymentTokenValue} + inAmountFiat={swapState.paymentFiatValue} + fromAsset={swapState.marketState.fromAsset} + toAsset={swapState.marketState.toAsset} + isBeyondOppositePrice={swapState.priceState.isBeyondOppositePrice} + /> + set({ from: e })} + setToAssetDenom={(e) => set({ to: e })} + /> + + ); + } +); diff --git a/packages/web/components/place-limit-tool/limit-price-selector.tsx b/packages/web/components/place-limit-tool/limit-price-selector.tsx new file mode 100644 index 0000000000..30eea75db8 --- /dev/null +++ b/packages/web/components/place-limit-tool/limit-price-selector.tsx @@ -0,0 +1,330 @@ +import { Dec } from "@keplr-wallet/unit"; +import classNames from "classnames"; +import { parseAsString, useQueryState } from "nuqs"; +import React, { FC, useCallback, useEffect, useMemo, useState } from "react"; +import AutosizeInput from "react-input-autosize"; +import { useMeasure } from "react-use"; + +import { Icon } from "~/components/assets"; +import { SkeletonLoader } from "~/components/loaders"; +import { GenericDisclaimer } from "~/components/tooltip/generic-disclaimer"; +import { useTranslation } from "~/hooks"; +import { OrderDirection, PlaceLimitState } from "~/hooks/limit-orders"; +import { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; +import { trimPlaceholderZeros } from "~/utils/number"; + +const percentAdjustmentOptions = [ + { value: new Dec(0), label: "Market", defaultValue: true }, + { value: new Dec(0.02), label: "2%" }, + { value: new Dec(0.05), label: "5%" }, + { value: new Dec(0.1), label: "10%" }, +]; + +interface LimitPriceSelectorProps { + swapState: PlaceLimitState; + orderDirection: OrderDirection; +} + +enum InputMode { + Percentage, + Price, +} + +export const LimitPriceSelector: FC = ({ + swapState, + orderDirection, +}) => { + const [tab] = useQueryState("tab", parseAsString.withDefault("swap")); + const [input, setInput] = useState(null); + const { t } = useTranslation(); + const { priceState } = swapState; + const [inputMode, setInputMode] = useState(InputMode.Price); + + const swapInputMode = useCallback(() => { + setInputMode( + inputMode === InputMode.Percentage + ? InputMode.Price + : InputMode.Percentage + ); + + if (inputMode === InputMode.Price) { + priceState.setPriceLock(false); + } else { + priceState.setPriceLock(true); + } + + if (priceState.isBeyondOppositePrice || !priceState.manualPercentAdjusted) { + priceState.setPercentAdjusted("0"); + } + + if (input) input.focus(); + }, [inputMode, input, priceState]); + + // Adjust order price as spot price changes until user inputs a price + useEffect(() => { + if (inputMode === InputMode.Price && !priceState.priceLocked) { + const formattedPrice = formatPretty( + priceState.spotPrice, + getPriceExtendedFormatOptions(priceState.spotPrice) + ).replace(/,/g, ""); + priceState._setPriceUnsafe(formattedPrice); + priceState._setPercentAdjustedUnsafe("0"); + } + + if (inputMode === InputMode.Percentage) { + priceState.setPriceAsPercentageOfSpotPrice( + new Dec( + !!priceState.manualPercentAdjusted + ? priceState.manualPercentAdjusted.replace(/,/g, "") + : 0 + ).quo(new Dec(100)), + false, + false + ); + } + /** + * Dependencies are ignored for this hook as we only want the price state to update + * when the spot price moves. + */ + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputMode, priceState.priceLocked, priceState.spotPrice]); + + const priceLabel = useMemo(() => { + if (inputMode === InputMode.Percentage) { + return `$${trimPlaceholderZeros( + priceState.priceFiat.toDec().toString() + )}`; + } + + return priceState.percentAdjusted.isZero() + ? t("limitOrders.marketPrice") + : `${trimPlaceholderZeros( + formatPretty(priceState.percentAdjusted.mul(new Dec(100)).abs(), { + ...getPriceExtendedFormatOptions(priceState.percentAdjusted), + maxDecimals: 3, + }) + )}%`; + }, [inputMode, priceState.percentAdjusted, priceState.priceFiat, t]); + + const percentageSuffix = useMemo(() => { + if (priceState.percentAdjusted.isZero()) + return ` ${ + orderDirection === "bid" + ? t("limitOrders.below") + : t("limitOrders.above") + } ${t("limitOrders.currentPrice")}`; + + if (orderDirection === "ask") { + return ` ${ + priceState.isBeyondOppositePrice + ? t("limitOrders.below") + : t("limitOrders.above") + } ${t("limitOrders.currentPrice")}`; + } + + return ` ${ + priceState.isBeyondOppositePrice + ? t("limitOrders.above") + : t("limitOrders.below") + } ${t("limitOrders.currentPrice")}`; + }, [ + t, + priceState.percentAdjusted, + orderDirection, + priceState.isBeyondOppositePrice, + ]); + + const [containerRef, { width }] = useMeasure(); + + const isPercentTooLarge = useMemo(() => { + if (tab !== "buy") return false; + + const maxPercentage = new Dec(99.999); + return priceState.percentAdjusted.abs().gt(maxPercentage); + }, [priceState.percentAdjusted, tab]); + + return ( +
+
+ + {orderDirection === "bid" + ? t("limitOrders.aboveMarket.title") + : t("limitOrders.belowMarket.title")} + + } + body={ + + {orderDirection === "bid" + ? t("limitOrders.aboveMarket.description") + : t("limitOrders.belowMarket.description")} + + } + > + + + +
+ {percentAdjustmentOptions.map(({ label, value, defaultValue }) => ( + + ))} +
+
+ ); +}; diff --git a/packages/web/components/pool-detail/base-pool.tsx b/packages/web/components/pool-detail/base-pool.tsx index 22fb7b2fff..272ace8414 100644 --- a/packages/web/components/pool-detail/base-pool.tsx +++ b/packages/web/components/pool-detail/base-pool.tsx @@ -3,8 +3,7 @@ import type { Pool } from "@osmosis-labs/server"; import classNames from "classnames"; import { observer } from "mobx-react-lite"; import Link from "next/link"; -import { FunctionComponent } from "react"; -import { Fragment, useState } from "react"; +import { Fragment, FunctionComponent, useState } from "react"; import { useMeasure } from "react-use"; import { Icon, PoolAssetsIcon } from "~/components/assets"; @@ -36,9 +35,9 @@ export const BasePoolDetails: FunctionComponent<{ useMeasure(); return ( -
+
-
+
= const { isLoading: isWalletLoading } = useWalletSelect(); const account = accountStore.getWallet(chainStore.osmosis.chainId); const openCreatePosition = useSearchParam(OpenCreatePositionSearchParam); + const featureFlags = useFeatureFlags(); const chartConfig = useHistoricalAndLiquidityData(poolId); const [activeModal, setActiveModal] = useState< @@ -145,7 +148,7 @@ export const ConcentratedLiquidityPool: FunctionComponent<{ poolId: string }> = ); return ( -
+
{pool && activeModal === "add-liquidity" && ( = /> )}
-
+
@@ -360,6 +367,10 @@ export const ConcentratedLiquidityPool: FunctionComponent<{ poolId: string }> = onSecondaryClick={() => { setActiveModal("learn-more"); }} + className={classNames({ + "bg-osmoverse-800": !featureFlags.limitOrders, + "bg-osmoverse-900": featureFlags.limitOrders, + })} /> = return (
-
+
{t("clPositions.totalBalance")} @@ -515,7 +533,9 @@ const UserAssetsAndExternalIncentives: FunctionComponent<{ poolId: string }> = {featureFlags.aprBreakdown && ( @@ -523,7 +543,14 @@ const UserAssetsAndExternalIncentives: FunctionComponent<{ poolId: string }> = )} {hasIncentives && ( -
+
{t("pool.incentives")} diff --git a/packages/web/components/pool-detail/share.tsx b/packages/web/components/pool-detail/share.tsx index 97a7ea57cd..1a2ec12b6f 100644 --- a/packages/web/components/pool-detail/share.tsx +++ b/packages/web/components/pool-detail/share.tsx @@ -29,11 +29,12 @@ import { SkeletonLoader } from "~/components/loaders/skeleton-loader"; import { Disableable } from "~/components/types"; import { Button } from "~/components/ui/button"; import { EventName } from "~/config"; -import { useTranslation, useWalletSelect } from "~/hooks"; import { useAmplitudeAnalytics, useLockTokenConfig, useSuperfluidPool, + useTranslation, + useWalletSelect, useWindowSize, } from "~/hooks"; import { @@ -316,7 +317,7 @@ export const SharePool: FunctionComponent<{ pool: Pool }> = observer( ); return ( -
+
{pool && showAddLiquidityModal && ( = observer( /> )}
-
+
= observer(
{userSharePool && userSharePool.totalValue.toDec().isPositive() && (
-
+
{t("pool.yourStats")} @@ -555,7 +556,7 @@ export const SharePool: FunctionComponent<{ pool: Pool }> = observer(
-
+
@@ -46,10 +46,10 @@ export const RadioWithOptions = ({ key={`${value} radio button`} className={({ checked }) => classNames( - "inline-flex w-full items-center justify-center overflow-hidden whitespace-nowrap rounded-lg px-6 py-2.5 font-subtitle1 leading-5 opacity-30 hover:cursor-pointer", + "inline-flex w-full items-center justify-center overflow-hidden whitespace-nowrap rounded-lg px-6 py-2.5 font-subtitle1 leading-5 hover:cursor-pointer", { "opacity-100": checked, - "hover:bg-osmoverse-900/25": !checked, + "opacity-30 hover:bg-osmoverse-900/25": !checked, "bg-wosmongton-700": mode === "primary" && checked, "bg-osmoverse-700": mode === "secondary" && checked, "h-13": variant === "large", diff --git a/packages/web/components/swap-tool/__tests__/swap-tool.spec.tsx b/packages/web/components/swap-tool/__tests__/swap-tool.spec.tsx deleted file mode 100644 index efe962ab5f..0000000000 --- a/packages/web/components/swap-tool/__tests__/swap-tool.spec.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -import { Dec, PricePretty } from "@keplr-wallet/unit"; -import { DEFAULT_VS_CURRENCY, NumPoolsResponse } from "@osmosis-labs/server"; -import { createCallerFactory } from "@osmosis-labs/trpc"; -import { getAssetFromAssetList } from "@osmosis-labs/utils"; -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { rest } from "msw"; -import mockRouter from "next-router-mock"; - -import { server, trpcMsw } from "~/__tests__/msw"; -import { renderWithProviders } from "~/__tests__/test-utils"; -import { SwapTool } from "~/components/swap-tool"; -import { AssetLists } from "~/config/generated/asset-lists"; -import { ChainList } from "~/config/generated/chain-list"; -import { appRouter } from "~/server/api/root-router"; - -jest.mock("next/router", () => jest.requireActual("next-router-mock")); - -// Mock the ResizeObserver -const ResizeObserverMock = jest.fn(() => ({ - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn(), -})); - -// Stub the global ResizeObserver -global.ResizeObserver = ResizeObserverMock; - -const createCaller = createCallerFactory(appRouter); -const caller = createCaller({ - /** - * The asset list and chain list used here are snapshots of the - * production asset list. In case of discrepancies due to outdated - * assets or chains, consider updating these mocks. - */ - assetLists: AssetLists, - chainList: ChainList, -}); - -beforeEach(() => { - server.use( - trpcMsw.edge.assets.getUserAssets.query(async (req, res, ctx) => { - return res( - ctx.status(200), - ctx.data(await caller.edge.assets.getUserAssets(req.getInput())) - ); - }), - trpcMsw.edge.assets.getAssetPrice.query((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.data(new PricePretty(DEFAULT_VS_CURRENCY, new Dec(1))) - ); - }) - ); -}); - -afterEach(() => { - mockRouter.setCurrentUrl("/"); -}); - -it("should return only pool assets when sending a pool id and useOtherCurrencies is true", async () => { - const poolId = "908"; - const rawPoolTokens = [ - { - denom: - "ibc/0CD3A0285E1341859B5E86B6AB7682F023D03E97607CCC1DC95706411D866DF7", - amount: "249.68336812445327", - name: "Dai Stablecoin", - symbol: "DAI", - display: "dai", - exponent: "18", - coingecko_id: "dai", - weight_or_scaling: "1000000000000", - percent: "0.999999999998", - price: "1.0009205128376601", - price_24h_change: "-0.0005927567076408077", - }, - { - denom: - "ibc/23CA6C8D1AB2145DD13EB1E089A2E3F960DC298B468CCE034E19E5A78B61136E", - amount: "823.563328", - name: "CMST", - symbol: "CMST", - display: "cmst", - exponent: "6", - coingecko_id: "composite", - weight_or_scaling: "1", - percent: "9.99999999998e-13", - price: "0.7051241017886", - price_24h_change: "0.7037924681345253", - }, - { - denom: - "ibc/92BE0717F4678905E53F4E45B2DED18BC0CB97BF1F8B6A25AFEDF3D5A879B4D5", - amount: "250.470027", - name: "Inter Stable Token", - symbol: "IST", - display: "ist", - exponent: "6", - coingecko_id: "inter-stable-token", - weight_or_scaling: "1", - percent: "9.99999999998e-13", - price: "1.0004902227514614", - price_24h_change: "0.07891367083611964", - }, - ]; - const assets = rawPoolTokens.map((rawToken) => - getAssetFromAssetList({ - assetLists: AssetLists, - coinMinimalDenom: rawToken.denom, - }) - ); - - expect(assets).toHaveLength(3); - - server.use( - rest.get("https://sqs.osmosis.zone/pools", (_req, res, ctx) => { - return res( - ctx.status(200), - ctx.json([ - { - chain_model: { - address: - "osmo1mw0ac6rwlp5r8wapwk3zs6g29h8fcscxqakdzw9emkne6c8wjp9q0t3v8t", - id: Number(poolId), - pool_params: { - swap_fee: "0.002000000000000000", - exit_fee: "0.000000000000000000", - }, - future_pool_governor: "24h", - total_weight: "1073741824000000.000000000000000000", - total_shares: { - denom: `gamm/pool/${poolId}`, - amount: "8443258129578637381630355637", - }, - pool_assets: rawPoolTokens.map((token) => ({ - token: { denom: token.denom, amount: token.amount }, - weight: token.weight_or_scaling, - })), - }, - balances: rawPoolTokens.map((token) => ({ - denom: token.denom, - amount: token.amount, - })), - type: 0, - spread_factor: "0.002000000000000000", - liquidity_cap: "6822727", - liquidity_cap_error: "", - }, - ]) - ); - }), - rest.get( - "https://lcd-osmosis.keplr.app/osmosis/poolmanager/v1beta1/num_pools", - (_req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ num_pools: "1" } as NumPoolsResponse) - ); - } - ) - ); - - renderWithProviders( - - ); - - await waitFor(() => { - expect(screen.getByText("Swap")).toBeInTheDocument(); - }); - - // Click on asset 1 to open drawer - const fromTokenButton = screen.getByRole("button", { - name: `Select 'from' token. Current token is ${assets[0]!.symbol}`, - }); - expect(fromTokenButton).toBeInTheDocument(); - - // asset 3 should not be visible as the token select list is not open - expect(screen.queryByText(assets[2]!.symbol)).toBeNull(); - - await userEvent.click(fromTokenButton); - - // Check if drawer is open - expect(await screen.findByText("Select a token")).toBeInTheDocument(); - - // IST should be in the list - expect(screen.getByText(assets[2]!.symbol)).toBeInTheDocument(); - - // Recommended assets should not be visible - expect(screen.queryByTestId("recommended-assets-container")).toBeNull(); - - // Search box should not be visible - expect(screen.queryByPlaceholderText("Search")).toBeNull(); - - // Only the pool assets should be visible, including the to and from tokens - expect(screen.getAllByTestId("token-select-asset")).toHaveLength(3); - - // Close drawer - userEvent.click(screen.getByRole("button", { name: "Close" })); - - // Should have been closed - await waitFor(() => { - expect(screen.queryByText("Select a token")).toBeNull(); - }); - - // Click on asset 2 to open drawer - const toTokenButton = screen.getByRole("button", { - name: `Select 'to' token. Current token is ${assets[1]!.symbol}`, - }); - - // asset 3 should not be visible as the token select list is not open - expect(screen.queryByText(assets[2]!.symbol)).toBeNull(); - - await userEvent.click(toTokenButton); - - // Check if drawer is open - expect(await screen.findByText("Select a token")).toBeInTheDocument(); - - // IST should be in the list - expect(screen.getByText(assets[2]!.symbol)).toBeInTheDocument(); - - // Recommended assets should not be visible - expect(screen.queryByTestId("recommended-assets-container")).toBeNull(); - - // Search box should not be visible - expect(screen.queryByPlaceholderText("Search")).toBeNull(); - - // Only the pool assets should be visible, including the to and from tokens - expect(screen.getAllByTestId("token-select-asset")).toHaveLength(3); -}); diff --git a/packages/web/components/swap-tool/alt.tsx b/packages/web/components/swap-tool/alt.tsx new file mode 100644 index 0000000000..9bab54590b --- /dev/null +++ b/packages/web/components/swap-tool/alt.tsx @@ -0,0 +1,661 @@ +import { WalletStatus } from "@cosmos-kit/core"; +import { Dec, IntPretty, PricePretty, RatePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { isNil } from "@osmosis-labs/utils"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { + FunctionComponent, + ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from "react"; +import { useMeasure, useMount } from "react-use"; + +import { Icon } from "~/components/assets"; +import { + AssetFieldset, + AssetFieldsetFooter, + AssetFieldsetHeader, + AssetFieldsetHeaderBalance, + AssetFieldsetHeaderLabel, + AssetFieldsetInput, + AssetFieldsetTokenSelector, +} from "~/components/complex/asset-fieldset"; +import { tError } from "~/components/localization"; +import { TradeDetails } from "~/components/swap-tool/trade-details"; +import { Button } from "~/components/ui/button"; +import { EventName, EventPage } from "~/config"; +import { + useAmplitudeAnalytics, + useDisclosure, + useFeatureFlags, + useOneClickTradingSession, + useSlippageConfig, + useTranslation, + useWalletSelect, + useWindowSize, +} from "~/hooks"; +import { useSwap } from "~/hooks/use-swap"; +import { useGlobalIs1CTIntroModalScreen } from "~/modals"; +import { AddFundsModal } from "~/modals/add-funds"; +import { ReviewOrder } from "~/modals/review-order"; +import { TokenSelectModalLimit } from "~/modals/token-select-modal-limit"; +import { useStore } from "~/stores"; +import { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; + +export interface SwapToolProps { + fixedWidth?: boolean; + useOtherCurrencies: boolean | undefined; + useQueryParams: boolean | undefined; + onRequestModalClose?: () => void; + swapButton?: React.ReactElement; + initialSendTokenDenom?: string; + initialOutTokenDenom?: string; + page: EventPage; + forceSwapInPoolId?: string; + onSwapSuccess?: (params: { + sendTokenDenom: string; + outTokenDenom: string; + }) => void; +} + +export const AltSwapTool: FunctionComponent = observer( + ({ + useOtherCurrencies, + useQueryParams, + onRequestModalClose, + swapButton, + initialSendTokenDenom, + initialOutTokenDenom, + page, + forceSwapInPoolId, + onSwapSuccess, + }) => { + const { chainStore, accountStore } = useStore(); + const { t } = useTranslation(); + const { chainId } = chainStore.osmosis; + const { isMobile } = useWindowSize(); + const { logEvent } = useAmplitudeAnalytics(); + const { isLoading: isWalletLoading, onOpenWalletSelect } = + useWalletSelect(); + const featureFlags = useFeatureFlags(); + const [, setIs1CTIntroModalScreen] = useGlobalIs1CTIntroModalScreen(); + const { isOneClickTradingEnabled } = useOneClickTradingSession(); + const [isSendingTx, setIsSendingTx] = useState(false); + + const [_, setType] = useQueryState("type"); + + useMount(() => { + setType(null); + }); + + const account = accountStore.getWallet(chainId); + const slippageConfig = useSlippageConfig(); + + const swapState = useSwap({ + initialFromDenom: initialSendTokenDenom, + initialToDenom: initialOutTokenDenom, + useOtherCurrencies, + useQueryParams, + forceSwapInPoolId, + maxSlippage: slippageConfig.slippage.toDec(), + }); + + if (swapState.fromAsset?.coinDenom === swapState.toAsset?.coinDenom) { + if (swapState.toAsset?.coinDenom === "OSMO") { + swapState.setToAssetDenom("USDC"); + } else { + swapState.setToAssetDenom("OSMO"); + } + } + + // auto focus from amount on token switch + const fromAmountInputEl = useRef(null); + + const outputDifference = new RatePretty( + swapState.inAmountInput?.fiatValue + ?.toDec() + .sub(swapState.tokenOutFiatValue?.toDec()) + .quo(swapState.inAmountInput?.fiatValue?.toDec()) ?? new Dec(0) + ); + + const showOutputDifferenceWarning = outputDifference + .toDec() + .abs() + .gt(new Dec(0.05)); + + const showPriceImpactWarning = + swapState.quote?.priceImpactTokenOut?.toDec().abs().gt(new Dec(0.05)) ?? + false; + + // token select dropdown + const [showFromTokenSelectModal, setFromTokenSelectDropdownLocal] = + useState(false); + const [sellOpen, setSellOpen] = useQueryState( + "sellOpen", + parseAsBoolean.withDefault(false) + ); + + const [buyOpen, setBuyOpen] = useQueryState( + "buyOpen", + parseAsBoolean.withDefault(false) + ); + + const [showToTokenSelectModal, setToTokenSelectDropdownLocal] = + useState(false); + const setOneTokenSelectOpen = useCallback((dropdown: "to" | "from") => { + if (dropdown === "to") { + setToTokenSelectDropdownLocal(true); + setFromTokenSelectDropdownLocal(false); + } else { + setFromTokenSelectDropdownLocal(true); + setToTokenSelectDropdownLocal(false); + } + }, []); + const closeTokenSelectModals = useCallback(() => { + setFromTokenSelectDropdownLocal(false); + setToTokenSelectDropdownLocal(false); + setSellOpen(false); + setBuyOpen(false); + }, [setBuyOpen, setSellOpen]); + + const { outAmountLessSlippage, outFiatAmountLessSlippage } = useMemo(() => { + // Compute ratio of 1 - slippage + const oneMinusSlippage = new Dec(1).sub(slippageConfig.slippage.toDec()); + + // Compute out amount less slippage + const outAmountLessSlippage = + swapState.quote && swapState.toAsset + ? new IntPretty(swapState.quote.amount.toDec().mul(oneMinusSlippage)) + : undefined; + + // Compute out fiat amount less slippage + const outFiatAmountLessSlippage = swapState.tokenOutFiatValue + ? new PricePretty( + DEFAULT_VS_CURRENCY, + swapState.tokenOutFiatValue?.toDec().mul(oneMinusSlippage) + ) + : undefined; + + return { outAmountLessSlippage, outFiatAmountLessSlippage }; + }, [ + slippageConfig.slippage, + swapState.quote, + swapState.toAsset, + swapState.tokenOutFiatValue, + ]); + + // reivew swap modal + const [showSwapReviewModal, setShowSwapReviewModal] = useState(false); + + // user action + const sendSwapTx = () => { + if (!swapState.inAmountInput.amount) return; + + const baseEvent = { + fromToken: swapState.fromAsset?.coinDenom, + tokenAmount: Number(swapState.inAmountInput.amount.toDec().toString()), + toToken: swapState.toAsset?.coinDenom, + isOnHome: page === "Swap Page", + isMultiHop: swapState.quote?.split.some( + ({ pools }) => pools.length !== 1 + ), + isMultiRoute: (swapState.quote?.split.length ?? 0) > 1, + valueUsd: Number( + swapState.inAmountInput.fiatValue?.toDec().toString() ?? "0" + ), + feeValueUsd: Number(swapState.totalFee?.toString() ?? "0"), + page, + quoteTimeMilliseconds: swapState.quote?.timeMs, + router: swapState.quote?.name, + }; + logEvent([EventName.Swap.swapStarted, baseEvent]); + setIsSendingTx(true); + swapState + .sendTradeTokenInTx() + .then((result) => { + // onFullfill + logEvent([ + EventName.Swap.swapCompleted, + { + ...baseEvent, + isMultiHop: result === "multihop", + }, + ]); + + if (swapState.toAsset && swapState.fromAsset) { + onSwapSuccess?.({ + outTokenDenom: swapState.toAsset.coinDenom, + sendTokenDenom: swapState.fromAsset.coinDenom, + }); + } + }) + .catch((error) => { + console.error("swap failed", error); + if (error instanceof Error && error.message === "Request rejected") { + // don't log when the user rejects in wallet + return; + } + logEvent([EventName.Swap.swapFailed, baseEvent]); + }) + .finally(() => { + setIsSendingTx(false); + onRequestModalClose?.(); + setShowSwapReviewModal(false); + }); + }; + + const isSwapToolLoading = isWalletLoading || swapState.isQuoteLoading; + + let buttonText: string; + if (swapState.error) { + buttonText = t(...tError(swapState.error)); + } else if (showPriceImpactWarning) { + buttonText = t("swap.buttonError"); + } else if (swapState.hasOverSpendLimitError) { + buttonText = t("swap.continueAnyway"); + } else { + buttonText = t("swap.button"); + } + + let warningText: string | ReactNode; + if (swapState.hasOverSpendLimitError) { + warningText = ( + + {t("swap.warning.exceedsSpendLimit")}{" "} + + + ); + } + + const isLoadingMaxButton = + featureFlags.swapToolSimulateFee && + !isNil(account?.address) && + !swapState.inAmountInput.hasErrorWithCurrentBalanceQuote && + !swapState.inAmountInput?.balance?.toDec().isZero() && + swapState.inAmountInput.isLoadingCurrentBalanceNetworkFee; + + const isConfirmationDisabled = + isSendingTx || + isWalletLoading || + (account?.walletStatus === WalletStatus.Connected && + (swapState.inAmountInput.isEmpty || + !Boolean(swapState.quote) || + isSwapToolLoading || + Boolean(swapState.error) || + Boolean(swapState.networkFeeError))); + + const showTokenSelectRecommendedTokens = isNil(forceSwapInPoolId); + + const { + isOpen: isAddFundsModalOpen, + onClose: closeAddFundsModal, + onOpen: openAddFundsModal, + } = useDisclosure(); + + const [containerRef, { width }] = useMeasure(); + + return ( + <> +
+
+
+ + + + + From + + + swapState.inAmountInput.toggleMax()} + availableBalance={ + swapState.inAmountInput.balance && + formatPretty( + swapState.inAmountInput.balance.toDec(), + swapState.inAmountInput.balance.toDec().gt(new Dec(0)) + ? { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + } + : undefined + ) + } + showAddFundsButton={ + !account?.isWalletConnected || + (swapState.inAmountInput.balance && + swapState.inAmountInput.balance.toDec().isZero()) + } + openAddFundsModal={openAddFundsModal} + isLoadingMaxButton={isLoadingMaxButton} + isMaxButtonDisabled={ + !swapState.inAmountInput.balance || + swapState.inAmountInput.balance.toDec().isZero() || + swapState.inAmountInput.notEnoughBalanceForMax || + isLoadingMaxButton + } + /> + +
+ { + e.preventDefault(); + if (e.target.value.length <= (isMobile ? 19 : 26)) { + swapState.inAmountInput.setAmount(e.target.value); + } + }} + /> + + showTokenSelectRecommendedTokens && + setOneTokenSelectOpen("from") + } + /> +
+ + + {formatPretty( + swapState.inAmountInput?.fiatValue ?? + new PricePretty(DEFAULT_VS_CURRENCY, new Dec(0)), + swapState.inAmountInput?.fiatValue?.toDec() && { + ...getPriceExtendedFormatOptions( + swapState.inAmountInput?.fiatValue?.toDec() + ), + } + )} + + +
+
+
+ +
+ + + + + {t("assets.transfer.to")} + + + +
+ + {swapState.quote?.amount + ? formatPretty(swapState.quote.amount.toDec(), { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + }) + : "0"} + + } + /> + + showTokenSelectRecommendedTokens && + setOneTokenSelectOpen("to") + } + /> +
+ + + {swapState.tokenOutFiatValue ? ( + + {formatPretty( + swapState.tokenOutFiatValue, + swapState.tokenOutFiatValue?.toDec().gt(new Dec(0)) + ? { + ...getPriceExtendedFormatOptions( + swapState.tokenOutFiatValue.toDec() + ), + } + : undefined + )} + {` (-${outputDifference})`} + + ) : ( + "" + )} + + +
+
+
+ {!isNil(warningText) && ( +
+ {warningText} +
+ )} + {swapButton ?? ( +
+ +
+ )} + +
+ { + // If the selected token is the same as the current "to" token, switch the assets + if (tokenDenom === swapState.toAsset?.coinDenom) { + swapState.switchAssets(); + } else { + swapState.setFromAssetDenom(tokenDenom); + } + + closeTokenSelectModals(); + fromAmountInputEl.current?.focus(); + }, + [swapState, closeTokenSelectModals] + )} + showRecommendedTokens={showTokenSelectRecommendedTokens} + setAssetQueryInput={swapState.setAssetsQueryInput} + assetQueryInput={swapState.assetsQueryInput} + fetchNextPageAssets={swapState.fetchNextPageAssets} + hasNextPageAssets={swapState.hasNextPageAssets} + isFetchingNextPageAssets={swapState.isFetchingNextPageAssets} + isLoadingSelectAssets={swapState.isLoadingSelectAssets} + /> + { + // If the selected token is the same as the current "from" token, switch the assets + if (tokenDenom === swapState.fromAsset?.coinDenom) { + swapState.switchAssets(); + } else { + swapState.setToAssetDenom(tokenDenom); + } + + closeTokenSelectModals(); + }, + [swapState, closeTokenSelectModals] + )} + showRecommendedTokens={showTokenSelectRecommendedTokens} + hideBalances + setAssetQueryInput={swapState.setAssetsQueryInput} + assetQueryInput={swapState.assetsQueryInput} + fetchNextPageAssets={swapState.fetchNextPageAssets} + hasNextPageAssets={swapState.hasNextPageAssets} + isFetchingNextPageAssets={swapState.isFetchingNextPageAssets} + isLoadingSelectAssets={swapState.isLoadingSelectAssets} + /> + setShowSwapReviewModal(false)} + confirmAction={sendSwapTx} + isConfirmationDisabled={isConfirmationDisabled} + slippageConfig={slippageConfig} + outAmountLessSlippage={outAmountLessSlippage} + outFiatAmountLessSlippage={outFiatAmountLessSlippage} + outputDifference={outputDifference} + showOutputDifferenceWarning={showOutputDifferenceWarning} + fromAsset={swapState.fromAsset} + toAsset={swapState.toAsset} + inAmountToken={swapState.inAmountInput.amount} + inAmountFiat={swapState.inAmountInput.fiatValue} + expectedOutput={swapState.quote?.amount} + expectedOutputFiat={swapState.tokenOutFiatValue} + gasAmount={swapState.networkFee?.gasUsdValueToPay} + isGasLoading={swapState.isLoadingNetworkFee} + gasError={swapState.networkFeeError} + /> + { + closeAddFundsModal(); + onRequestModalClose?.(); + }} + from="swap" + fromAsset={swapState.fromAsset} + setFromAssetDenom={swapState.setFromAssetDenom} + setToAssetDenom={swapState.setToAssetDenom} + standalone={!showTokenSelectRecommendedTokens} + /> + + ); + } +); diff --git a/packages/web/components/swap-tool/index.tsx b/packages/web/components/swap-tool/index.tsx index c8c6c4a18e..f6fe961807 100644 --- a/packages/web/components/swap-tool/index.tsx +++ b/packages/web/components/swap-tool/index.tsx @@ -5,13 +5,14 @@ import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; import { ellipsisText, isNil } from "@osmosis-labs/utils"; import classNames from "classnames"; import { observer } from "mobx-react-lite"; -import { ReactNode, useMemo } from "react"; import { Fragment, FunctionComponent, MouseEvent, + ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -28,11 +29,12 @@ import { SplitRoute } from "~/components/swap-tool/split-route"; import { InfoTooltip, Tooltip } from "~/components/tooltip"; import { Button } from "~/components/ui/button"; import { EventName, EventPage } from "~/config"; -import { useFeatureFlags, useTranslation } from "~/hooks"; import { useAmplitudeAnalytics, useDisclosure, + useFeatureFlags, useSlippageConfig, + useTranslation, useWalletSelect, useWindowSize, } from "~/hooks"; diff --git a/packages/web/components/swap-tool/order-type-selector.tsx b/packages/web/components/swap-tool/order-type-selector.tsx new file mode 100644 index 0000000000..7c44a74dd9 --- /dev/null +++ b/packages/web/components/swap-tool/order-type-selector.tsx @@ -0,0 +1,125 @@ +import classNames from "classnames"; +import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs"; +import React, { useEffect, useMemo } from "react"; + +import { GenericDisclaimer } from "~/components/tooltip/generic-disclaimer"; +import { EventName } from "~/config"; +import { useAmplitudeAnalytics, useTranslation } from "~/hooks"; +import { useOrderbookSelectableDenoms } from "~/hooks/limit-orders/use-orderbook"; + +interface UITradeType { + id: "market" | "limit"; + title: string; + disabled: boolean; +} + +export const TRADE_TYPES = ["market", "limit"] as const; + +export const OrderTypeSelector = () => { + const { t } = useTranslation(); + const { logEvent } = useAmplitudeAnalytics(); + + const [type, setType] = useQueryState( + "type", + parseAsStringLiteral(TRADE_TYPES).withDefault("market") + ); + const [base] = useQueryState("from", parseAsString.withDefault("ATOM")); + const [quote, setQuote] = useQueryState( + "quote", + parseAsString.withDefault("USDC") + ); + + const { selectableBaseAssets, selectableQuoteDenoms } = + useOrderbookSelectableDenoms(); + + const hasOrderbook = useMemo( + () => selectableBaseAssets.some((asset) => asset.coinDenom === base), + [base, selectableBaseAssets] + ); + + const selectableQuotes = useMemo(() => { + return selectableQuoteDenoms[base] ?? []; + }, [base, selectableQuoteDenoms]); + + useEffect(() => { + if (type === "limit" && !hasOrderbook) { + setType("market"); + } else if ( + type === "limit" && + !selectableQuotes.some((asset) => asset.coinDenom === quote) && + selectableQuotes.length > 0 + ) { + setQuote(selectableQuotes[0].coinDenom); + } + }, [hasOrderbook, setType, type, selectableQuotes, setQuote, quote]); + + useEffect(() => { + switch (type) { + case "market": + logEvent([EventName.LimitOrder.marketOrderSelected]); + break; + case "limit": + logEvent([EventName.LimitOrder.limitOrderSelected]); + break; + } + /** + * Dependencies are disabled for this hook as we only want to emit + * events when the user changes order types. + */ + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type]); + + const uiTradeTypes: UITradeType[] = useMemo( + () => [ + { + id: "market", + title: t("limitOrders.market"), + disabled: false, + }, + { + id: "limit", + title: t("limitOrders.limit"), + disabled: !hasOrderbook, + }, + ], + [hasOrderbook, t] + ); + + return ( +
+ {uiTradeTypes.map(({ disabled, id, title }) => { + const isSelected = type === id; + + return ( + + + + ); + })} +
+ ); +}; diff --git a/packages/web/components/swap-tool/price-selector.tsx b/packages/web/components/swap-tool/price-selector.tsx new file mode 100644 index 0000000000..4d9c8256db --- /dev/null +++ b/packages/web/components/swap-tool/price-selector.tsx @@ -0,0 +1,490 @@ +import { Menu, Transition } from "@headlessui/react"; +import { Dec, PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY, MaybeUserAssetCoin } from "@osmosis-labs/server"; +import { Asset } from "@osmosis-labs/types"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { parseAsBoolean, parseAsString, useQueryState } from "nuqs"; +import React, { Fragment, memo, useEffect, useMemo } from "react"; + +import { Icon } from "~/components/assets"; +import { + AssetLists, + MainnetAssetSymbols, +} from "~/config/generated/asset-lists"; +import { useDisclosure, useTranslation } from "~/hooks"; +import { useOrderbookSelectableDenoms } from "~/hooks/limit-orders/use-orderbook"; +import { AddFundsModal } from "~/modals/add-funds"; +import { useStore } from "~/stores"; +import { formatFiatPrice } from "~/utils/formatter"; +import { api } from "~/utils/trpc"; + +type AssetWithBalance = Asset & MaybeUserAssetCoin; + +const UI_DEFAULT_QUOTES: MainnetAssetSymbols[] = ["USDC", "USDT"]; + +const VALID_QUOTES: MainnetAssetSymbols[] = [ + ...UI_DEFAULT_QUOTES, + "USDC.sol.wh", + "USDC.eth.grv", + "USDC.eth.wh", + "USDC.matic.axl", + "USDC.avax.axl", + "USDC.eth.axl", + "USDT.eth.grv", + "USDT.eth.wh", + "USDT.kava", + "USDT.eth.pica", + "USDT.sol.pica", +]; + +function sortByAmount( + assetA?: MaybeUserAssetCoin, + assetB?: MaybeUserAssetCoin +) { + return (assetA?.amount?.toDec() ?? new Dec(0)).gt( + assetB?.amount?.toDec() ?? new Dec(0) + ) + ? -1 + : 1; +} + +export const PriceSelector = memo(() => { + const { t } = useTranslation(); + + const [tab, setTab] = useQueryState("tab"); + const [quote] = useQueryState("quote", parseAsString.withDefault("USDC")); + const [base, setBase] = useQueryState( + "from", + parseAsString.withDefault("OSMO") + ); + const [_, setSellOpen] = useQueryState( + "sellOpen", + parseAsBoolean.withDefault(false) + ); + + const [__, setBuyOpen] = useQueryState( + "buyOpen", + parseAsBoolean.withDefault(false) + ); + + const { selectableQuoteDenoms } = useOrderbookSelectableDenoms(); + + const quoteAsset = useMemo( + () => + getAssetFromAssetList({ assetLists: AssetLists, symbol: quote }) + ?.rawAsset as Asset | undefined, + [quote] + ); + + useEffect(() => { + if (quote === base) { + setBase("OSMO"); + } + }, [base, quote, setBase]); + + const { accountStore } = useStore(); + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + + const defaultQuotes = useMemo( + () => + UI_DEFAULT_QUOTES.map( + (symbol) => + getAssetFromAssetList({ + assetLists: AssetLists, + symbol, + })?.rawAsset + ).filter(Boolean) as Asset[], + [] + ); + + const { data: userQuotes } = api.edge.assets.getUserAssets.useQuery( + { userOsmoAddress: wallet?.address }, + { + enabled: !!wallet?.address, + select: (data) => + data.items + .map((walletAsset) => { + if ( + !(tab === "sell" ? UI_DEFAULT_QUOTES : VALID_QUOTES).includes( + walletAsset.coinDenom as MainnetAssetSymbols + ) + ) { + return undefined; + } + + const asset = getAssetFromAssetList({ + assetLists: AssetLists, + symbol: walletAsset.coinDenom, + }); + + // Extrapolate the rawAsset and return the amount and usdValue + const returnAsset: AssetWithBalance = { + ...asset!.rawAsset, + amount: walletAsset.amount, + }; + // In the future, we might want to pass every coin instead of just stables. + return asset?.rawAsset.categories.includes("stablecoin") + ? returnAsset + : undefined; + }) + .filter(Boolean) + .toSorted(sortByAmount) + .toSorted((assetA) => { + const isAssetAAvailable = selectableQuoteDenoms[base]?.some( + (asset) => asset.coinDenom === assetA?.symbol + ); + + return isAssetAAvailable ? -1 : 1; + }) as AssetWithBalance[], + } + ); + + const userQuotesWithoutBalances = useMemo( + () => + (userQuotes ?? []) + .map(({ amount, usdValue, ...props }) => ({ ...props })) + .filter(Boolean) as Asset[], + [userQuotes] + ); + + /** + * Stablecoin balances or Add funds CTA not shown in Sell trade mode. + * Sell trades limited to canonical USDC and alloyed USDT. + */ + const defaultQuotesWithBalances = useMemo( + () => + userQuotes?.filter(({ amount, symbol }) => { + if (UI_DEFAULT_QUOTES.includes(symbol as MainnetAssetSymbols)) + return true; + return amount?.toDec().gt(new Dec(0)) ?? false; + }) ?? [], + [userQuotes] + ); + + const selectableQuotes = useMemo(() => { + return wallet?.isWalletConnected + ? tab === "sell" + ? userQuotesWithoutBalances + : defaultQuotesWithBalances + : defaultQuotes; + }, [ + defaultQuotes, + defaultQuotesWithBalances, + tab, + userQuotesWithoutBalances, + wallet?.isWalletConnected, + ]); + + const { + isOpen: isAddFundsModalOpen, + onClose: closeAddFundsModal, + onOpen: openAddFundsModal, + } = useDisclosure(); + + return ( + <> + + {({ open }) => ( + <> + +
+ {quoteAsset && ( +
+ + {tab === "buy" + ? t("limitOrders.payWith") + : t("limitOrders.receive")} + +
+ {quoteAsset.logoURIs && ( +
+ {`${quoteAsset.symbol} +
+ )} + + {quoteAsset.symbol} + + +
+
+ )} +
+
+ + +
+ +
+
+ {wallet?.isWalletConnected && tab === "buy" && ( + + )} + +
+
+
+ + )} +
+ + + ); +}); + +function HighestBalanceAssetsIcons({ + userOsmoAddress, +}: { + userOsmoAddress: string; +}) { + const { data: userSortedAssets } = api.edge.assets.getUserAssets.useQuery( + { userOsmoAddress }, + { + select: ({ items }) => { + return items.sort(sortByAmount).slice(0, 5).reverse(); + }, + } + ); + + return ( +
+ {userSortedAssets?.map(({ coinImageUrl }, i) => + coinImageUrl ? ( + {coinImageUrl} + ) : null + )} +
+ ); +} + +const SelectableQuotes = observer( + ({ + selectableQuotes = [], + userQuotes = [], + }: { + selectableQuotes?: AssetWithBalance[]; + userQuotes?: AssetWithBalance[]; + }) => { + const { t } = useTranslation(); + const { accountStore } = useStore(); + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + + const [base] = useQueryState("from", parseAsString.withDefault("ATOM")); + const [quote, setQuote] = useQueryState( + "quote", + parseAsString.withDefault("USDC") + ); + const [type] = useQueryState("type", parseAsString.withDefault("market")); + + const { selectableQuoteDenoms } = useOrderbookSelectableDenoms(); + + return selectableQuotes.map(({ symbol, name, logoURIs }) => { + const isSelected = quote === symbol; + const availableBalance = + userQuotes && + (userQuotes.find((u) => u?.symbol === symbol)?.amount?.toDec() ?? + new Dec(0)); + const isDisabled = + type === "limit" && + !selectableQuoteDenoms[base]?.some( + (asset) => asset.coinDenom === symbol + ); + return ( + + {({ active }) => ( + + )} + + ); + }); + } +); diff --git a/packages/web/components/swap-tool/split-route.tsx b/packages/web/components/swap-tool/split-route.tsx index ff9800f334..64f963fb66 100644 --- a/packages/web/components/swap-tool/split-route.tsx +++ b/packages/web/components/swap-tool/split-route.tsx @@ -10,8 +10,7 @@ import { FunctionComponent, useMemo } from "react"; import { Icon } from "~/components/assets"; import { Tooltip } from "~/components/tooltip"; import { CustomClasses } from "~/components/types"; -import { useTranslation } from "~/hooks"; -import { UseDisclosureReturn, useWindowSize } from "~/hooks"; +import { UseDisclosureReturn, useTranslation, useWindowSize } from "~/hooks"; import { usePreviousWhen } from "~/hooks/use-previous-when"; import { useStore } from "~/stores"; import type { RouterOutputs } from "~/utils/trpc"; @@ -87,7 +86,7 @@ export const SplitRoute: FunctionComponent< ); }; -const RouteLane: FunctionComponent<{ +export const RouteLane: FunctionComponent<{ route: RouteWithPercentage; }> = observer(({ route }) => { const { chainStore } = useStore(); @@ -101,10 +100,10 @@ const RouteLane: FunctionComponent<{ if (!sendCurrency || !lastOutCurrency) return null; return ( -
-
+
+
{route.percentage && ( - + {route.percentage.inequalitySymbol(false).maxDecimals(0).toString()} )} @@ -155,7 +154,7 @@ const Pools: FunctionComponent = observer(({ pools }) => { moveTransition="transform 0.4s cubic-bezier(0.7, -0.4, 0.4, 1.4)" content="" /> -
+
{pools.map( ( { @@ -249,10 +248,7 @@ const Pools: FunctionComponent = observer(({ pools }) => { } > diff --git a/packages/web/components/swap-tool/swap-tool-tabs.tsx b/packages/web/components/swap-tool/swap-tool-tabs.tsx new file mode 100644 index 0000000000..9a06afe65a --- /dev/null +++ b/packages/web/components/swap-tool/swap-tool-tabs.tsx @@ -0,0 +1,74 @@ +import classNames from "classnames"; +import { FunctionComponent, useMemo } from "react"; + +import { useTranslation } from "~/hooks"; + +export enum SwapToolTab { + SWAP = "swap", + BUY = "buy", + SELL = "sell", +} + +export interface SwapToolTabsProps { + setTab: (tab: SwapToolTab) => void; + activeTab: SwapToolTab; +} + +/** + * Component for swapping between tabs on the swap modal. + * Has three tabs: + * - Buy + * - Sell + * - Swap + */ +export const SwapToolTabs: FunctionComponent = ({ + setTab, + activeTab, +}) => { + const { t } = useTranslation(); + + const tabs = useMemo( + () => [ + { + label: t("portfolio.buy"), + value: SwapToolTab.BUY, + }, + { + label: t("limitOrders.sell"), + value: SwapToolTab.SELL, + }, + { + label: t("swap.title"), + value: SwapToolTab.SWAP, + }, + ], + [t] + ); + + return ( +
+ {tabs.map((tab) => { + const isActive = activeTab === tab.value; + return ( + + ); + })} +
+ ); +}; diff --git a/packages/web/components/swap-tool/trade-details.tsx b/packages/web/components/swap-tool/trade-details.tsx new file mode 100644 index 0000000000..e444e583dc --- /dev/null +++ b/packages/web/components/swap-tool/trade-details.tsx @@ -0,0 +1,501 @@ +import { Disclosure } from "@headlessui/react"; +import { Dec, IntPretty, PricePretty, RatePretty } from "@keplr-wallet/unit"; +import { EmptyAmountError } from "@osmosis-labs/keplr-hooks"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import { useEffect, useMemo, useState } from "react"; +import { useMeasure } from "react-use"; + +import { Icon } from "~/components/assets/icon"; +import { SkeletonLoader, Spinner } from "~/components/loaders"; +import { RouteLane } from "~/components/swap-tool/split-route"; +import { GenericDisclaimer } from "~/components/tooltip/generic-disclaimer"; +import { RecapRow } from "~/components/ui/recap-row"; +import { + useDisclosure, + UseDisclosureReturn, + usePreviousWhen, + useSlippageConfig, + useTranslation, +} from "~/hooks"; +import { useSwap } from "~/hooks/use-swap"; +import { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; +import { RouterOutputs } from "~/utils/trpc"; + +interface TradeDetailsProps { + swapState: ReturnType; + slippageConfig: ReturnType; + type: "limit" | "market"; + outAmountLessSlippage?: IntPretty; + outFiatAmountLessSlippage?: PricePretty; + inPriceFetching?: boolean; + treatAsStable?: string; + makerFee?: Dec; + tab?: "buy" | "sell"; +} + +export const TradeDetails = observer( + ({ + swapState, + inPriceFetching, + treatAsStable, + type, + makerFee, + tab, + }: Partial) => { + const { t } = useTranslation(); + const routesVisDisclosure = useDisclosure(); + + const [outAsBase, setOutAsBase] = useState(!tab || tab === "buy"); + + const [details, { height: detailsHeight }] = useMeasure(); + + const isInAmountEmpty = + swapState?.inAmountInput.error instanceof EmptyAmountError; + + const isLoading = + type === "market" && + swapState?.isQuoteLoading && + !Boolean(swapState?.error); + + const priceImpact = swapState?.quote?.priceImpactTokenOut; + + const isPriceImpactHigh = useMemo( + () => priceImpact?.toDec().abs().gt(new Dec(0.1)), + [priceImpact] + ); + + const limitTotalFees = useMemo(() => { + if (!makerFee || makerFee.isZero()) return; + return formatPretty((makerFee ?? new Dec(0)).mul(new Dec(100)), { + maxDecimals: 2, + minimumFractionDigits: 2, + }); + }, [makerFee]); + + return ( +
+ + {({ open, close }) => ( +
+
+ +
+ + +
+ {isLoading && ( + + )} + setOutAsBase(!outAsBase)} + className={classNames("body2 text-osmoverse-300", { + "animate-pulse": inPriceFetching || isLoading, + })} + > + {swapState?.inBaseOutQuoteSpotPrice && + ExpectedRate(swapState, outAsBase, treatAsStable)} + +
+
+
+ + +
+ {isPriceImpactHigh && !open && ( + + )} + + {open ? t("swap.hideDetails") : t("swap.showDetails")} + +
+
+
+
+ + {type === "market" ? ( + + {t("assets.transfer.priceImpact")} + + } + right={ + +
+ {isPriceImpactHigh && ( + + )} + + -{formatPretty(priceImpact ?? new Dec(0))} + +
+
+ } + /> + ) : ( + {t("limitOrders.tradeFees")}} + right={ + !limitTotalFees ? ( + + {t("transfer.free")} + + ) : ( + {limitTotalFees} + ) + } + /> + )} + {type === "market" && ( + + {t("pools.aprBreakdown.swapFees")} + + } + right={ + <> + {swapState?.tokenInFeeAmountFiatValue && ( + <> + {swapState?.tokenInFeeAmountFiatValue + .toDec() + .gt(new Dec(0)) ? ( + + + {swapState?.tokenInFeeAmountFiatValue + .toDec() + .lte(new Dec(0.01)) ? ( + <><$0.01 + ) : ( + <> + ~ + {formatPretty( + swapState?.tokenInFeeAmountFiatValue, + { + maxDecimals: 3, + maximumSignificantDigits: 4, + } + )} + + )} + + + {swapState?.quote?.swapFee + ? ` (${swapState?.quote?.swapFee})` + : ""} + + + ) : ( + + {t("transfer.free")} + + )} + + )} + + } + /> + )} + {type === "market" && ( + + {({ open }) => { + const routes = swapState?.quote?.split; + + return ( + <> + + + If there’s no direct market between the + assets you’re trading, Osmosis will try to + make the trade happen by making a series of + trades with other assets to get the best + price at any given time. +
+
+ For optimal efficiency based on available + liquidity, sometimes trades will be split + into multiple routes with different assets. + + } + > + + {t("swap.autoRouter")} + +
+
+ + {!!routes && routes.length > 0 ? ( + <> + {routes?.length}{" "} + {routes?.length === 1 + ? t("swap.route") + : t("swap.routes")} + + ) : ( + + {t("swap.noRoutes")} + + )} + + {!!routes && routes.length > 0 && ( + + )} +
+
+ + + + + ); + }} +
+ )} +
+
+
+ )} +
+
+ ); + } +); + +export function Closer({ + close, + isInAmountEmpty, +}: { + isInAmountEmpty: boolean; + close: () => void; +}) { + useEffect(() => { + if (isInAmountEmpty) { + close(); + } + }, [close, isInAmountEmpty]); + + return <>; +} + +export function ExpectedRate( + swapState: ReturnType, + outAsBase: boolean, + treatAsStable: string | undefined = undefined +) { + var inBaseOutQuoteSpotPrice = + swapState?.inBaseOutQuoteSpotPrice?.toDec() ?? new Dec(1); + if (inBaseOutQuoteSpotPrice.isZero()) { + console.warn("ExpectedRate: inBaseOutQuoteSpotPrice is Zero"); + return; + } + + let baseAsset, quoteAsset, inQuoteAssetPrice; + let inFiatPrice = new PricePretty(DEFAULT_VS_CURRENCY, new Dec(0)); + + if (treatAsStable && treatAsStable == "in") { + baseAsset = swapState.toAsset?.coinDenom; + inQuoteAssetPrice = new Dec(1).quo(inBaseOutQuoteSpotPrice); + + return ( + + 1 {baseAsset} ≈{" $"} + {formatPretty(inQuoteAssetPrice, { + ...getPriceExtendedFormatOptions(inQuoteAssetPrice), + })}{" "} + + ); + } + + if (treatAsStable && treatAsStable == "out") { + baseAsset = swapState.fromAsset?.coinDenom; + inQuoteAssetPrice = inBaseOutQuoteSpotPrice; + + return ( + + 1 {baseAsset} ≈{" $"} + {formatPretty(inQuoteAssetPrice, { + ...getPriceExtendedFormatOptions(inQuoteAssetPrice), + })}{" "} + + ); + } + + if (outAsBase) { + baseAsset = swapState.toAsset?.coinDenom; + quoteAsset = swapState.fromAsset?.coinDenom; + + inQuoteAssetPrice = new Dec(1).quo(inBaseOutQuoteSpotPrice); + + if ( + swapState?.tokenOutFiatValue && + swapState?.quote?.amount?.toDec().gt(new Dec(0)) + ) { + inFiatPrice = new PricePretty( + DEFAULT_VS_CURRENCY, + swapState.tokenOutFiatValue.quo(swapState.quote.amount.toDec()) + ); + } else { + if (swapState.inAmountInput?.price) { + inFiatPrice = swapState.inAmountInput?.price?.quo( + inBaseOutQuoteSpotPrice + ); + } + } + } else { + baseAsset = swapState.fromAsset?.coinDenom; + quoteAsset = swapState.toAsset?.coinDenom; + + inQuoteAssetPrice = inBaseOutQuoteSpotPrice; + + if ( + swapState.tokenOutFiatValue && + swapState.inAmountInput?.amount?.toDec().gt(new Dec(0)) + ) { + inFiatPrice = swapState.tokenOutFiatValue.quo( + swapState.inAmountInput.amount.toDec() + ); + } else { + inFiatPrice = + swapState.inAmountInput.price ?? + new PricePretty(DEFAULT_VS_CURRENCY, new Dec(0)); + } + } + + return ( + + 1 {baseAsset} ≈{" "} + {formatPretty(inQuoteAssetPrice, { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + })}{" "} + {quoteAsset} ( + {formatPretty(inFiatPrice, { + ...getPriceExtendedFormatOptions(inFiatPrice.toDec()), + })} + ) + + ); +} + +type Split = + RouterOutputs["local"]["quoteRouter"]["routeTokenOutGivenIn"]["split"]; +type Route = Split[number]; +type RouteWithPercentage = Route & { percentage?: RatePretty }; + +function RoutesTaken({ + split, + isLoading, +}: { split: Split } & Pick & { + isLoading?: boolean; + }) { + // hold on to a ref of the last split to use while we're loading the next one + // this prevents whiplash in the UI + const latestSplitRef = usePreviousWhen(split, (s) => s.length > 0); + + split = isLoading ? latestSplitRef ?? split : split; + + const tokenInTotal = useMemo( + () => + split.reduce( + (sum, { initialAmount }) => sum.add(new Dec(initialAmount)), + new Dec(0) + ), + [split] + ); + + const splitWithPercentages: RouteWithPercentage[] = useMemo(() => { + if (split.length === 1) return split; + + return split.map((route) => { + const percentage = new RatePretty( + new Dec(route.initialAmount).quo(tokenInTotal).mul(new Dec(100)) + ).moveDecimalPointLeft(2); + + return { + ...route, + percentage, + }; + }); + }, [split, tokenInTotal]); + + return ( +
+ {splitWithPercentages.map((route) => ( + id).join()} // pool IDs are unique + route={route} + /> + ))} +
+ ); +} diff --git a/packages/web/components/table/assets-table-v1.tsx b/packages/web/components/table/assets-table-v1.tsx index 5ccab79931..0e4db0b78e 100644 --- a/packages/web/components/table/assets-table-v1.tsx +++ b/packages/web/components/table/assets-table-v1.tsx @@ -1,5 +1,6 @@ import { CoinPretty, Dec, PricePretty } from "@keplr-wallet/unit"; import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import classNames from "classnames"; import { observer } from "mobx-react-lite"; import Image from "next/image"; import Link from "next/link"; @@ -650,7 +651,9 @@ export const AssetsTableV1: FunctionComponent = observer(
) : ( - className="my-5 w-full" + className={classNames("my-5 w-full", { + "[&>thead>tr]:!bg-osmoverse-1000": featureFlags.limitOrders, + })} columnDefs={[ { display: t("assets.table.columns.assetChain"), diff --git a/packages/web/components/tooltip/generic-disclaimer.tsx b/packages/web/components/tooltip/generic-disclaimer.tsx new file mode 100644 index 0000000000..92047be61e --- /dev/null +++ b/packages/web/components/tooltip/generic-disclaimer.tsx @@ -0,0 +1,40 @@ +import classNames from "classnames"; +import { PropsWithChildren, ReactNode } from "react"; + +import { Tooltip } from "~/components/tooltip"; + +export function GenericDisclaimer({ + body, + children, + title, + disabled, + containerClassName, +}: PropsWithChildren< + Partial<{ + title: ReactNode; + body: ReactNode; + disabled: boolean; + containerClassName: string; + }> +>) { + return ( + +
+ {title && {title}} + {body && {body}} +
+
+ } + enablePropagation + > +
{children}
+ + ); +} diff --git a/packages/web/components/trade-tool/index.tsx b/packages/web/components/trade-tool/index.tsx new file mode 100644 index 0000000000..b6b8c32620 --- /dev/null +++ b/packages/web/components/trade-tool/index.tsx @@ -0,0 +1,138 @@ +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { parseAsStringEnum, useQueryState } from "nuqs"; +import { FunctionComponent, useEffect, useMemo } from "react"; + +import { Icon } from "~/components/assets"; +import { ClientOnly } from "~/components/client-only"; +import { PlaceLimitTool } from "~/components/place-limit-tool"; +import type { SwapToolProps } from "~/components/swap-tool"; +import { AltSwapTool } from "~/components/swap-tool/alt"; +import { OrderTypeSelector } from "~/components/swap-tool/order-type-selector"; +import { + SwapToolTab, + SwapToolTabs, +} from "~/components/swap-tool/swap-tool-tabs"; +import { EventName, EventPage } from "~/config"; +import { useAmplitudeAnalytics, useTranslation } from "~/hooks"; +import { useOrderbookAllActiveOrders } from "~/hooks/limit-orders/use-orderbook"; +import { useStore } from "~/stores"; + +export interface TradeToolProps { + swapToolProps?: SwapToolProps; + page: EventPage; +} + +export const TradeTool: FunctionComponent = observer( + ({ page, swapToolProps }) => { + const { logEvent } = useAmplitudeAnalytics(); + const { t } = useTranslation(); + const [tab, setTab] = useQueryState( + "tab", + parseAsStringEnum(Object.values(SwapToolTab)).withDefault( + SwapToolTab.SWAP + ) + ); + + const { accountStore } = useStore(); + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + + const { orders, refetch } = useOrderbookAllActiveOrders({ + userAddress: wallet?.address ?? "", + pageSize: 10, + refetchInterval: 4000, + }); + + useEffect(() => { + switch (tab) { + case SwapToolTab.BUY: + logEvent([EventName.LimitOrder.buySelected]); + break; + case SwapToolTab.SELL: + logEvent([EventName.LimitOrder.sellSelected]); + break; + case SwapToolTab.SWAP: + logEvent([EventName.LimitOrder.swapSelected]); + break; + } + + /** + * Dependencies are disabled for this hook as we only want to emit + * events when the user changes tabs. + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab]); + return ( + +
+
+ +
+ {tab !== SwapToolTab.SWAP && } +
+
+ {useMemo(() => { + switch (tab) { + case SwapToolTab.BUY: + return ( + + ); + case SwapToolTab.SELL: + return ( + + ); + case SwapToolTab.SWAP: + default: + return ( + + ); + } + }, [page, swapToolProps, tab, refetch])} +
+ {wallet?.isWalletConnected && orders.length > 0 && ( + +
+
+ +
+ + {t("limitOrders.orderHistory")} + +
+
+
+ +
+
+ + )} +
+ ); + } +); diff --git a/packages/web/components/transactions/transaction-buttons.tsx b/packages/web/components/transactions/transaction-buttons.tsx index 318a0338ba..549d7c7dab 100644 --- a/packages/web/components/transactions/transaction-buttons.tsx +++ b/packages/web/components/transactions/transaction-buttons.tsx @@ -1,152 +1,74 @@ -import { Transition } from "@headlessui/react"; +import { Popover, Transition } from "@headlessui/react"; import classNames from "classnames"; import Link from "next/link"; -import { useState } from "react"; -import { MenuDropdown } from "~/components/control"; -import { Button } from "~/components/ui/button"; import { EventName } from "~/config"; -import { useWindowSize } from "~/hooks"; import { useAmplitudeAnalytics, useTranslation } from "~/hooks"; export const TransactionButtons = ({ - open, address, }: { open: boolean; address: string; }) => { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - - const { isLargeDesktop } = useWindowSize(); - - const { logEvent } = useAmplitudeAnalytics(); - const { t } = useTranslation(); + const { logEvent } = useAmplitudeAnalytics(); const options = [ { id: "explorer", - display: ( - { - logEvent([ - EventName.TransactionsPage.explorerClicked, - { - source: "top", - }, - ]); - }} - > - {t("transactions.explorer")} ↗ - - ), + href: `https://www.mintscan.io/osmosis/address/${address}`, + description: <>{t("transactions.explorer")} ↗, }, { id: "tax-reports", - display: ( - - {t("transactions.taxReports")} ↗ - - ), + href: "https://stake.tax/", + description: <>{t("transactions.taxReports")} ↗, }, ]; return ( -
- {isLargeDesktop && ( - - )} + + + ⋯ + - - - - -
- - option.id !== "explorer") - : options - } - // noop since links are used - onSelect={() => {}} - isFloating - /> -
+ + {options.map(({ id, href, description }, i, original) => ( + { + if (id === "explorer") { + logEvent([ + EventName.TransactionsPage.explorerClicked, + { + source: "top", + }, + ]); + } + }} + className={classNames( + "px-4 py-1.5 transition-colors hover:bg-osmoverse-700", + { + "rounded-t-xl": i === 0, + "rounded-b-xl": i === original.length - 1, + } + )} + > + {description} + + ))} +
-
+ ); }; diff --git a/packages/web/components/transactions/transaction-content.tsx b/packages/web/components/transactions/transaction-content.tsx index 514f6e8320..1dec6abb9c 100644 --- a/packages/web/components/transactions/transaction-content.tsx +++ b/packages/web/components/transactions/transaction-content.tsx @@ -1,3 +1,4 @@ +import { Tab } from "@headlessui/react"; import { FormattedTransaction } from "@osmosis-labs/server"; import { AccountStoreWallet, @@ -5,15 +6,21 @@ import { CosmwasmAccount, OsmosisAccount, } from "@osmosis-labs/stores"; +import classNames from "classnames"; import { useRouter } from "next/router"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; import { BackToTopButton } from "~/components/buttons/back-to-top-button"; +import { ClientOnly } from "~/components/client-only"; +import { OrderHistory } from "~/components/complex/orders-history"; import { Spinner } from "~/components/loaders"; import { NoTransactionsSplash } from "~/components/transactions/no-transactions-splash"; import { TransactionButtons } from "~/components/transactions/transaction-buttons"; import { TransactionsPaginaton } from "~/components/transactions/transaction-pagination"; import { TransactionRows } from "~/components/transactions/transaction-rows"; -import { useTranslation } from "~/hooks"; +import { useFeatureFlags, useTranslation } from "~/hooks"; + +const TX_PAGE_TABS = ["history", "orders"] as const; export const TransactionContent = ({ setSelectedTransactionHash, @@ -41,11 +48,17 @@ export const TransactionContent = ({ wallet?: AccountStoreWallet<[OsmosisAccount, CosmosAccount, CosmwasmAccount]>; }) => { const { t } = useTranslation(); + const featureFlags = useFeatureFlags(); const showPagination = isWalletConnected && !isLoading; const router = useRouter(); + const [tab, setTab] = useQueryState( + "tab", + parseAsStringLiteral(TX_PAGE_TABS).withDefault("history") + ); + const showTransactionContent = wallet && wallet.isWalletConnected && @@ -55,53 +68,90 @@ export const TransactionContent = ({ const showConnectWallet = !isWalletConnected && !isLoading; return ( -
-
-
-

- {t("transactions.title")} -

-

- {t("transactions.launchAlert")} -

+ +
+
+
+

+ {t("transactions.title")} +

+

+ {t("transactions.launchAlert")} +

+
+
- -
-
- {showConnectWallet ? ( - - ) : showTransactionContent ? ( - - ) : isLoading ? ( - - ) : transactions.length === 0 ? ( - - ) : null} -
-
- {showPagination && ( - 0} - showNext={hasNextPage} - previousHref={{ - pathname: router.pathname, - query: { ...router.query, page: Math.max(0, +page - 1) }, - }} - nextHref={{ - pathname: router.pathname, - query: { ...router.query, page: +page + 1 }, - }} - /> - )} + setTab(TX_PAGE_TABS[idx])} + > + {featureFlags.limitOrders && ( + + {TX_PAGE_TABS.map((defaultTab) => ( + +
+ {t(`orderHistory.${defaultTab}`)} +
+
+ ))} +
+ )} + + + <> +
+ {showConnectWallet ? ( + + ) : showTransactionContent ? ( + + ) : isLoading ? ( + + ) : transactions.length === 0 ? ( + + ) : null} +
+
+ {showPagination && ( + 0} + showNext={hasNextPage} + previousHref={{ + pathname: router.pathname, + query: { + ...router.query, + page: Math.max(0, +page - 1), + }, + }} + nextHref={{ + pathname: router.pathname, + query: { ...router.query, page: +page + 1 }, + }} + /> + )} +
+ + +
+ {featureFlags.limitOrders && ( + + + + )} +
+
- -
+ ); }; diff --git a/packages/web/components/ui/progress-bar.tsx b/packages/web/components/ui/progress-bar.tsx new file mode 100644 index 0000000000..b2abc65b86 --- /dev/null +++ b/packages/web/components/ui/progress-bar.tsx @@ -0,0 +1,47 @@ +import cn from "classnames"; +import React from "react"; + +interface ProgressSegment { + percentage: string; + classNames: string; +} + +interface ProgressBarProps { + segments: ProgressSegment[]; + classNames?: string; + totalPercentClassNames?: string; + totalPercent?: string; +} + +export const ProgressBar: React.FC = ({ + segments, + classNames, + totalPercent, + totalPercentClassNames, +}) => { + return ( +
+
+ {segments.map((segment, index) => ( +
+ ))} +
+ {totalPercent && totalPercent.length > 0 && ( + + {totalPercent}% + + )} +
+ ); +}; diff --git a/packages/web/components/ui/recap-row.tsx b/packages/web/components/ui/recap-row.tsx new file mode 100644 index 0000000000..dfd757ff7f --- /dev/null +++ b/packages/web/components/ui/recap-row.tsx @@ -0,0 +1,24 @@ +import classNames from "classnames"; +import { ReactNode } from "react"; + +export function RecapRow({ + left, + right, + className, +}: { + left: ReactNode; + right: ReactNode; + className?: string; +}) { + return ( +
+ {left} + {right} +
+ ); +} diff --git a/packages/web/config/analytics-events.ts b/packages/web/config/analytics-events.ts index 6a450b690f..747fba5c65 100644 --- a/packages/web/config/analytics-events.ts +++ b/packages/web/config/analytics-events.ts @@ -226,6 +226,20 @@ export const EventName = { enableOneClickTrading: "1CT: Enable 1-Click Trading", accessed: "1CT: Accessed", }, + LimitOrder: { + buySelected: "Buy tab selected", + sellSelected: "Sell tab selected", + swapSelected: "Swap tab selected", + marketOrderSelected: "Market Order selected", + limitOrderSelected: "Limit Order selected", + placeOrderStarted: "Limit Order: Place order started", + placeOrderCompleted: "Limit Order: Place order completed", + placeOrderFailed: "Limit Order: Place order failed", + claimOrdersStarted: "Limit Order: Claim all orders started", + claimOrdersCompleted: "Limit Order: Claim all orders completed", + claimOrdersFailed: "Limit Order: Claim all orders failed", + pageViewed: "Limit Order: Order page viewed", + }, DepositWithdraw: { assetSelected: "DepositWithdraw: Asset selected", networkSelected: "DepositWithdraw: Network selected", diff --git a/packages/web/hooks/input/use-amount-input.ts b/packages/web/hooks/input/use-amount-input.ts index 2e6e4414c9..38076ccf9a 100644 --- a/packages/web/hooks/input/use-amount-input.ts +++ b/packages/web/hooks/input/use-amount-input.ts @@ -11,9 +11,7 @@ import { } from "@osmosis-labs/stores"; import { Currency } from "@osmosis-labs/types"; import { isNil } from "@osmosis-labs/utils"; -import { useCallback, useState } from "react"; -import { useMemo } from "react"; -import { useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { mulPrice } from "~/hooks/queries/assets/use-coin-fiat-value"; import { usePrice } from "~/hooks/queries/assets/use-price"; @@ -28,7 +26,7 @@ import { api } from "~/utils/trpc"; */ export function useAmountInput({ currency, - inputDebounceMs = 500, + inputDebounceMs = 200, gasAmount, }: { currency: Currency | undefined; @@ -55,17 +53,18 @@ export function useAmountInput({ const setAmount = useCallback( (amount: string) => { + let updatedAmount = amount.trim(); // check validity of raw input - if (!isValidNumericalRawInput(amount)) return; - if (amount.startsWith(".")) { - amount = "0" + amount; + if (!isValidNumericalRawInput(updatedAmount)) return; + if (updatedAmount.startsWith(".")) { + updatedAmount = "0" + updatedAmount; } if (fraction != null) { setFraction(null); } - setAmount_(amount); + setAmount_(updatedAmount); }, [fraction] ); @@ -203,10 +202,7 @@ export function useAmountInput({ return { inputAmount: inputAmountWithFraction, debouncedInAmount, - isTyping: - debouncedInAmount && amount - ? !amount.toDec().equals(debouncedInAmount.toDec()) - : false, + isTyping: debouncedInAmount?.toString() !== amount?.toString(), amount, balance, fiatValue, diff --git a/packages/web/hooks/limit-orders/index.ts b/packages/web/hooks/limit-orders/index.ts new file mode 100644 index 0000000000..e5bf586d2b --- /dev/null +++ b/packages/web/hooks/limit-orders/index.ts @@ -0,0 +1 @@ +export * from "./use-place-limit"; diff --git a/packages/web/hooks/limit-orders/use-orderbook.ts b/packages/web/hooks/limit-orders/use-orderbook.ts new file mode 100644 index 0000000000..ab8941f9b2 --- /dev/null +++ b/packages/web/hooks/limit-orders/use-orderbook.ts @@ -0,0 +1,396 @@ +import { Dec } from "@keplr-wallet/unit"; +import { CoinPrimitive } from "@osmosis-labs/keplr-stores"; +import { + MappedLimitOrder, + MaybeUserAssetCoin, + Orderbook, +} from "@osmosis-labs/server"; +import { MinimalAsset } from "@osmosis-labs/types"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import { useCallback, useMemo } from "react"; + +import { AssetLists } from "~/config/generated/asset-lists"; +import { useSwapAsset } from "~/hooks/use-swap"; +import { useStore } from "~/stores"; +import { api } from "~/utils/trpc"; + +/** + * Retrieves all available orderbooks for the current chain. + * Fetch is asynchronous so a loading state is returned. + * @returns A state including an orderbooks array and a loading boolean. + */ +export const useOrderbooks = (): { + orderbooks: Orderbook[]; + isLoading: boolean; +} => { + const { data: orderbooks, isLoading } = + api.edge.orderbooks.getPools.useQuery(); + + return { orderbooks: orderbooks ?? [], isLoading }; +}; + +/** + * Retrieves all available base and quote denoms for the current chain. + * Fetch is asynchronous so a loading state is returned. + * @returns A state including an an array of selectable base denom strings, selectable base denom assets, selectable quote assets organised by base assets in the form of an object and a loading boolean. + */ +export const useOrderbookSelectableDenoms = () => { + const { orderbooks, isLoading } = useOrderbooks(); + + const { data: selectableAssetPages } = + api.edge.assets.getUserAssets.useInfiniteQuery( + {}, + { + enabled: true, + getNextPageParam: (lastPage: any) => lastPage.nextCursor, + initialCursor: 0, + } + ); + + // Determine selectable base denoms from orderbooks in the form of denom strings + const selectableBaseDenoms = useMemo(() => { + const selectableDenoms = orderbooks.map((orderbook) => orderbook.baseDenom); + return Array.from(new Set(selectableDenoms)); + }, [orderbooks]); + // Map selectable asset pages to array of assets + const selectableAssets = useMemo(() => { + return selectableAssetPages?.pages.flatMap((page) => page.items) ?? []; + }, [selectableAssetPages]); + + // Map selectable base asset denoms to asset objects + const selectableBaseAssets = useMemo( + () => + selectableBaseDenoms + .map((denom) => { + const existingAsset = selectableAssets.find( + (asset) => asset.coinMinimalDenom === denom + ); + if (existingAsset) { + return existingAsset; + } + const asset = getAssetFromAssetList({ + coinMinimalDenom: denom, + assetLists: AssetLists, + }); + + if (!asset) return; + + return asset; + }) + .filter(Boolean) as (MinimalAsset & MaybeUserAssetCoin)[], + [selectableBaseDenoms, selectableAssets] + ); + // Create mapping between base denom strings and a string of selectable quote asset denom strings + const selectableQuoteDenoms = useMemo(() => { + const quoteDenoms: Record = + {}; + selectableBaseAssets.forEach((asset) => { + quoteDenoms[asset.coinDenom] = orderbooks + .filter((orderbook) => { + return orderbook.baseDenom === asset.coinMinimalDenom; + }) + .map((orderbook) => { + const { quoteDenom } = orderbook; + + const existingAsset = selectableAssets.find( + (asset) => asset.coinMinimalDenom === quoteDenom + ); + + if (existingAsset) { + return existingAsset; + } + + const asset = getAssetFromAssetList({ + coinMinimalDenom: quoteDenom, + assetLists: AssetLists, + }); + if (!asset) return; + + return { ...asset, amount: undefined, usdValue: undefined }; + }) + .filter(Boolean) + .sort((a, b) => + (a?.amount?.toDec() ?? new Dec(0)).gt( + b?.amount?.toDec() ?? new Dec(0) + ) + ? 1 + : -1 + ) as (MinimalAsset & MaybeUserAssetCoin)[]; + }); + return quoteDenoms; + }, [selectableBaseAssets, orderbooks, selectableAssets]); + + return { + selectableBaseDenoms, + selectableQuoteDenoms, + selectableBaseAssets, + isLoading, + }; +}; + +/** + * Retrieves a single orderbook by base and quote denom. + * @param denoms An object including both the base and quote denom + * @returns A state including info about the current orderbook and any orders the user may have on the orderbook + */ +export const useOrderbook = ({ + baseDenom, + quoteDenom, +}: { + baseDenom: string; + quoteDenom: string; +}) => { + const { accountStore } = useStore(); + const { orderbooks, isLoading: isOrderbookLoading } = useOrderbooks(); + const { data: selectableAssetPages } = + api.edge.assets.getUserAssets.useInfiniteQuery( + { + userOsmoAddress: accountStore.getWallet(accountStore.osmosisChainId) + ?.address, + includePreview: false, + limit: 50, // items per page + }, + { + enabled: true, + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: 0, + + // avoid blocking + trpc: { + context: { + skipBatch: true, + }, + }, + } + ); + + const selectableAssets = useMemo( + () => + true + ? selectableAssetPages?.pages.flatMap(({ items }) => items) ?? [] + : [], + [selectableAssetPages?.pages] + ); + const { asset: baseAsset } = useSwapAsset({ + minDenomOrSymbol: baseDenom, + existingAssets: selectableAssets, + }); + const { asset: quoteAsset } = useSwapAsset({ + minDenomOrSymbol: quoteDenom, + existingAssets: selectableAssets, + }); + + const orderbook = useMemo( + () => + orderbooks.find( + (orderbook) => + baseAsset && + quoteAsset && + (orderbook.baseDenom === baseAsset.coinDenom || + orderbook.baseDenom === baseAsset.coinMinimalDenom) && + (orderbook.quoteDenom === quoteAsset.coinDenom || + orderbook.quoteDenom === quoteAsset.coinMinimalDenom) + ), + [orderbooks, baseAsset, quoteAsset] + ); + const { + makerFee, + isLoading: isMakerFeeLoading, + error: makerFeeError, + } = useMakerFee({ + orderbookAddress: orderbook?.contractAddress ?? "", + }); + + const error = useMemo(() => { + if ( + !Boolean(orderbook) || + !Boolean(orderbook!.poolId) || + orderbook!.poolId === "" + ) { + return "errors.noOrderbook"; + } + + if (Boolean(makerFeeError)) { + return makerFeeError?.message; + } + }, [orderbook, makerFeeError]); + + return { + orderbook, + poolId: orderbook?.poolId ?? "", + contractAddress: orderbook?.contractAddress ?? "", + makerFee, + isMakerFeeLoading, + isOrderbookLoading, + error, + }; +}; + +/** + * Hook to fetch the maker fee for a given orderbook. + * + * Queries the maker fee using the orderbook's address. + * If the data is still loading, it returns a default value of Dec(0) for the maker fee. + * Once the data is loaded, it returns the actual maker fee if available, or Dec(0) if not. + * @param {string} orderbookAddress - The contract address of the orderbook. + * @returns {Object} An object containing the maker fee and the loading state. + */ +const useMakerFee = ({ orderbookAddress }: { orderbookAddress: string }) => { + const { + data: makerFeeData, + isLoading, + error, + } = api.edge.orderbooks.getMakerFee.useQuery( + { + osmoAddress: orderbookAddress, + }, + { + enabled: !!orderbookAddress, + } + ); + + const makerFee = useMemo(() => { + if (isLoading) return new Dec(0); + return makerFeeData?.makerFee ?? new Dec(0); + }, [isLoading, makerFeeData]); + + return { + makerFee, + isLoading, + error, + }; +}; + +export type DisplayableLimitOrder = MappedLimitOrder; + +export const useOrderbookAllActiveOrders = ({ + userAddress, + pageSize = 10, + refetchInterval = 2000, +}: { + userAddress: string; + pageSize?: number; + refetchInterval?: number; +}) => { + const { orderbooks } = useOrderbooks(); + const addresses = orderbooks.map(({ contractAddress }) => contractAddress); + const { + data: orders, + isLoading, + fetchNextPage, + isFetching, + isFetchingNextPage, + hasNextPage, + refetch, + isRefetching, + } = api.edge.orderbooks.getAllOrders.useInfiniteQuery( + { + contractAddresses: addresses, + userOsmoAddress: userAddress, + limit: pageSize, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: 0, + refetchInterval, + enabled: !!userAddress && addresses.length > 0, + refetchOnMount: true, + keepPreviousData: true, + trpc: { + abortOnUnmount: true, + context: { + skipBatch: true, + }, + }, + } + ); + + const allOrders = useMemo(() => { + return orders?.pages.flatMap((page) => page.items) ?? []; + }, [orders]); + + const refetchOrders = useCallback(async () => { + if (isRefetching) return; + + return refetch(); + }, [refetch, isRefetching]); + + return { + orders: allOrders, + isLoading, + fetchNextPage, + isFetching, + isFetchingNextPage, + hasNextPage, + refetch: refetchOrders, + isRefetching, + }; +}; + +export const useOrderbookClaimableOrders = ({ + userAddress, + disabled = false, +}: { + userAddress: string; + disabled?: boolean; +}) => { + const { orderbooks } = useOrderbooks(); + const { accountStore } = useStore(); + const account = accountStore.getWallet(accountStore.osmosisChainId); + const addresses = orderbooks.map(({ contractAddress }) => contractAddress); + const { + data: orders, + isLoading, + isFetching, + refetch, + } = api.edge.orderbooks.getClaimableOrders.useQuery( + { + contractAddresses: addresses, + userOsmoAddress: userAddress, + }, + { + enabled: !!userAddress && addresses.length > 0 && !disabled, + refetchInterval: 2000, + refetchOnMount: true, + } + ); + + const claimAllOrders = useCallback(async () => { + if (!account || !orders) return; + const msgs = addresses + .map((contractAddress) => { + const ordersForAddress = orders.filter( + (o) => o.orderbookAddress === contractAddress + ); + if (ordersForAddress.length === 0) return; + + const msg = { + batch_claim: { + orders: ordersForAddress.map((o) => [o.tick_id, o.order_id]), + }, + }; + return { + contractAddress, + msg, + funds: [], + }; + }) + .filter(Boolean) as { + contractAddress: string; + msg: object; + funds: CoinPrimitive[]; + }[]; + + if (msgs.length > 0) { + await account?.cosmwasm.sendMultiExecuteContractMsg("executeWasm", msgs); + await refetch(); + } + }, [orders, account, addresses, refetch]); + + return { + orders: orders ?? [], + count: orders?.length ?? 0, + isLoading: isLoading || isFetching, + claimAllOrders, + }; +}; diff --git a/packages/web/hooks/limit-orders/use-place-limit.ts b/packages/web/hooks/limit-orders/use-place-limit.ts new file mode 100644 index 0000000000..67354b3f06 --- /dev/null +++ b/packages/web/hooks/limit-orders/use-place-limit.ts @@ -0,0 +1,844 @@ +import { CoinPretty, Dec, Int, PricePretty } from "@keplr-wallet/unit"; +import { priceToTick } from "@osmosis-labs/math"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { cosmwasmMsgOpts } from "@osmosis-labs/stores"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { tError } from "~/components/localization"; +import { EventName, EventPage } from "~/config"; +import { + isValidNumericalRawInput, + useAmountInput, +} from "~/hooks/input/use-amount-input"; +import { useOrderbook } from "~/hooks/limit-orders/use-orderbook"; +import { mulPrice } from "~/hooks/queries/assets/use-coin-fiat-value"; +import { useAmplitudeAnalytics } from "~/hooks/use-amplitude-analytics"; +import { useEstimateTxFees } from "~/hooks/use-estimate-tx-fees"; +import { useSwap, useSwapAssets } from "~/hooks/use-swap"; +import { useStore } from "~/stores"; +import { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; +import { countDecimals, trimPlaceholderZeros } from "~/utils/number"; +import { api } from "~/utils/trpc"; + +function getNormalizationFactor( + baseAssetDecimals: number, + quoteAssetDecimals: number +) { + return new Dec(10).pow(new Int(quoteAssetDecimals - baseAssetDecimals)); +} + +export type OrderDirection = "bid" | "ask"; + +export interface UsePlaceLimitParams { + osmosisChainId: string; + orderDirection: OrderDirection; + useQueryParams?: boolean; + useOtherCurrencies?: boolean; + baseDenom: string; + quoteDenom: string; + type: "limit" | "market"; + page: EventPage; + maxSlippage?: Dec; +} + +export type PlaceLimitState = ReturnType; + +// TODO: adjust as necessary +const CLAIM_BOUNTY = "0.0001"; + +export const usePlaceLimit = ({ + osmosisChainId, + quoteDenom, + baseDenom, + orderDirection, + useQueryParams = false, + useOtherCurrencies = true, + type, + page, + maxSlippage, +}: UsePlaceLimitParams) => { + const { logEvent } = useAmplitudeAnalytics(); + const { accountStore } = useStore(); + const { + makerFee, + isMakerFeeLoading, + contractAddress: orderbookContractAddress, + error: orderbookError, + } = useOrderbook({ + quoteDenom, + baseDenom, + }); + + const swapAssets = useSwapAssets({ + initialFromDenom: baseDenom, + initialToDenom: quoteDenom, + useQueryParams, + useOtherCurrencies, + }); + + const inAmountInput = useAmountInput({ + currency: swapAssets.fromAsset, + }); + + const marketState = useSwap({ + initialFromDenom: orderDirection === "ask" ? baseDenom : quoteDenom, + initialToDenom: orderDirection === "ask" ? quoteDenom : baseDenom, + useQueryParams: false, + useOtherCurrencies, + maxSlippage, + }); + + const quoteAsset = swapAssets.toAsset; + const baseAsset = swapAssets.fromAsset; + + const priceState = useLimitPrice({ + orderDirection, + baseDenom: baseAsset?.coinMinimalDenom, + }); + + const isMarket = useMemo( + () => type === "market", + //|| priceState.isBeyondOppositePrice + // Disabled auto market placing but can be readded with the above conditional + [type] + ); + + const account = accountStore.getWallet(osmosisChainId); + + // TODO: Readd this once orderbooks support non-stablecoin pairs + // const { price: quoteAssetPrice } = usePrice({ + // coinMinimalDenom: quoteAsset?.coinMinimalDenom ?? "", + // }); + const quoteAssetPrice = useMemo( + () => new PricePretty(DEFAULT_VS_CURRENCY, new Dec(1)), + [] + ); + + /** + * Calculates the amount of tokens to be sent with the order. + * In the case of an Ask order the amount sent is the amount of tokens defined by the user in terms of the base asset. + * In the case of a Bid order the amount sent is the requested fiat amount divided by the current quote asset price. + * The amount is then multiplied by the number of decimal places the quote asset has. + * + * @returns The amount of tokens to be sent with the order in base asset amounts for an Ask and quote asset amounts for a Bid. + */ + const paymentTokenValue = useMemo(() => { + if (isMarket) + return ( + marketState.inAmountInput.amount ?? + new CoinPretty( + orderDirection === "ask" ? baseAsset! : quoteAsset!, + new Dec(0) + ) + ); + // The amount of tokens the user wishes to buy/sell + const baseTokenAmount = + inAmountInput.amount ?? new CoinPretty(baseAsset!, new Dec(0)); + if (orderDirection === "ask") { + // In the case of an Ask we just return the amount requested to sell + return baseTokenAmount; + } + + // Determine the outgoing fiat amount the user wants to buy + const outgoingFiatValue = + marketState.inAmountInput.amount?.toDec() ?? new Dec(0); + + // Determine the amount of quote asset tokens to send by dividing the outgoing fiat amount by the current quote asset price + // Multiply by 10^n where n is the amount of decimals for the quote asset + const quoteTokenAmount = outgoingFiatValue! + .quo(quoteAssetPrice.toDec() ?? new Dec(1)) + .mul(new Dec(Math.pow(10, quoteAsset!.coinDecimals))); + return new CoinPretty(quoteAsset!, quoteTokenAmount); + }, [ + quoteAssetPrice, + baseAsset, + orderDirection, + inAmountInput.amount, + quoteAsset, + isMarket, + marketState.inAmountInput.amount, + ]); + + /** + * When creating a market order we want to update the market state with the input amount + * with the amount of base tokens. + * + * Only runs on an ASK order. A BID order is handled by the input directly. + */ + useEffect(() => { + if (orderDirection === "bid") return; + + const normalizedAmount = inAmountInput.amount?.toDec().toString() ?? "0"; + marketState.inAmountInput.setAmount(normalizedAmount); + }, [inAmountInput.amount, orderDirection, marketState.inAmountInput]); + + const normalizationFactor = useMemo(() => { + return getNormalizationFactor( + baseAsset!.coinDecimals, + quoteAsset!.coinDecimals + ); + }, [baseAsset, quoteAsset]); + + /** + * Determines the fiat amount the user will pay for their order. + * In the case of an Ask the fiat amount is the amount of tokens the user will sell multiplied by the currently selected price. + * In the case of a Bid the fiat amount is the amount of quote asset tokens the user will send multiplied by the current price of the quote asset. + */ + const paymentFiatValue = useMemo(() => { + if (isMarket) + return ( + marketState.inAmountInput.fiatValue ?? + new PricePretty(DEFAULT_VS_CURRENCY, new Dec(0)) + ); + return orderDirection === "ask" + ? mulPrice( + paymentTokenValue, + new PricePretty(DEFAULT_VS_CURRENCY, priceState.price), + DEFAULT_VS_CURRENCY + ) + : mulPrice(paymentTokenValue, quoteAssetPrice, DEFAULT_VS_CURRENCY); + }, [ + paymentTokenValue, + orderDirection, + quoteAssetPrice, + priceState, + isMarket, + marketState.inAmountInput.fiatValue, + ]); + + const feeUsdValue = useMemo(() => { + return ( + paymentFiatValue?.mul(makerFee) ?? + new PricePretty(DEFAULT_VS_CURRENCY, new Dec(0)) + ); + }, [paymentFiatValue, makerFee]); + + const placeLimitMsg = useMemo(() => { + if (isMarket) return; + + const quantity = paymentTokenValue.toCoin().amount ?? "0"; + + if (quantity === "0") { + return; + } + + // The requested price must account for the ratio between the quote and base asset as the base asset may not be a stablecoin. + // To account for this we divide by the quote asset price. + const tickId = priceToTick( + priceState.price.quo(quoteAssetPrice.toDec()).mul(normalizationFactor) + ); + const msg = { + place_limit: { + tick_id: parseInt(tickId.toString()), + order_direction: orderDirection, + quantity, + claim_bounty: CLAIM_BOUNTY, + }, + }; + + return msg; + }, [ + orderDirection, + priceState.price, + quoteAssetPrice, + normalizationFactor, + paymentTokenValue, + isMarket, + ]); + + const encodedMsg = useMemo(() => { + if (!placeLimitMsg) return; + + return cosmwasmMsgOpts.executeWasm.messageComposer({ + contract: orderbookContractAddress, + sender: account?.address ?? "", + msg: Buffer.from(JSON.stringify(placeLimitMsg)), + funds: [ + { + denom: paymentTokenValue.toCoin().denom, + amount: paymentTokenValue.toCoin().amount ?? "0", + }, + ], + }); + }, [ + account?.address, + orderbookContractAddress, + paymentTokenValue, + placeLimitMsg, + ]); + + const placeLimit = useCallback(async () => { + const quantity = paymentTokenValue.toCoin().amount ?? "0"; + if (quantity === "0") { + return; + } + + if (isMarket) { + const baseEvent = { + fromToken: marketState.fromAsset?.coinDenom, + tokenAmount: Number( + marketState.inAmountInput.amount?.toDec().toString() ?? "0" + ), + toToken: marketState.toAsset?.coinDenom, + isOnHome: page === "Swap Page", + isMultiHop: marketState.quote?.split.some( + ({ pools }) => pools.length !== 1 + ), + isMultiRoute: (marketState.quote?.split.length ?? 0) > 1, + valueUsd: Number( + marketState.inAmountInput.fiatValue?.toDec().toString() ?? "0" + ), + feeValueUsd: Number(marketState.totalFee?.toString() ?? "0"), + page, + quoteTimeMilliseconds: marketState.quote?.timeMs, + router: marketState.quote?.name, + }; + try { + logEvent([EventName.Swap.swapStarted, baseEvent]); + const result = await marketState.sendTradeTokenInTx(); + logEvent([ + EventName.Swap.swapCompleted, + { + ...baseEvent, + isMultiHop: result === "multihop", + }, + ]); + } catch (error) { + console.error("swap failed", error); + if (error instanceof Error && error.message === "Request rejected") { + // don't log when the user rejects in wallet + return; + } + logEvent([EventName.Swap.swapFailed, baseEvent]); + } finally { + return; + } + } + + if (!placeLimitMsg) return; + + const paymentDenom = paymentTokenValue.toCoin().denom; + + const baseEvent = { + type: orderDirection === "bid" ? "buy" : "sell", + fromToken: paymentDenom, + toToken: + orderDirection === "bid" ? baseAsset?.coinDenom : quoteAsset?.coinDenom, + valueUsd: Number(paymentFiatValue?.toDec().toString() ?? "0"), + tokenAmount: Number(quantity), + page, + isOnHomePage: page === "Swap Page", + feeUsdValue, + }; + + try { + logEvent([EventName.LimitOrder.placeOrderStarted, baseEvent]); + await account?.cosmwasm.sendExecuteContractMsg( + "executeWasm", + orderbookContractAddress, + placeLimitMsg!, + [ + { + amount: quantity, + denom: paymentDenom, + }, + ] + ); + logEvent([EventName.LimitOrder.placeOrderCompleted, baseEvent]); + } catch (error) { + console.error("Error attempting to broadcast place limit tx", error); + if (error instanceof Error && error.message === "Request rejected") { + // don't log when the user rejects in wallet + return; + } + const { message } = error as Error; + logEvent([ + EventName.LimitOrder.placeOrderFailed, + { ...baseEvent, errorMessage: message }, + ]); + } + }, [ + orderbookContractAddress, + account, + orderDirection, + paymentTokenValue, + isMarket, + marketState, + paymentFiatValue, + baseAsset, + quoteAsset, + logEvent, + page, + feeUsdValue, + placeLimitMsg, + ]); + + const { data: balances, isLoading: isBalancesLoading } = + api.local.balances.getUserBalances.useQuery( + { bech32Address: account?.address ?? "" }, + { + enabled: !!account?.address, + select: (balances) => + balances.filter( + ({ denom }) => + denom === baseAsset?.coinMinimalDenom || + denom === quoteAsset?.coinMinimalDenom + ), + } + ); + + const quoteTokenBalance = useMemo(() => { + if (!balances) return; + + return balances.find(({ denom }) => denom === quoteAsset?.coinMinimalDenom) + ?.coin; + }, [balances, quoteAsset]); + + const baseTokenBalance = useMemo(() => { + if (!balances) return; + + return balances.find(({ denom }) => denom === baseAsset?.coinMinimalDenom) + ?.coin; + }, [balances, baseAsset]); + const insufficientFunds = useMemo(() => { + return orderDirection === "bid" + ? (quoteTokenBalance?.toDec() ?? new Dec(0)).lt( + paymentTokenValue.toDec() ?? new Dec(0) + ) + : (baseTokenBalance?.toDec() ?? new Dec(0)).lt( + paymentTokenValue.toDec() ?? new Dec(0) + ); + }, [orderDirection, paymentTokenValue, baseTokenBalance, quoteTokenBalance]); + + const expectedTokenAmountOut = useMemo(() => { + if (isMarket) { + return ( + marketState.quote?.amount ?? + new CoinPretty( + orderDirection === "ask" ? quoteAsset! : baseAsset!, + new Dec(0) + ) + ); + } + const preFeeAmount = + orderDirection === "ask" + ? new CoinPretty( + quoteAsset!, + paymentFiatValue?.quo(quoteAssetPrice?.toDec() ?? new Dec(1)) ?? + new Dec(1) + ).mul(new Dec(Math.pow(10, quoteAsset!.coinDecimals))) + : inAmountInput.amount ?? new CoinPretty(baseAsset!, new Dec(0)); + return preFeeAmount.mul(new Dec(1).sub(makerFee)); + }, [ + inAmountInput.amount, + baseAsset, + quoteAsset, + orderDirection, + makerFee, + quoteAssetPrice, + paymentFiatValue, + isMarket, + marketState.quote?.amount, + ]); + + const expectedFiatAmountOut = useMemo(() => { + if (isMarket) { + return marketState.tokenOutFiatValue; + } + return orderDirection === "ask" + ? new PricePretty( + DEFAULT_VS_CURRENCY, + quoteAssetPrice?.mul(expectedTokenAmountOut.toDec()) ?? new Dec(0) + ) + : new PricePretty( + DEFAULT_VS_CURRENCY, + priceState.price?.mul(expectedTokenAmountOut.toDec()) ?? new Dec(0) + ); + }, [ + priceState.price, + expectedTokenAmountOut, + orderDirection, + quoteAssetPrice, + isMarket, + marketState.tokenOutFiatValue, + ]); + + const reset = useCallback(() => { + inAmountInput.reset(); + priceState.reset(); + marketState.inAmountInput.reset(); + }, [inAmountInput, priceState, marketState]); + const error = useMemo(() => { + if (!isMarket && orderbookError) { + return orderbookError; + } + + if (insufficientFunds) { + return "limitOrders.insufficientFunds"; + } + + if (!isMarket && !priceState.isValidPrice) { + return "limitOrders.invalidPrice"; + } + + if (isMarket && marketState.error) { + return tError(marketState.error)[0]; + } + + const quantity = paymentTokenValue.toCoin().amount ?? "0"; + if (quantity === "0") { + return "errors.zeroAmount"; + } + + return; + }, [ + insufficientFunds, + isMarket, + marketState.error, + priceState.isValidPrice, + paymentTokenValue, + orderbookError, + ]); + + const shouldEstimateLimitGas = useMemo(() => { + return ( + !isMarket && + !!encodedMsg && + !!account?.address && + !insufficientFunds && + !inAmountInput.isTyping && + !marketState.inAmountInput.isTyping + ); + }, [ + isMarket, + encodedMsg, + account?.address, + insufficientFunds, + inAmountInput.isTyping, + marketState.inAmountInput.isTyping, + ]); + + const { + data: gasEstimate, + isLoading: gasFeeLoading, + error: limitGasError, + } = useEstimateTxFees({ + chainId: accountStore.osmosisChainId, + messages: encodedMsg && !isMarket ? [encodedMsg] : [], + enabled: shouldEstimateLimitGas, + }); + + const gasAmountFiat = useMemo(() => { + if (isMarket) { + return marketState.networkFee?.gasUsdValueToPay; + } + return gasEstimate?.gasUsdValueToPay; + }, [ + isMarket, + marketState.networkFee?.gasUsdValueToPay, + gasEstimate?.gasUsdValueToPay, + ]); + + const isGasLoading = useMemo(() => { + if (isMarket) { + return marketState.isLoadingNetworkFee; + } + return gasFeeLoading && shouldEstimateLimitGas; + }, [ + isMarket, + marketState.isLoadingNetworkFee, + gasFeeLoading, + shouldEstimateLimitGas, + ]); + + const gasError = useMemo(() => { + if (isMarket) { + return marketState.networkFeeError; + } + return limitGasError; + }, [isMarket, marketState.networkFeeError, limitGasError]); + + return { + baseAsset, + quoteAsset, + priceState, + inAmountInput, + placeLimit, + baseTokenBalance, + quoteTokenBalance, + isBalancesFetched: !isBalancesLoading, + insufficientFunds, + paymentFiatValue, + paymentTokenValue, + makerFee, + isMakerFeeLoading, + expectedTokenAmountOut, + expectedFiatAmountOut, + marketState, + isMarket, + quoteAssetPrice, + reset, + error, + feeUsdValue, + gas: { + gasAmountFiat, + isLoading: isGasLoading, + error: gasError, + }, + }; +}; + +const DEFAULT_PERCENT_ADJUSTMENT = "0"; + +const MAX_TICK_PRICE = 340282300000000000000; +const MIN_TICK_PRICE = 0.000000000001; + +/** + * Handles the logic for the limit price selector. + * Allows the user to input either a set fiat price or a percentage related to the current spot price. + * Also returns relevant spot price for each direction. + */ +const useLimitPrice = ({ + orderDirection, + baseDenom, +}: { + orderDirection: OrderDirection; + baseDenom?: string; +}) => { + const [priceLocked, setPriceLock] = useState(false); + + const { + data: assetPrice, + isLoading: loadingSpotPrice, + isRefetching: isSpotPriceRefetching, + } = api.edge.assets.getAssetPrice.useQuery( + { + coinMinimalDenom: baseDenom ?? "", + }, + { refetchInterval: 5000, enabled: !!baseDenom && !priceLocked } + ); + + const [orderPrice, setOrderPrice] = useState("0"); + const [manualPercentAdjusted, setManualPercentAdjusted] = useState("0"); + + // Decimal version of the spot price, defaults to 1 + const spotPrice = useMemo(() => { + return assetPrice + ? new Dec( + formatPretty( + assetPrice.toDec(), + getPriceExtendedFormatOptions(assetPrice.toDec()) + ).replace(/,/g, "") + ) + : new Dec(1); + }, [assetPrice]); + + const setPriceAsPercentageOfSpotPrice = useCallback( + (percent: Dec, lockPrice = true, format = true) => { + const percentAdjusted = + orderDirection === "bid" + ? // Adjust negatively for bid orders + new Dec(1).sub(percent) + : // Adjust positively for ask orders + new Dec(1).add(percent); + const newPrice = spotPrice.mul(percentAdjusted); + + setOrderPrice( + format + ? formatPretty( + newPrice, + getPriceExtendedFormatOptions(newPrice) + ).replace(/,/g, "") + : trimPlaceholderZeros(newPrice.toString()) + ); + setPriceLock(lockPrice); + }, + [setOrderPrice, orderDirection, spotPrice] + ); + + // Sets a user based order price, if nothing is input it resets the form (including percentage adjustments) + const setManualOrderPrice = useCallback( + (price: string) => { + if (countDecimals(price) > 12) { + return; + } + + if (!isValidNumericalRawInput(price)) return; + + const newPrice = new Dec(price.length > 0 ? price : "0"); + + if (newPrice.lt(new Dec(MIN_TICK_PRICE)) && !newPrice.isZero()) { + price = trimPlaceholderZeros(new Dec(MIN_TICK_PRICE).toString()); + } else if (newPrice.gt(new Dec(MAX_TICK_PRICE))) { + price = trimPlaceholderZeros(new Dec(MAX_TICK_PRICE).toString()); + } + + const percentAdjusted = newPrice + .quo(spotPrice) + .sub(new Dec(1)) + .mul(new Dec(100)); + + const isPercentAdjustedTooLarge = + percentAdjusted.toString().split(".")[0].length > 9; + + if (isPercentAdjustedTooLarge) return; + + setPriceLock(true); + setOrderPrice(price); + + if (price.length === 0 || newPrice.isZero()) { + setManualPercentAdjusted(""); + return; + } + + setManualPercentAdjusted( + percentAdjusted.isZero() || newPrice.isZero() + ? "0" + : trimPlaceholderZeros( + formatPretty(percentAdjusted.abs(), { + maxDecimals: 3, + }) + .toString() + .replace(/,/g, "") + ) + ); + }, + [setOrderPrice, spotPrice] + ); + + // Adjusts the percentage for placing the order. + // Adjusting the precentage also resets a user based input in order to maintain + // a percentage related to the current spot price. + const setManualPercentAdjustedSafe = useCallback( + (percentAdjusted: string) => { + if (percentAdjusted.startsWith(".")) { + percentAdjusted = "0" + percentAdjusted; + } + + if ( + percentAdjusted.length > 0 && + !isValidNumericalRawInput(percentAdjusted) + ) + return; + + if (countDecimals(percentAdjusted) > 3) { + // percentAdjusted = parseFloat(percentAdjusted).toFixed(10).toString(); + return; + } + + const split = percentAdjusted.split("."); + if (split[0].length > 9) { + return; + } + + // Do not allow the user to input 100% below current price + if ( + orderDirection === "bid" && + percentAdjusted.length > 0 && + new Dec(percentAdjusted).gte(new Dec(100)) + ) { + return; + } + + setManualPercentAdjusted(percentAdjusted); + + if (!percentAdjusted) { + setPriceAsPercentageOfSpotPrice(new Dec(0), false); + return; + } + + setPriceAsPercentageOfSpotPrice( + new Dec(percentAdjusted).quo(new Dec(100)), + false, + false + ); + }, + [setManualPercentAdjusted, orderDirection, setPriceAsPercentageOfSpotPrice] + ); + + // Whether the user's manual order price is a valid price + const isValidInputPrice = + Boolean(orderPrice) && + orderPrice.length > 0 && + !new Dec(orderPrice).isZero() && + new Dec(orderPrice).isPositive(); + + // The current price. If the user has input a manual order price then that is used, otherwise we look at the percentage adjusted. + // If the user has a percentage adjusted input we calculate the price relative to the spot price + // given the current direction of the order. + // If the form is empty we default to a percentage relative to the spot price. + const price = useMemo(() => { + if (isValidInputPrice) { + return new Dec(orderPrice); + } + + const percent = + manualPercentAdjusted.length > 0 + ? manualPercentAdjusted + : DEFAULT_PERCENT_ADJUSTMENT; + const percentAdjusted = + orderDirection === "bid" + ? // Adjust negatively for bid orders + new Dec(1).sub(new Dec(percent).quo(new Dec(100))) + : // Adjust positively for ask orders + new Dec(1).add(new Dec(percent).quo(new Dec(100))); + + return spotPrice.mul(percentAdjusted); + }, [ + orderPrice, + spotPrice, + manualPercentAdjusted, + orderDirection, + isValidInputPrice, + ]); + + // The raw percentage adjusted based on the current order price state + const percentAdjusted = useMemo( + () => + !!manualPercentAdjusted + ? new Dec(manualPercentAdjusted).quo(new Dec(100)) + : price.quo(spotPrice).sub(new Dec(1)), + [price, spotPrice, manualPercentAdjusted] + ); + + // If the user is inputting a price that crosses over the spot price + const isBeyondOppositePrice = + orderDirection === "ask" ? spotPrice.gt(price) : spotPrice.lt(price); + + const priceFiat = useMemo(() => { + return new PricePretty(DEFAULT_VS_CURRENCY, price); + }, [price]); + + const reset = useCallback(() => { + setManualPercentAdjusted("0"); + setOrderPrice(""); + setPriceLock(false); + }, []); + + useEffect(() => { + reset(); + }, [orderDirection, reset, baseDenom]); + + const isValidPrice = isValidInputPrice || Boolean(spotPrice); + + return { + spotPrice, + orderPrice, + price, + priceFiat, + manualPercentAdjusted, + setPercentAdjusted: setManualPercentAdjustedSafe, + _setPercentAdjustedUnsafe: setManualPercentAdjusted, + percentAdjusted, + isLoading: loadingSpotPrice, + reset, + setPrice: setManualOrderPrice, + _setPriceUnsafe: setOrderPrice, + isValidPrice, + isBeyondOppositePrice, + isSpotPriceRefetching, + setPriceLock, + priceLocked, + setPriceAsPercentageOfSpotPrice, + }; +}; diff --git a/packages/web/hooks/use-feature-flags.ts b/packages/web/hooks/use-feature-flags.ts index 9947e28db0..1c41dae298 100644 --- a/packages/web/hooks/use-feature-flags.ts +++ b/packages/web/hooks/use-feature-flags.ts @@ -1,7 +1,10 @@ +import { apiClient } from "@osmosis-labs/utils"; +import { useQuery } from "@tanstack/react-query"; import { useFlags, useLDClient } from "launchdarkly-react-client-sdk"; import { useEffect, useState } from "react"; import { useWindowSize } from "~/hooks"; +import { LevanaGeoBlockedResponse } from "~/pages/_app"; // NOTE: Please add a default value to any new flag you add to this list export type AvailableFlags = @@ -26,6 +29,7 @@ export type AvailableFlags = | "newAssetsPage" | "newDepositWithdrawFlow" | "oneClickTrading" + | "limitOrders" | "advancedChart"; type ModifiedFlags = @@ -40,7 +44,7 @@ const defaultFlags: Record = { sidebarOsmoChangeAndChart: true, multiBridgeProviders: true, earnPage: false, - transactionsPage: false, + transactionsPage: true, sidecarRouter: true, legacyRouter: true, tfmRouter: true, @@ -51,15 +55,21 @@ const defaultFlags: Record = { positionRoi: true, swapToolSimulateFee: false, portfolioPageAndNewAssetsPage: false, - newAssetsPage: false, + newAssetsPage: true, displayDailyEarn: false, newDepositWithdrawFlow: false, oneClickTrading: false, + limitOrders: true, advancedChart: false, _isInitialized: false, _isClientIDPresent: false, }; +const LIMIT_ORDER_COUNTRY_CODES = + process.env.NEXT_PUBLIC_LIMIT_ORDER_COUNTRY_CODES?.split(",").map((s) => + s.trim() + ) ?? []; + export const useFeatureFlags = () => { const launchdarklyFlags: Record = useFlags(); const { isMobile } = useWindowSize(); @@ -67,6 +77,17 @@ export const useFeatureFlags = () => { const client = useLDClient(); + const { data: levanaGeoblock } = useQuery( + ["levana-geoblocked"], + () => + apiClient("https://geoblocked.levana.finance/"), + { + staleTime: Infinity, + cacheTime: Infinity, + retry: false, + } + ); + useEffect(() => { if (!isInitialized && client && process.env.NODE_ENV !== "test") client.waitForInitialization().then(() => setIsInitialized(true)); @@ -75,7 +96,6 @@ export const useFeatureFlags = () => { const isDevModeWithoutClientID = process.env.NODE_ENV === "development" && !process.env.NEXT_PUBLIC_LAUNCH_DARKLY_CLIENT_SIDE_ID; - return { ...launchdarklyFlags, ...(isDevModeWithoutClientID ? defaultFlags : {}), @@ -93,5 +113,10 @@ export const useFeatureFlags = () => { launchdarklyFlags.oneClickTrading, _isInitialized: isDevModeWithoutClientID ? true : isInitialized, _isClientIDPresent: !!process.env.NEXT_PUBLIC_LAUNCH_DARKLY_CLIENT_SIDE_ID, + limitOrders: + isInitialized && + launchdarklyFlags.limitOrders && + (LIMIT_ORDER_COUNTRY_CODES.length === 0 || + LIMIT_ORDER_COUNTRY_CODES.includes(levanaGeoblock?.countryCode ?? "")), } as Record; }; diff --git a/packages/web/hooks/use-swap.tsx b/packages/web/hooks/use-swap.tsx index 2c9605ceba..91e59f2fb7 100644 --- a/packages/web/hooks/use-swap.tsx +++ b/packages/web/hooks/use-swap.tsx @@ -16,14 +16,11 @@ import { getAssetFromAssetList, isNil, makeMinimalAsset, + sum, } from "@osmosis-labs/utils"; -import { sum } from "@osmosis-labs/utils"; import { createTRPCReact, TRPCClientError } from "@trpc/react-query"; -import { useRouter } from "next/router"; -import { useState } from "react"; -import { useMemo } from "react"; -import { useCallback } from "react"; -import { useEffect } from "react"; +import { parseAsString, useQueryState } from "nuqs"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "react-toastify"; import { displayToast, ToastType } from "~/components/alert"; @@ -48,9 +45,9 @@ import { useDebouncedState } from "./use-debounced-state"; import { useFeatureFlags } from "./use-feature-flags"; import { usePreviousWhen } from "./use-previous-when"; import { useWalletSelect } from "./use-wallet-select"; -import { useQueryParamState } from "./window/use-query-param-state"; export type SwapState = ReturnType; +export type SwapAsset = ReturnType["asset"]; type SwapOptions = { /** Initial from denom if `useQueryParams` is not `true` and there's no query param. */ @@ -749,7 +746,7 @@ export function useSwapAssets({ }; } -function useSwapAmountInput({ +export function useSwapAmountInput({ swapAssets, forceSwapInPoolId, maxSlippage, @@ -868,7 +865,7 @@ function useSwapAmountInput({ * Switches between using query parameters or React state to store 'from' and 'to' asset denominations. * If the user has set preferences via query parameters, the initial denominations will be ignored. */ -function useToFromDenoms({ +export function useToFromDenoms({ useQueryParams, initialFromDenom, initialToDenom, @@ -877,21 +874,19 @@ function useToFromDenoms({ initialFromDenom?: string; initialToDenom?: string; }) { - const router = useRouter(); - /** * user query params as state source-of-truth * ignores initial denoms if there are query params */ - const [fromDenomQueryParam, setFromDenomQueryParam] = useQueryParamState( + const [fromDenomQueryParam, setFromDenomQueryParam] = useQueryState( "from", - useQueryParams ? initialFromDenom : undefined + parseAsString.withDefault(initialFromDenom ?? "ATOM") ); const fromDenomQueryParamStr = typeof fromDenomQueryParam === "string" ? fromDenomQueryParam : undefined; - const [toAssetQueryParam, setToAssetQueryParam] = useQueryParamState( + const [toAssetQueryParam, setToAssetQueryParam] = useQueryState( "to", - useQueryParams ? initialToDenom : undefined + parseAsString.withDefault(initialToDenom ?? "OSMO") ); const toDenomQueryParamStr = typeof toAssetQueryParam === "string" ? toAssetQueryParam : undefined; @@ -913,14 +908,9 @@ function useToFromDenoms({ // doesn't handle two immediate pushes well within `useQueryParamState` hooks const switchAssets = () => { if (useQueryParams) { - const existingParams = router.query; - router.replace({ - query: { - ...existingParams, - from: toDenomQueryParamStr, - to: fromDenomQueryParamStr, - }, - }); + const temp = fromDenomQueryParam; + setFromDenomQueryParam(toAssetQueryParam); + setToAssetQueryParam(temp); return; } @@ -942,7 +932,7 @@ function useToFromDenoms({ /** Will query for an individual asset of any type of denom (symbol, min denom) * if it's not already in the list of existing assets. */ -function useSwapAsset({ +export function useSwapAsset({ minDenomOrSymbol, existingAssets = [], }: { @@ -1315,7 +1305,7 @@ function makeRouterErrorFromTrpcError( } /** Gets recommended assets directly from asset list. */ -function useRecommendedAssets( +export function useRecommendedAssets( fromCoinMinimalDenom?: string, toCoinMinimalDenom?: string ) { diff --git a/packages/web/localizations/__tests__/localizations.test.ts b/packages/web/localizations/__tests__/localizations.spec.ts similarity index 93% rename from packages/web/localizations/__tests__/localizations.test.ts rename to packages/web/localizations/__tests__/localizations.spec.ts index 40a6929dbd..f293758179 100644 --- a/packages/web/localizations/__tests__/localizations.test.ts +++ b/packages/web/localizations/__tests__/localizations.spec.ts @@ -10,7 +10,10 @@ import { glob } from "glob"; const warn = (..._args: Parameters) => null; // console.warn(...args); /** Add key paths here to skip them in localizations tests. */ -const omittedKeyPaths = ["assets.categories"]; +const omittedKeyPaths = [ + "assets.categories", + "limitOrders.historyTable.columns", +]; describe("Localization JSON files", () => { const localizationObjs = getJSONsAsObjs(); @@ -54,12 +57,18 @@ describe("Localization JSON files", () => { const keys: string[] = []; objectKeys(obj, keys); keys.forEach((key) => { - if (!fileContents.some((content) => content.includes(`"${key}"`))) { + if ( + !fileContents.some((content) => + content.includes(`"${key}"` || `\`${key}\``) + ) + ) { throw new Error( `Localization key ${key} is not found in any tsx files but is found in ${jsonFileName}. Tip: use scripts/remove-key JS script with cwd in localizations folder to remove unused keys. Pass key path as only parameter.` ); } else if ( - !fileContents.some((content) => content.includes(`t("${key}"`)) + !fileContents.some((content) => + content.includes(`t("${key}"` || `\`${key}\``) + ) ) { warn( `Localization key ${key} IS found but not within a t() function in ${jsonFileName}` diff --git a/packages/web/localizations/de.json b/packages/web/localizations/de.json index e7a49ea988..d86348dcdc 100644 --- a/packages/web/localizations/de.json +++ b/packages/web/localizations/de.json @@ -411,6 +411,7 @@ "txTimedOutError": "Zeitüberschreitung bei der Transaktion. Bitte erneut versuchen.", "insufficientFee": "Unzureichendes Guthaben für Transaktionsgebühren. Bitte fügen Sie Geld hinzu, um fortzufahren.", "noData": "Keine Daten", + "noOrderbook": "Kein Orderbuch für Paar", "uhOhSomethingWentWrong": "Oh oh, etwas ist schiefgelaufen", "sorryForTheInconvenience": "Entschuldigen Sie die Unannehmlichkeiten. Bitte versuchen Sie es später erneut.", "startAgain": "Fang nochmal an" @@ -447,7 +448,6 @@ "pools": "Pools", "stake": "Einsatz", "store": "Apps", - "swap": "Tauschen", "vote": "Abstimmung", "featureRequests": "Funktionsanfragen", "trade": "Profi-Handel", @@ -798,9 +798,11 @@ "MAX": "MAX", "minimumSlippage": "Nach Slippage erhaltenes Minimum ( {slippage} )", "pool": "Pool # {id}", - "priceImpact": "Preisauswirkungen", + "priceImpact": "Auswirkungen auf den Markt", "routerTooltipFee": "Gebühr", "routerTooltipSpreadFactor": "Spread-Faktor", + "showDetails": "Zeige Details", + "hideDetails": "Verstecken", "continueAnyway": "Mache trotzdem weiter", "warning": { "exceedsSpendLimit": "Dieser Tausch überschreitet Ihr verbleibendes Ausgabenlimit für 1-Click-Handel.", @@ -812,7 +814,17 @@ "title": "Transaktionseinstellungen" }, "title": "Tauschen", - "dynamicSpreadFactor": "Dynamisch" + "dynamicSpreadFactor": "Dynamisch", + "gas": { + "oneClickTradingError": "Die Netzwerkgebühr kann derzeit nicht geschätzt werden. Wenn die Gebühr Ihr 1-Click Trading-Netzwerkgebührenlimit überschreitet, können Sie mit der manuellen Genehmigung in Ihrem Wallet fortfahren.", + "error": "Die Netzwerkgebühr kann derzeit nicht geschätzt werden. Überprüfen Sie die Netzwerkgebühren in Ihrem Wallet, bevor Sie die Transaktion genehmigen.", + "gasEstimationError": "Netzwerkgebühr kann nicht geschätzt werden", + "unknown": "Unbekannt", + "additionalNetworkFee": "Zusätzliche Netzwerkgebühr" + }, + "noRoutes": "Keine Routen gefunden", + "route": "Route", + "routes": "Routen" }, "walletSelect": { "gettingStarted": "Erste Schritte", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Älter", "newer": "Neuere" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Befehl", + "amount": "Menge", + "price": "Preis", + "orderPlaced": "Bestellung aufgegeben", + "status": "Status" + }, + "emptyState": { + "title": "Keine aktuellen Bestellungen", + "subtitle": "Ihr Handelsauftragsverlauf wird hier angezeigt.", + "connectTitle": "Verbinden Sie Ihr Portemonnaie, um Ihren Bestellverlauf anzuzeigen", + "connectSubtitle": "Ihre früheren Limitaufträge bei Osmosis werden hier angezeigt." + } + }, + "aboveMarket": { + "title": "Limitpreis über Marktpreis", + "description": "Wenn Sie fortfahren, wird Ihre Bestellung als Marktorder zum Marktpreis bearbeitet." + }, + "belowMarket": { + "title": "Limitpreis unter Marktpreis", + "description": "Wenn Sie fortfahren, wird Ihre Bestellung als Marktorder zum Marktpreis bearbeitet." + }, + "enterAnAmountTo": "Geben Sie einen Betrag ein, um", + "sell": "Verkaufen", + "buy": "Kaufen", + "pay": "Zahlen", + "open": "Offen", + "filled": "Gefüllt", + "claimable": "Einforderbar", + "claimAndClose": "Beanspruchen und schließen", + "accept": "Akzeptieren", + "cancelled": "Abgesagt", + "cancel": "Stornieren", + "daysAgo": "{days} vor Tagen", + "hoursAgo": "vor {hours} Stunden", + "buyWith": "Kaufen mit {coinA} oder {coinB}", + "orderHistory": "Bestellverlauf", + "tradeAnotherAssetOr": "Tauschen Sie einen anderen Vermögenswert gegen {coinA} oder {coinB}", + "tradeAnotherAsset": "Tauschen Sie einen anderen Vermögenswert gegen {coinDenom}", + "sellAnAsset": "Einen Vermögenswert verkaufen", + "swapAnAsset": "Tauschen Sie einen Vermögenswert aus", + "of": "von", + "insufficientFunds": "Unzureichende Mittel", + "startTrading": "Mit dem handel beginnen", + "filledOrdersToClaim": "Ausgeführte Bestellungen zum Einfordern", + "tradeFees": "Handelsgebühren (bei Auftragsausführung)", + "youNeed": "Du brauchst", + "stablecoin": "stabile Münze", + "quoteUpdated": "Angebot aktualisiert", + "fundsOsmosisToBuyAssets": "Mittel auf Osmosis, um Vermögenswerte zu kaufen.", + "chooseAnOption": "Wählen Sie eine Option, um fortzufahren.", + "lowerSlippageToleranceRecommended": "Eine geringere Rutschtoleranz wird empfohlen.", + "tryHigherSlippage": "Versuchen Sie es mit einer höheren maximalen Schlupftoleranz.", + "reviewTrade": "Handel überprüfen", + "errors": { + "noAssetAvailable": "Sie haben keine {coinName} Guthaben auf Osmosis, mit denen Sie handeln können. Wählen Sie eine Option, um fortzufahren.", + "tradeMayResultInLossOfValue": "Ihr Handel kann zu einem erheblichen Wertverlust führen", + "tradeMayNotExecuted": "Ihr Handel wird möglicherweise nicht ausgeführt" + }, + "transferFromAnotherNetwork": "Überweisung von einem anderen Netzwerk oder Wallet", + "whatIsOrderClaim": { + "title": "Was ist eine Auftragsbeantragung?", + "description": "In den meisten Fällen werden bei der Ausführung einer Limit-Order Gelder automatisch Ihrem Konto gutgeschrieben. In einigen seltenen Fällen müssen Gelder jedoch manuell aus der ausgeführten Order angefordert werden.\nBei diesem Anspruchsprozess wird für alle nicht beanspruchten, ausgeführten Bestellungen eine einzelne Transaktion in Ihrem Portemonnaie genehmigt, für die eine geringe Netzwerkgebühr anfällt." + }, + "whatIsAStablecoin": { + "title": "Was ist eine Stablecoin?", + "description": "Stablecoins sind eine Art Kryptowährung, deren Wert an einen anderen Vermögenswert wie eine Fiat-Währung oder Gold gekoppelt ist, um einen stabilen Preis aufrechtzuerhalten. Auf Osmosis sind USDC und USDT die wichtigsten Stablecoins zum Kaufen und Verkaufen von Vermögenswerten." + }, + "claimAll": "Alles beanspruchen", + "addFunds": "Guthaben hinzufügen", + "selectAnAssetTo": { + "buy": "Wählen Sie einen Vermögenswert zum Kauf aus", + "sell": "Wählen Sie einen Vermögenswert zum Verkauf aus" + }, + "payWith": "Bezahlen mit", + "receive": "Erhalten", + "swapFromAnotherAsset": "Swap von einem anderen Vermögenswert", + "connectYourWallet": "Verbinde dein Wallet", + "toSeeYourBalances": "um Ihre Guthaben einzusehen", + "searchAssets": "Assets suchen", + "marketPrice": "Marktpreis", + "below": "unten", + "above": "über", + "currentPrice": "derzeitiger Preis", + "whenDenomPriceIs": "Wenn der Preis {denom}", + "orderType": "Auftragsart", + "confirm": "Bestätigen", + "market": "Markt", + "limit": "Grenze", + "trade": "Handel", + "swapToAnotherAsset": "Tauschen Sie gegen ein anderes Asset", + "invalidPrice": "Ungültiger Preis", + "unavailable": "Nicht verfügbar für {denom}" } } diff --git a/packages/web/localizations/en.json b/packages/web/localizations/en.json index 55baa6cf96..e7eb802c72 100644 --- a/packages/web/localizations/en.json +++ b/packages/web/localizations/en.json @@ -411,6 +411,7 @@ "txTimedOutError": "Transaction timed out. Please retry.", "insufficientFee": "Insufficient balance for transaction fees. Please add funds to continue.", "noData": "No data", + "noOrderbook": "No Orderbook for Pair", "uhOhSomethingWentWrong": "Uh oh, something went wrong", "sorryForTheInconvenience": "Sorry for the inconvenience. Please try again later.", "startAgain": "Start again" @@ -447,7 +448,6 @@ "pools": "Pools", "stake": "Stake", "store": "Apps", - "swap": "Swap", "vote": "Vote", "featureRequests": "Feature Requests", "trade": "Pro Trading", @@ -798,9 +798,11 @@ "MAX": "MAX", "minimumSlippage": "Minimum received after slippage ({slippage})", "pool": "Pool #{id}", - "priceImpact": "Price Impact", + "priceImpact": "Market Impact", "routerTooltipFee": "Fee", "routerTooltipSpreadFactor": "Spread Factor", + "showDetails": "Show details", + "hideDetails": "Hide", "continueAnyway": "Continue Anyway", "warning": { "exceedsSpendLimit": "This swap exceeds your remaining spend limit for 1-Click Trading.", @@ -812,7 +814,17 @@ "title": "Transaction settings" }, "title": "Swap", - "dynamicSpreadFactor": "Dynamic" + "dynamicSpreadFactor": "Dynamic", + "gas": { + "oneClickTradingError": "Network fee cannot be estimated at this time. If the fee exceeds your 1-Click Trading network fee limit, you will be able to proceed with manual approval in your wallet.", + "error": "Network fee cannot be estimated at this time. Review network fees in your wallet before approving the transaction.", + "gasEstimationError": "Network fee cannot be estimated", + "unknown": "Unknown", + "additionalNetworkFee": "Additional network fee" + }, + "noRoutes": "No routes found", + "route": "route", + "routes": "routes" }, "walletSelect": { "gettingStarted": "Getting Started", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Older", "newer": "Newer" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Order", + "amount": "Amount", + "price": "Price", + "orderPlaced": "Order Placed", + "status": "Status" + }, + "emptyState": { + "title": "No recent orders", + "subtitle": "Your trade order history will appear here.", + "connectTitle": "Connect your wallet to see your order history", + "connectSubtitle": "Your past limit orders on Osmosis will appear here." + } + }, + "aboveMarket": { + "title": "Limit price above market price", + "description": "If you proceed your order may be filled at an undesirable price." + }, + "belowMarket": { + "title": "Limit price below market price", + "description": "If you proceed your order may be filled at an undesirable price." + }, + "enterAnAmountTo": "Enter an amount to", + "sell": "Sell", + "buy": "Buy", + "pay": "Pay", + "open": "Open", + "filled": "Filled", + "claimable": "Claimable", + "claimAndClose": "Claim and close", + "accept": "Accept", + "cancelled": "Cancelled", + "cancel": "Cancel", + "daysAgo": "{days}d ago", + "hoursAgo": "{hours}h ago", + "buyWith": "Buy with {coinA} or {coinB}", + "orderHistory": "Order history", + "tradeAnotherAssetOr": "Trade another asset for {coinA} or {coinB}", + "tradeAnotherAsset": "Trade another asset for {coinDenom}", + "sellAnAsset": "Sell an asset", + "swapAnAsset": "Swap an asset", + "of": "of", + "insufficientFunds": "Insufficient balance", + "startTrading": "Start trading", + "filledOrdersToClaim": "Filled orders to claim", + "tradeFees": "Trade fees (when order filled)", + "youNeed": "You need", + "stablecoin": "stablecoin", + "quoteUpdated": "Quote updated", + "fundsOsmosisToBuyAssets": "funds on Osmosis to buy assets.", + "chooseAnOption": "Choose an option to continue.", + "lowerSlippageToleranceRecommended": "A lower slippage tolerance is recommended.", + "tryHigherSlippage": "Try a higher maximum slippage tolerance.", + "reviewTrade": "Review trade", + "errors": { + "noAssetAvailable": "You don’t have any {coinName} funds on Osmosis to trade with. Choose an option to continue.", + "tradeMayResultInLossOfValue": "Your trade may result in significant loss of value", + "tradeMayNotExecuted": "Your trade may not be executed" + }, + "transferFromAnotherNetwork": "Transfer from another network or wallet", + "whatIsOrderClaim": { + "title": "What is order claiming?", + "description": "In the majority of cases, when a limit order is filled, funds are automatically added to your balance. However in some rare cases, funds must be manually claimed from the filled order.\n\nThis claiming process involves approving a single transaction in your wallet for all unclaimed filled orders which incurs a nominal network fee." + }, + "whatIsAStablecoin": { + "title": "What is a stablecoin?", + "description": "Stablecoins are a type of cryptocurrency whose value is pegged to another asset, such as a fiat currency or gold, to maintain a stable price. On Osmosis, the primary stablecoins for buying and selling assets are USDC and USDT." + }, + "claimAll": "Claim all", + "addFunds": "Add funds", + "selectAnAssetTo": { + "buy": "Select an asset to buy", + "sell": "Select an asset to sell" + }, + "payWith": "Pay with", + "receive": "Receive", + "swapFromAnotherAsset": "Swap from another asset", + "connectYourWallet": "Connect your wallet", + "toSeeYourBalances": "to see your balances", + "searchAssets": "Search assets", + "marketPrice": "market price", + "below": "below", + "above": "above", + "currentPrice": "current price", + "whenDenomPriceIs": "When {denom} price is", + "orderType": "Order type", + "confirm": "Confirm", + "market": "Market", + "limit": "Limit", + "trade": "Trade", + "swapToAnotherAsset": "Swap to another asset", + "invalidPrice": "Invalid price", + "unavailable": "Unavailable for {denom}" } } diff --git a/packages/web/localizations/es.json b/packages/web/localizations/es.json index a8d5f951d9..22635e6580 100644 --- a/packages/web/localizations/es.json +++ b/packages/web/localizations/es.json @@ -411,6 +411,7 @@ "txTimedOutError": "Se agotó el tiempo de espera de la transacción. Por favor, intenta de nuevo.", "insufficientFee": "Saldo insuficiente para tarifas de transacción. Por favor agregue fondos para continuar.", "noData": "Sin datos", + "noOrderbook": "Sin libro de pedidos para el par", "uhOhSomethingWentWrong": "Oh oh algo salió mal", "sorryForTheInconvenience": "Lo siento por los inconvenientes ocasionados. Por favor, inténtelo de nuevo más tarde.", "startAgain": "Empezar de nuevo" @@ -447,7 +448,6 @@ "pools": "Piscinas", "stake": "Abonar", "store": "Aplicaciones", - "swap": "Intercambio", "vote": "Votar", "featureRequests": "Solicitudes de Funciones", "trade": "Comercio profesional", @@ -798,9 +798,11 @@ "MAX": "Máximo", "minimumSlippage": "Mínimo recibido después del deslizamiento ({slippage})", "pool": "Piscina #{id}", - "priceImpact": "Impacto sobre el precio", + "priceImpact": "Impacto en el mercado", "routerTooltipFee": "Tarifa", "routerTooltipSpreadFactor": "Factor de dispersión", + "showDetails": "Mostrar detalles", + "hideDetails": "Esconder", "continueAnyway": "De todas maneras, continúe", "warning": { "exceedsSpendLimit": "Este intercambio excede su límite de gasto restante para 1-Click Trading.", @@ -812,7 +814,17 @@ "title": "Configuración de transacción" }, "title": "Intercambio", - "dynamicSpreadFactor": "Dinámica" + "dynamicSpreadFactor": "Dinámica", + "gas": { + "oneClickTradingError": "La tarifa de red no se puede estimar en este momento. Si la tarifa excede el límite de tarifa de la red 1-Click Trading, podrá continuar con la aprobación manual en su billetera.", + "error": "La tarifa de red no se puede estimar en este momento. Revise las tarifas de la red en su billetera antes de aprobar la transacción.", + "gasEstimationError": "No se puede estimar la tarifa de red", + "unknown": "Desconocido", + "additionalNetworkFee": "Tarifa de red adicional" + }, + "noRoutes": "No se encontraron rutas", + "route": "ruta", + "routes": "rutas" }, "walletSelect": { "gettingStarted": "Empezando", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Más viejo", "newer": "Más nuevo" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Orden", + "amount": "Cantidad", + "price": "Precio", + "orderPlaced": "Pedido realizado", + "status": "Estado" + }, + "emptyState": { + "title": "No hay pedidos recientes", + "subtitle": "Su historial de órdenes comerciales aparecerá aquí.", + "connectTitle": "Conecte su billetera para ver su historial de pedidos", + "connectSubtitle": "Tus órdenes límite anteriores en Osmosis aparecerán aquí." + } + }, + "aboveMarket": { + "title": "Precio límite por encima del precio de mercado", + "description": "Si continúa, su orden se procesará como una orden de mercado a precio de mercado." + }, + "belowMarket": { + "title": "Precio límite por debajo del precio de mercado", + "description": "Si continúa, su orden se procesará como una orden de mercado a precio de mercado." + }, + "enterAnAmountTo": "Introduzca una cantidad a", + "sell": "Vender", + "buy": "Comprar", + "pay": "Pagar", + "open": "Abierto", + "filled": "Completado", + "claimable": "Reclamable", + "claimAndClose": "Reclamar y cerrar", + "accept": "Aceptar", + "cancelled": "Cancelado", + "cancel": "Cancelar", + "daysAgo": "{days} hace d", + "hoursAgo": "{hours} hace h", + "buyWith": "Compre con {coinA} o {coinB}", + "orderHistory": "Historial de pedidos", + "tradeAnotherAssetOr": "Cambie otro activo por {coinA} o {coinB}", + "tradeAnotherAsset": "Cambie otro activo por {coinDenom}", + "sellAnAsset": "Vender un activo", + "swapAnAsset": "Intercambiar un activo", + "of": "de", + "insufficientFunds": "Fondos insuficientes", + "startTrading": "Comienza a negociar", + "filledOrdersToClaim": "Pedidos completados para reclamar", + "tradeFees": "Tarifas comerciales (cuando se completa el pedido)", + "youNeed": "Necesitas", + "stablecoin": "moneda estable", + "quoteUpdated": "Cotización actualizada", + "fundsOsmosisToBuyAssets": "fondos en Osmosis para comprar activos.", + "chooseAnOption": "Elija una opción para continuar.", + "lowerSlippageToleranceRecommended": "Se recomienda una menor tolerancia al deslizamiento.", + "tryHigherSlippage": "Pruebe con una tolerancia máxima de deslizamiento más alta.", + "reviewTrade": "Revisar el comercio", + "errors": { + "noAssetAvailable": "No tienes fondos {coinName} en Osmosis para operar. Elija una opción para continuar.", + "tradeMayResultInLossOfValue": "Su operación puede resultar en una pérdida significativa de valor", + "tradeMayNotExecuted": "Es posible que su operación no se ejecute" + }, + "transferFromAnotherNetwork": "Transferir desde otra red o billetera", + "whatIsOrderClaim": { + "title": "¿Qué reclama el pedido?", + "description": "En la mayoría de los casos, cuando se completa una orden limitada, los fondos se agregan automáticamente a su saldo. Sin embargo, en algunos casos excepcionales, los fondos deben reclamarse manualmente desde la orden completada.\nEste proceso de reclamo implica aprobar una única transacción en su billetera para todos los pedidos ejecutados no reclamados, lo que genera una tarifa de red nominal." + }, + "whatIsAStablecoin": { + "title": "¿Qué es una moneda estable?", + "description": "Las monedas estables son un tipo de criptomoneda cuyo valor está vinculado a otro activo, como una moneda fiduciaria o el oro, para mantener un precio estable. En Osmosis, las principales monedas estables para comprar y vender activos son USDC y USDT." + }, + "claimAll": "Reclamar todo", + "addFunds": "Añadir fondos", + "selectAnAssetTo": { + "buy": "Seleccione un activo para comprar", + "sell": "Seleccione un activo para vender" + }, + "payWith": "Pagar con", + "receive": "Recibir", + "swapFromAnotherAsset": "Intercambiar desde otro activo", + "connectYourWallet": "Conecta tu billetera", + "toSeeYourBalances": "para ver tus saldos", + "searchAssets": "Buscar activos", + "marketPrice": "precio de mercado", + "below": "abajo", + "above": "arriba", + "currentPrice": "precio actual", + "whenDenomPriceIs": "Cuando el precio {denom} es", + "orderType": "Tipo de orden", + "confirm": "Confirmar", + "market": "Mercado", + "limit": "Límite", + "trade": "Comercio", + "swapToAnotherAsset": "Cambiar a otro activo", + "invalidPrice": "Precio no válido", + "unavailable": "No disponible para {denom}" } } diff --git a/packages/web/localizations/fa.json b/packages/web/localizations/fa.json index 3d1da2f2c1..26a92046ac 100644 --- a/packages/web/localizations/fa.json +++ b/packages/web/localizations/fa.json @@ -411,6 +411,7 @@ "txTimedOutError": "زمان معامله تمام شد. لطفا دوباره امتحان کنید.", "insufficientFee": "موجودی ناکافی برای کارمزد تراکنش لطفاً برای ادامه بودجه اضافه کنید.", "noData": "اطلاعاتی وجود ندارد", + "noOrderbook": "بدون دفترچه سفارش برای جفت", "uhOhSomethingWentWrong": "اوه اوه، مشکلی پیش آمد", "sorryForTheInconvenience": "با عرض پوزش برای ناراحتی. لطفاً بعداً دوباره امتحان کنید.", "startAgain": "دوباره شروع کن" @@ -447,7 +448,6 @@ "pools": "استخرها", "stake": "سپرده گذاری", "store": "برنامه ها", - "swap": "تبدیل", "vote": "انتخابات", "featureRequests": "درخواست‌های ویژگی", "trade": "تجارت حرفه ای", @@ -798,9 +798,11 @@ "MAX": "همه", "minimumSlippage": "حداقل تغییرات قابل قبول ({slippage})", "pool": "استخر #{id}", - "priceImpact": "تاثیر قیمت", + "priceImpact": "تاثیر بازار", "routerTooltipFee": "کارمزد", "routerTooltipSpreadFactor": "فاکتور گسترش", + "showDetails": "نمایش جزئیات", + "hideDetails": "پنهان شدن", "continueAnyway": "ادامه دادن به هر طریق", "warning": { "exceedsSpendLimit": "این مبادله از حد باقیمانده هزینه شما برای تجارت 1 کلیک بیشتر است.", @@ -812,7 +814,17 @@ "title": "تنظیمات تراکنش" }, "title": "تبدیل", - "dynamicSpreadFactor": "پویا" + "dynamicSpreadFactor": "پویا", + "gas": { + "oneClickTradingError": "در حال حاضر نمی توان هزینه شبکه را تخمین زد. اگر کارمزد از حد کارمزد شبکه تجارت 1 کلیک شما بیشتر شود، می‌توانید با تأیید دستی در کیف پول خود ادامه دهید.", + "error": "در حال حاضر نمی توان هزینه شبکه را تخمین زد. قبل از تأیید تراکنش، کارمزدهای شبکه را در کیف پول خود بررسی کنید.", + "gasEstimationError": "هزینه شبکه قابل تخمین نیست", + "unknown": "ناشناخته", + "additionalNetworkFee": "هزینه شبکه اضافی" + }, + "noRoutes": "هیچ مسیری پیدا نشد", + "route": "مسیر", + "routes": "مسیرها" }, "walletSelect": { "gettingStarted": "شروع شدن", @@ -1198,5 +1210,101 @@ "pagination": { "older": "مسن تر", "newer": "جدیدتر" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "سفارش", + "amount": "میزان", + "price": "قیمت", + "orderPlaced": "سفارش ثبت شد", + "status": "وضعیت" + }, + "emptyState": { + "title": "بدون سفارش اخیر", + "subtitle": "تاریخچه سفارش تجارت شما در اینجا ظاهر می شود.", + "connectTitle": "کیف پول خود را وصل کنید تا تاریخچه سفارش خود را ببینید", + "connectSubtitle": "سفارشات محدود قبلی شما در Osmosis در اینجا ظاهر می شود." + } + }, + "aboveMarket": { + "title": "محدودیت قیمت بالاتر از قیمت بازار", + "description": "در صورت ادامه، سفارش شما به عنوان سفارش بازار با قیمت بازار پردازش می شود." + }, + "belowMarket": { + "title": "محدود کردن قیمت زیر قیمت بازار", + "description": "در صورت ادامه، سفارش شما به عنوان سفارش بازار با قیمت بازار پردازش می شود." + }, + "enterAnAmountTo": "مقداری را وارد کنید", + "sell": "فروش", + "buy": "خرید کنید", + "pay": "پرداخت", + "open": "باز کن", + "filled": "پر شده است", + "claimable": "قابل ادعا", + "claimAndClose": "ادعا کنید و ببندید", + "accept": "تایید کنید", + "cancelled": "لغو شد", + "cancel": "لغو کنید", + "daysAgo": "{days} روز قبل", + "hoursAgo": "{hours} ساعت قبل", + "buyWith": "خرید با {coinA} یا {coinB}", + "orderHistory": "تاریخچه سفارش ها", + "tradeAnotherAssetOr": "دارایی دیگری را برای {coinA} یا {coinB} مبادله کنید.", + "tradeAnotherAsset": "دارایی دیگری را برای {coinDenom} مبادله کنید", + "sellAnAsset": "فروش یک دارایی", + "swapAnAsset": "یک دارایی را مبادله کنید", + "of": "از", + "insufficientFunds": "بودجه ناکافی", + "startTrading": "تجارت را شروع کنید", + "filledOrdersToClaim": "سفارشات تکمیل شده برای ادعا", + "tradeFees": "هزینه های تجارت (در صورت تکمیل سفارش)", + "youNeed": "تو نیاز داری", + "stablecoin": "استیبل کوین", + "quoteUpdated": "نقل قول به روز شد", + "fundsOsmosisToBuyAssets": "سرمایه در اسموز برای خرید دارایی.", + "chooseAnOption": "گزینه ای را برای ادامه انتخاب کنید.", + "lowerSlippageToleranceRecommended": "تحمل لغزش کمتر توصیه می شود.", + "tryHigherSlippage": "حداکثر تحمل لغزش را امتحان کنید.", + "reviewTrade": "تجارت را بررسی کنید", + "errors": { + "noAssetAvailable": "شما هیچ گونه وجوهی در Osmosis برای تجارت با {coinName} ندارید. گزینه ای را برای ادامه انتخاب کنید.", + "tradeMayResultInLossOfValue": "تجارت شما ممکن است منجر به از دست دادن قابل توجه ارزش شود", + "tradeMayNotExecuted": "معامله شما ممکن است اجرا نشود" + }, + "transferFromAnotherNetwork": "انتقال از شبکه یا کیف پول دیگر", + "whatIsOrderClaim": { + "title": "ادعای سفارش چیست؟", + "description": "در اکثر موارد، هنگامی که یک سفارش محدود پر می شود، وجوه به طور خودکار به موجودی شما اضافه می شود. اما در برخی موارد نادر، وجوه باید به صورت دستی از سفارش پر شده درخواست شود.\nاین فرآیند ادعا شامل تأیید یک تراکنش واحد در کیف پول شما برای تمام سفارشات تکمیل نشده است که مستلزم کارمزد اسمی شبکه است." + }, + "whatIsAStablecoin": { + "title": "استیبل کوین چیست؟", + "description": "استیبل کوین ها نوعی از ارزهای دیجیتال هستند که ارزش آن به دارایی دیگری مانند ارز فیات یا طلا متصل می شود تا قیمت آن ثابت بماند. در اسموز، استیبل کوین های اولیه برای خرید و فروش دارایی ها USDC و USDT هستند." + }, + "claimAll": "ادعای همه", + "addFunds": "اضافه کردن وجوه", + "selectAnAssetTo": { + "buy": "دارایی را برای خرید انتخاب کنید", + "sell": "دارایی را برای فروش انتخاب کنید" + }, + "payWith": "پرداخت با", + "receive": "دريافت كردن", + "swapFromAnotherAsset": "تعویض از دارایی دیگر", + "connectYourWallet": "کیف پول خود را وصل کنید", + "toSeeYourBalances": "برای دیدن موجودی خود", + "searchAssets": "جستجوی دارایی ها", + "marketPrice": "قیمت بازار", + "below": "زیر", + "above": "در بالا", + "currentPrice": "قیمت فعلی", + "whenDenomPriceIs": "وقتی قیمت {denom} است", + "orderType": "نوع سفارش", + "confirm": "تایید", + "market": "بازار", + "limit": "حد", + "trade": "تجارت", + "swapToAnotherAsset": "تعویض به دارایی دیگر", + "invalidPrice": "قیمت نامعتبر", + "unavailable": "برای {denom} در دسترس نیست" } } diff --git a/packages/web/localizations/fr.json b/packages/web/localizations/fr.json index 621ae5b0f8..cbbd6eceb2 100644 --- a/packages/web/localizations/fr.json +++ b/packages/web/localizations/fr.json @@ -411,6 +411,7 @@ "txTimedOutError": "La transaction a expiré. Veuillez réessayer.", "insufficientFee": "Solde insuffisant pour les frais de transaction. Veuillez ajouter des fonds pour continuer.", "noData": "Pas de données", + "noOrderbook": "Pas de carnet de commandes pour la paire", "uhOhSomethingWentWrong": "Oh oh, quelque chose s'est mal passé", "sorryForTheInconvenience": "Désolé pour le dérangement. Veuillez réessayer plus tard.", "startAgain": "Recommencer" @@ -447,7 +448,6 @@ "pools": "Bassins", "stake": "Stake", "store": "Applications", - "swap": "Échanger", "vote": "Voter", "featureRequests": "Requêtes de nouvelles fonctionnalités", "trade": "Commerce professionnel", @@ -798,9 +798,11 @@ "MAX": "MAX", "minimumSlippage": "Minimum reçu après glissement ({slippage})", "pool": "Bassin n°{id}", - "priceImpact": "Impact du prix", + "priceImpact": "Impact sur le marché", "routerTooltipFee": "Frais", "routerTooltipSpreadFactor": "Facteur de propagation", + "showDetails": "Afficher les détails", + "hideDetails": "Cacher", "continueAnyway": "Continuer quand même", "warning": { "exceedsSpendLimit": "Cet échange dépasse votre limite de dépenses restantes pour le trading en 1 clic.", @@ -812,7 +814,17 @@ "title": "Paramètres de transaction" }, "title": "Échanger", - "dynamicSpreadFactor": "Dynamique" + "dynamicSpreadFactor": "Dynamique", + "gas": { + "oneClickTradingError": "Les frais de réseau ne peuvent pas être estimés pour le moment. Si les frais dépassent la limite des frais de votre réseau 1-Click Trading, vous pourrez procéder à une approbation manuelle dans votre portefeuille.", + "error": "Les frais de réseau ne peuvent pas être estimés pour le moment. Vérifiez les frais de réseau dans votre portefeuille avant d'approuver la transaction.", + "gasEstimationError": "Les frais de réseau ne peuvent pas être estimés", + "unknown": "Inconnu", + "additionalNetworkFee": "Frais de réseau supplémentaires" + }, + "noRoutes": "Aucun itinéraire trouvé", + "route": "itinéraire", + "routes": "itinéraires" }, "walletSelect": { "gettingStarted": "Commencer", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Plus vieux", "newer": "Plus récent" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Commande", + "amount": "Montant", + "price": "Prix", + "orderPlaced": "Commande passée", + "status": "Statut" + }, + "emptyState": { + "title": "Aucune commande récente", + "subtitle": "L’historique de vos commandes commerciales apparaîtra ici.", + "connectTitle": "Connectez votre portefeuille pour voir l'historique de vos commandes", + "connectSubtitle": "Vos ordres limités passés sur Osmosis apparaîtront ici." + } + }, + "aboveMarket": { + "title": "Prix limite au-dessus du prix du marché", + "description": "Si vous continuez, votre ordre sera traité comme un ordre au marché au prix du marché." + }, + "belowMarket": { + "title": "Prix limite en dessous du prix du marché", + "description": "Si vous continuez, votre ordre sera traité comme un ordre au marché au prix du marché." + }, + "enterAnAmountTo": "Entrez un montant à", + "sell": "Vendre", + "buy": "Acheter", + "pay": "Payer", + "open": "Ouvrir", + "filled": "Rempli", + "claimable": "Réclamable", + "claimAndClose": "Réclamez et clôturez", + "accept": "Accepter", + "cancelled": "Annulé", + "cancel": "Annuler", + "daysAgo": "{days} il y a 4 jours", + "hoursAgo": "{hours} il y a h", + "buyWith": "Achetez avec {coinA} ou {coinB}", + "orderHistory": "Historique des commandes", + "tradeAnotherAssetOr": "Échangez un autre actif contre {coinA} ou {coinB}", + "tradeAnotherAsset": "Échangez un autre actif contre {coinDenom}", + "sellAnAsset": "Vendre un actif", + "swapAnAsset": "Échanger un actif", + "of": "de", + "insufficientFunds": "Fonds insuffisants", + "startTrading": "Commencer à trader", + "filledOrdersToClaim": "Commandes exécutées à réclamer", + "tradeFees": "Frais de transaction (lorsque la commande est exécutée)", + "youNeed": "Vous avez besoin", + "stablecoin": "pièce stable", + "quoteUpdated": "Devis mis à jour", + "fundsOsmosisToBuyAssets": "fonds sur Osmosis pour acheter des actifs.", + "chooseAnOption": "Choisissez une option pour continuer.", + "lowerSlippageToleranceRecommended": "Une tolérance de glissement plus faible est recommandée.", + "tryHigherSlippage": "Essayez une tolérance de glissement maximale plus élevée.", + "reviewTrade": "Revoir le commerce", + "errors": { + "noAssetAvailable": "Vous n'avez aucun fonds {coinName} sur Osmosis avec lequel échanger. Choisissez une option pour continuer.", + "tradeMayResultInLossOfValue": "Votre transaction peut entraîner une perte de valeur importante", + "tradeMayNotExecuted": "Votre transaction pourrait ne pas être exécutée" + }, + "transferFromAnotherNetwork": "Transfert depuis un autre réseau ou portefeuille", + "whatIsOrderClaim": { + "title": "Qu'est-ce qu'une réclamation de commande ?", + "description": "Dans la majorité des cas, lorsqu'un ordre limité est exécuté, les fonds sont automatiquement ajoutés à votre solde. Cependant, dans de rares cas, les fonds doivent être réclamés manuellement à partir de la commande exécutée.\nCe processus de réclamation consiste à approuver une seule transaction dans votre portefeuille pour toutes les commandes exécutées non réclamées, ce qui entraîne des frais de réseau nominaux." + }, + "whatIsAStablecoin": { + "title": "Qu’est-ce qu’un stablecoin ?", + "description": "Les Stablecoins sont un type de crypto-monnaie dont la valeur est liée à un autre actif, comme une monnaie fiduciaire ou de l'or, pour maintenir un prix stable. Sur Osmosis, les principales pièces stables pour l'achat et la vente d'actifs sont l'USDC et l'USDT." + }, + "claimAll": "Réclamez tout", + "addFunds": "Ajouter des fonds", + "selectAnAssetTo": { + "buy": "Sélectionnez un actif à acheter", + "sell": "Sélectionnez un actif à vendre" + }, + "payWith": "Payer avec", + "receive": "Recevoir", + "swapFromAnotherAsset": "Échanger depuis un autre actif", + "connectYourWallet": "Connectez votre portefeuille", + "toSeeYourBalances": "pour voir vos soldes", + "searchAssets": "Rechercher des ressources", + "marketPrice": "prix du marché", + "below": "ci-dessous", + "above": "au-dessus de", + "currentPrice": "prix actuel", + "whenDenomPriceIs": "Lorsque le prix {denom} est", + "orderType": "Type de commande", + "confirm": "Confirmer", + "market": "Marché", + "limit": "Limite", + "trade": "Commerce", + "swapToAnotherAsset": "Échanger vers un autre actif", + "invalidPrice": "Prix invalide", + "unavailable": "Indisponible pour {denom}" } } diff --git a/packages/web/localizations/gu.json b/packages/web/localizations/gu.json index 144f8e5a52..abe12d102c 100644 --- a/packages/web/localizations/gu.json +++ b/packages/web/localizations/gu.json @@ -411,6 +411,7 @@ "txTimedOutError": "વ્યવહારનો સમય સમાપ્ત થયો. કૃપા કરીને ફરી પ્રયાસ કરો.", "insufficientFee": "વ્યવહાર શુલ્ક માટે અપર્યાપ્ત બેલેન્સ. ચાલુ રાખવા માટે કૃપા કરીને ભંડોળ ઉમેરો.", "noData": "કોઈ ડેટા નથી", + "noOrderbook": "જોડી માટે કોઈ ઓર્ડરબુક નથી", "uhOhSomethingWentWrong": "ઓહ, કંઈક ખોટું થયું", "sorryForTheInconvenience": "અસુવીધી બદલ માફી. પછીથી ફરી પ્રયત્ન કરો.", "startAgain": "ફરી શરૂ કરો" @@ -447,7 +448,6 @@ "pools": "પૂલ", "stake": "દાવ", "store": "એપ્સ", - "swap": "સ્વેપ", "vote": "મત આપો", "featureRequests": "સુવિધા વિનંતીઓ", "trade": "પ્રો ટ્રેડિંગ", @@ -798,9 +798,11 @@ "MAX": "MAX", "minimumSlippage": "સ્લિપેજ પછી પ્રાપ્ત ન્યૂનતમ ( {slippage} )", "pool": "પૂલ # {id}", - "priceImpact": "ભાવની અસર", + "priceImpact": "બજારની અસર", "routerTooltipFee": "ફી", "routerTooltipSpreadFactor": "સ્પ્રેડ ફેક્ટર", + "showDetails": "વિગતો બતાવો", + "hideDetails": "છુપાવો", "continueAnyway": "કોઈપણ રીતે ચાલુ રાખો", "warning": { "exceedsSpendLimit": "આ સ્વેપ 1-ક્લિક ટ્રેડિંગ માટે તમારી બાકીની ખર્ચ મર્યાદાને ઓળંગે છે.", @@ -812,7 +814,17 @@ "title": "વ્યવહાર સેટિંગ્સ" }, "title": "સ્વેપ", - "dynamicSpreadFactor": "ગતિશીલ" + "dynamicSpreadFactor": "ગતિશીલ", + "gas": { + "oneClickTradingError": "આ સમયે નેટવર્ક ફીનો અંદાજ લગાવી શકાતો નથી. જો ફી તમારી 1-ક્લિક ટ્રેડિંગ નેટવર્ક ફી મર્યાદા કરતાં વધી જાય, તો તમે તમારા વૉલેટમાં મેન્યુઅલ મંજૂરી સાથે આગળ વધી શકશો.", + "error": "આ સમયે નેટવર્ક ફીનો અંદાજ લગાવી શકાતો નથી. વ્યવહારને મંજૂરી આપતા પહેલા તમારા વૉલેટમાં નેટવર્ક ફીની સમીક્ષા કરો.", + "gasEstimationError": "નેટવર્ક ફીનો અંદાજ લગાવી શકાતો નથી", + "unknown": "અજ્ઞાત", + "additionalNetworkFee": "વધારાની નેટવર્ક ફી" + }, + "noRoutes": "કોઈ રૂટ મળ્યા નથી", + "route": "માર્ગ", + "routes": "માર્ગો" }, "walletSelect": { "gettingStarted": "શરૂઆત કરવી", @@ -1198,5 +1210,101 @@ "pagination": { "older": "જૂની", "newer": "નવું" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "ઓર્ડર", + "amount": "રકમ", + "price": "કિંમત", + "orderPlaced": "ઓર્ડર આપ્યો", + "status": "સ્થિતિ" + }, + "emptyState": { + "title": "કોઈ તાજેતરના ઓર્ડર નથી", + "subtitle": "તમારો ટ્રેડ ઓર્ડર ઇતિહાસ અહીં દેખાશે.", + "connectTitle": "તમારો ઓર્ડર ઇતિહાસ જોવા માટે તમારા વૉલેટને કનેક્ટ કરો", + "connectSubtitle": "ઓસ્મોસિસ પરના તમારા ભૂતકાળના મર્યાદા ઓર્ડર અહીં દેખાશે." + } + }, + "aboveMarket": { + "title": "બજાર કિંમત ઉપર મર્યાદા કિંમત", + "description": "જો તમે આગળ વધો છો, તો તમારા ઓર્ડરની બજાર કિંમત પર બજાર ઓર્ડર તરીકે પ્રક્રિયા કરવામાં આવશે." + }, + "belowMarket": { + "title": "બજાર કિંમત નીચે કિંમત મર્યાદા", + "description": "જો તમે આગળ વધો છો, તો તમારા ઓર્ડરની બજાર કિંમત પર બજાર ઓર્ડર તરીકે પ્રક્રિયા કરવામાં આવશે." + }, + "enterAnAmountTo": "માટે રકમ દાખલ કરો", + "sell": "વેચો", + "buy": "ખરીદો", + "pay": "પે", + "open": "ખુલ્લા", + "filled": "ભરેલ", + "claimable": "દાવો કરવા યોગ્ય", + "claimAndClose": "દાવો કરો અને બંધ કરો", + "accept": "સ્વીકારો", + "cancelled": "રદ કરેલ", + "cancel": "રદ કરો", + "daysAgo": "{days} દિવસ પહેલા", + "hoursAgo": "{hours} કલાક પહેલા", + "buyWith": "{coinA} અથવા {coinB} વડે ખરીદો", + "orderHistory": "ઓર્ડર ઇતિહાસ", + "tradeAnotherAssetOr": "{coinA} અથવા {coinB} માટે બીજી સંપત્તિનો વેપાર કરો", + "tradeAnotherAsset": "{coinDenom} માટે બીજી સંપત્તિનો વેપાર કરો", + "sellAnAsset": "સંપત્તિ વેચો", + "swapAnAsset": "સંપત્તિ સ્વેપ કરો", + "of": "ના", + "insufficientFunds": "અપૂરતું ભંડોળ", + "startTrading": "વેપાર શરૂ કરો", + "filledOrdersToClaim": "દાવો કરવાના આદેશો ભર્યા", + "tradeFees": "વેપાર ફી (જ્યારે ઓર્ડર ભરવામાં આવે છે)", + "youNeed": "તમને જરૂર છે", + "stablecoin": "stablecoin", + "quoteUpdated": "અવતરણ અપડેટ કર્યું", + "fundsOsmosisToBuyAssets": "અસ્કયામતો ખરીદવા માટે ઓસ્મોસિસ પર ભંડોળ.", + "chooseAnOption": "ચાલુ રાખવા માટે એક વિકલ્પ પસંદ કરો.", + "lowerSlippageToleranceRecommended": "ઓછી સ્લિપેજ સહિષ્ણુતાની ભલામણ કરવામાં આવે છે.", + "tryHigherSlippage": "ઉચ્ચ મહત્તમ સ્લિપેજ સહિષ્ણુતાનો પ્રયાસ કરો.", + "reviewTrade": "વેપારની સમીક્ષા કરો", + "errors": { + "noAssetAvailable": "તમારી પાસે ઓસ્મોસિસ પર વેપાર કરવા માટે કોઈ {coinName} ફંડ નથી. ચાલુ રાખવા માટે એક વિકલ્પ પસંદ કરો.", + "tradeMayResultInLossOfValue": "તમારા વેપારના પરિણામે મૂલ્યમાં નોંધપાત્ર નુકસાન થઈ શકે છે", + "tradeMayNotExecuted": "તમારો વેપાર કદાચ અમલમાં ન આવે" + }, + "transferFromAnotherNetwork": "બીજા નેટવર્ક અથવા વૉલેટમાંથી ટ્રાન્સફર કરો", + "whatIsOrderClaim": { + "title": "ઓર્ડરનો દાવો શું છે?", + "description": "મોટા ભાગના કિસ્સાઓમાં, જ્યારે મર્યાદા ઓર્ડર ભરવામાં આવે છે, ત્યારે ભંડોળ આપમેળે તમારા બેલેન્સમાં ઉમેરવામાં આવે છે. જો કે કેટલાક દુર્લભ કિસ્સાઓમાં, ભરેલા ઓર્ડરમાંથી મેન્યુઅલી ભંડોળનો દાવો કરવો આવશ્યક છે.\nઆ દાવો કરવાની પ્રક્રિયામાં તમારા વૉલેટમાં એક જ વ્યવહારને મંજૂર કરવાનો સમાવેશ થાય છે, જેના માટે નજીવી નેટવર્ક ફી લેવામાં આવે છે." + }, + "whatIsAStablecoin": { + "title": "સ્ટેબલકોઈન શું છે?", + "description": "સ્ટેબલકોઇન્સ એ ક્રિપ્ટોકરન્સીનો એક પ્રકાર છે જેની કિંમત સ્થિર કિંમત જાળવવા માટે અન્ય સંપત્તિ, જેમ કે ફિયાટ ચલણ અથવા સોનામાં મૂકવામાં આવે છે. ઓસ્મોસિસ પર, અસ્કયામતો ખરીદવા અને વેચવા માટેના પ્રાથમિક સ્ટેબલકોઈન્સ USDC અને USDT છે." + }, + "claimAll": "બધાનો દાવો કરો", + "addFunds": "ભંડોળ ઉમેરો", + "selectAnAssetTo": { + "buy": "ખરીદવા માટે સંપત્તિ પસંદ કરો", + "sell": "વેચવા માટે સંપત્તિ પસંદ કરો" + }, + "payWith": "સાથે ચૂકવો", + "receive": "પ્રાપ્ત કરો", + "swapFromAnotherAsset": "અન્ય સંપત્તિમાંથી સ્વેપ કરો", + "connectYourWallet": "તમારું વૉલેટ કનેક્ટ કરો", + "toSeeYourBalances": "તમારા બેલેન્સ જોવા માટે", + "searchAssets": "સંપત્તિ શોધો", + "marketPrice": "બજાર કિંમત", + "below": "નીચે", + "above": "ઉપર", + "currentPrice": "વર્તમાન ભાવ", + "whenDenomPriceIs": "જ્યારે {denom} કિંમત હોય છે", + "orderType": "ઓર્ડરનો પ્રકાર", + "confirm": "પુષ્ટિ કરો", + "market": "બજાર", + "limit": "મર્યાદા", + "trade": "વેપાર", + "swapToAnotherAsset": "અન્ય સંપત્તિમાં સ્વેપ કરો", + "invalidPrice": "અમાન્ય કિંમત", + "unavailable": "{denom} માટે અનુપલબ્ધ" } } diff --git a/packages/web/localizations/hi.json b/packages/web/localizations/hi.json index 1f44a97a48..618eea4c46 100644 --- a/packages/web/localizations/hi.json +++ b/packages/web/localizations/hi.json @@ -411,6 +411,7 @@ "txTimedOutError": "लेन-देन का समय समाप्त हो गया. कृपया फिर से कोशिश करें।", "insufficientFee": "लेन-देन शुल्क के लिए अपर्याप्त शेष. कृपया जारी रखने के लिए धनराशि जोड़ें।", "noData": "कोई डेटा नहीं", + "noOrderbook": "जोड़ी के लिए कोई ऑर्डरबुक नहीं", "uhOhSomethingWentWrong": "ओह ओह, कुछ ग़लत हो गया", "sorryForTheInconvenience": "असुविधा के लिए खेद है। कृपया बाद में पुनः प्रयास करें।", "startAgain": "फिर से शुरू करें" @@ -447,7 +448,6 @@ "pools": "ताल", "stake": "दांव", "store": "ऐप्स", - "swap": "बदलना", "vote": "वोट", "featureRequests": "सुविधा का अनुरोध", "trade": "प्रो ट्रेडिंग", @@ -798,9 +798,11 @@ "MAX": "अधिकतम", "minimumSlippage": "स्लिपेज के बाद प्राप्त न्यूनतम राशि ( {slippage} )", "pool": "पूल # {id}", - "priceImpact": "मूल्य प्रभाव", + "priceImpact": "बाजार प्रभाव", "routerTooltipFee": "शुल्क", "routerTooltipSpreadFactor": "प्रसार कारक", + "showDetails": "प्रदर्शन का विवरण", + "hideDetails": "छिपाना", "continueAnyway": "फिर भी जारी रखें", "warning": { "exceedsSpendLimit": "यह स्वैप 1-क्लिक ट्रेडिंग के लिए आपकी शेष खर्च सीमा से अधिक है।", @@ -812,7 +814,17 @@ "title": "लेन-देन सेटिंग" }, "title": "बदलना", - "dynamicSpreadFactor": "गतिशील" + "dynamicSpreadFactor": "गतिशील", + "gas": { + "oneClickTradingError": "इस समय नेटवर्क शुल्क का अनुमान नहीं लगाया जा सकता है। यदि शुल्क आपकी 1-क्लिक ट्रेडिंग नेटवर्क शुल्क सीमा से अधिक है, तो आप अपने वॉलेट में मैन्युअल अनुमोदन के साथ आगे बढ़ सकेंगे।", + "error": "इस समय नेटवर्क शुल्क का अनुमान नहीं लगाया जा सकता। लेन-देन को मंजूरी देने से पहले अपने वॉलेट में नेटवर्क शुल्क की समीक्षा करें।", + "gasEstimationError": "नेटवर्क शुल्क का अनुमान नहीं लगाया जा सकता", + "unknown": "अज्ञात", + "additionalNetworkFee": "अतिरिक्त नेटवर्क शुल्क" + }, + "noRoutes": "कोई मार्ग नहीं मिला", + "route": "मार्ग", + "routes": "मार्ग" }, "walletSelect": { "gettingStarted": "शुरू करना", @@ -1198,5 +1210,101 @@ "pagination": { "older": "पुराने", "newer": "नई" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "आदेश", + "amount": "मात्रा", + "price": "कीमत", + "orderPlaced": "आदेश रखा", + "status": "स्थिति" + }, + "emptyState": { + "title": "कोई हालिया ऑर्डर नहीं", + "subtitle": "आपका ट्रेड ऑर्डर इतिहास यहां दिखाई देगा।", + "connectTitle": "अपना ऑर्डर इतिहास देखने के लिए अपना वॉलेट कनेक्ट करें", + "connectSubtitle": "ओस्मोसिस पर आपके पिछले लिमिट ऑर्डर यहां दिखाई देंगे।" + } + }, + "aboveMarket": { + "title": "सीमा मूल्य बाजार मूल्य से ऊपर", + "description": "यदि आप आगे बढ़ते हैं, तो आपके ऑर्डर को बाजार मूल्य पर बाजार ऑर्डर के रूप में संसाधित किया जाएगा।" + }, + "belowMarket": { + "title": "सीमा मूल्य बाजार मूल्य से नीचे", + "description": "यदि आप आगे बढ़ते हैं, तो आपके ऑर्डर को बाजार मूल्य पर बाजार ऑर्डर के रूप में संसाधित किया जाएगा।" + }, + "enterAnAmountTo": "राशि दर्ज करें", + "sell": "बेचना", + "buy": "खरीदना", + "pay": "वेतन", + "open": "खुला", + "filled": "भरा हुआ", + "claimable": "दावा योग्य", + "claimAndClose": "दावा करें और बंद करें", + "accept": "स्वीकार करना", + "cancelled": "रद्द", + "cancel": "रद्द करना", + "daysAgo": "{days} दिन पहले", + "hoursAgo": "{hours} घंटे पहले", + "buyWith": "{coinA} या {coinB} के साथ खरीदें", + "orderHistory": "आदेश इतिहास", + "tradeAnotherAssetOr": "{coinA} या {coinB} के लिए किसी अन्य परिसंपत्ति का व्यापार करें", + "tradeAnotherAsset": "{coinDenom} के लिए किसी अन्य परिसंपत्ति का व्यापार करें", + "sellAnAsset": "एक परिसंपत्ति बेचें", + "swapAnAsset": "परिसंपत्ति की अदला-बदली करें", + "of": "का", + "insufficientFunds": "अपर्याप्त कोष", + "startTrading": "व्यापार शुरू करें", + "filledOrdersToClaim": "दावा करने के लिए भरे गए ऑर्डर", + "tradeFees": "व्यापार शुल्क (ऑर्डर भरने पर)", + "youNeed": "आप की जरूरत है", + "stablecoin": "स्थिर मुद्रा", + "quoteUpdated": "उद्धरण अद्यतन", + "fundsOsmosisToBuyAssets": "संपत्ति खरीदने के लिए ओस्मोसिस पर धन लगाया गया।", + "chooseAnOption": "जारी रखने के लिए कोई विकल्प चुनें.", + "lowerSlippageToleranceRecommended": "कम फिसलन सहनशीलता की सिफारिश की जाती है।", + "tryHigherSlippage": "उच्चतम अधिकतम फिसलन सहनशीलता का प्रयास करें।", + "reviewTrade": "व्यापार की समीक्षा करें", + "errors": { + "noAssetAvailable": "आपके पास Osmosis पर व्यापार करने के लिए कोई {coinName} फंड नहीं है। जारी रखने के लिए कोई विकल्प चुनें।", + "tradeMayResultInLossOfValue": "आपके व्यापार के परिणामस्वरूप मूल्य में महत्वपूर्ण हानि हो सकती है", + "tradeMayNotExecuted": "आपका व्यापार निष्पादित नहीं हो सकता" + }, + "transferFromAnotherNetwork": "किसी अन्य नेटवर्क या वॉलेट से स्थानांतरण", + "whatIsOrderClaim": { + "title": "आदेश क्या दावा कर रहा है?", + "description": "ज़्यादातर मामलों में, जब कोई लिमिट ऑर्डर भरा जाता है, तो फंड अपने आप आपके बैलेंस में जुड़ जाते हैं। हालाँकि, कुछ दुर्लभ मामलों में, भरे गए ऑर्डर से फंड को मैन्युअल रूप से क्लेम करना पड़ता है।\nइस दावा प्रक्रिया में आपके वॉलेट में सभी दावा रहित भरे गए ऑर्डरों के लिए एक एकल लेनदेन को मंजूरी देना शामिल है, जिस पर एक मामूली नेटवर्क शुल्क लगता है।" + }, + "whatIsAStablecoin": { + "title": "स्टेबलकॉइन क्या है?", + "description": "स्टेबलकॉइन एक प्रकार की क्रिप्टोकरेंसी है जिसका मूल्य स्थिर मूल्य बनाए रखने के लिए किसी अन्य परिसंपत्ति, जैसे कि फिएट मुद्रा या सोने से जुड़ा होता है। ऑस्मोसिस पर, संपत्ति खरीदने और बेचने के लिए प्राथमिक स्टेबलकॉइन USDC और USDT हैं।" + }, + "claimAll": "सभी का दावा करें", + "addFunds": "धन जोड़ें", + "selectAnAssetTo": { + "buy": "खरीदने के लिए एक परिसंपत्ति का चयन करें", + "sell": "बेचने के लिए एक परिसंपत्ति का चयन करें" + }, + "payWith": "के साथ भुगतान करें", + "receive": "प्राप्त करें", + "swapFromAnotherAsset": "किसी अन्य परिसंपत्ति से स्वैप करें", + "connectYourWallet": "अपना वॉलेट कनेक्ट करें", + "toSeeYourBalances": "अपना शेष देखने के लिए", + "searchAssets": "संपत्ति खोजें", + "marketPrice": "बाजार कीमत", + "below": "नीचे", + "above": "ऊपर", + "currentPrice": "मौजूदा कीमत", + "whenDenomPriceIs": "जब {denom} कीमत है", + "orderType": "आदेश प्रकार", + "confirm": "पुष्टि करना", + "market": "बाज़ार", + "limit": "आप LIMIT", + "trade": "व्यापार", + "swapToAnotherAsset": "किसी अन्य परिसंपत्ति में स्वैप करें", + "invalidPrice": "अमान्य मूल्य", + "unavailable": "{denom} के लिए अनुपलब्ध" } } diff --git a/packages/web/localizations/ja.json b/packages/web/localizations/ja.json index f73205ffc7..269e773812 100644 --- a/packages/web/localizations/ja.json +++ b/packages/web/localizations/ja.json @@ -411,6 +411,7 @@ "txTimedOutError": "トランザクションがタイムアウトしました。再試行してください。", "insufficientFee": "取引手数料の残高が不足しています。続行するには資金を追加してください。", "noData": "データなし", + "noOrderbook": "ペアの注文書はありません", "uhOhSomethingWentWrong": "ああ、何か問題が発生しました", "sorryForTheInconvenience": "ご不便をおかけして申し訳ございません。しばらくしてからもう一度お試しください。", "startAgain": "再開する" @@ -447,7 +448,6 @@ "pools": "プール", "stake": "ステーク", "store": "アプリ", - "swap": "スワップ", "vote": "投票する", "featureRequests": "機能リクエスト", "trade": "プロトレーディング", @@ -798,9 +798,11 @@ "MAX": "マックス", "minimumSlippage": "スリッページ後に受信した最小値 ( {slippage} )", "pool": "プール番号{id}", - "priceImpact": "価格への影響", + "priceImpact": "市場への影響", "routerTooltipFee": "手数料", "routerTooltipSpreadFactor": "スプレッドファクター", + "showDetails": "詳細を表示", + "hideDetails": "隠れる", "continueAnyway": "とにかく続けます", "warning": { "exceedsSpendLimit": "このスワップは、1-Click 取引の残りの支出制限を超えています。", @@ -812,7 +814,17 @@ "title": "トランザクション設定" }, "title": "スワップ", - "dynamicSpreadFactor": "動的" + "dynamicSpreadFactor": "動的", + "gas": { + "oneClickTradingError": "現時点ではネットワーク手数料を見積もることはできません。手数料が 1 クリック取引ネットワーク手数料の上限を超える場合は、ウォレットで手動で承認を進めることができます。", + "error": "現時点ではネットワーク手数料を見積もることはできません。取引を承認する前に、ウォレットでネットワーク手数料を確認してください。", + "gasEstimationError": "ネットワーク料金は見積もれません", + "unknown": "未知", + "additionalNetworkFee": "追加のネットワーク料金" + }, + "noRoutes": "ルートが見つかりません", + "route": "ルート", + "routes": "ルート" }, "walletSelect": { "gettingStarted": "はじめる", @@ -1198,5 +1210,101 @@ "pagination": { "older": "古い", "newer": "新しい" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "注文", + "amount": "額", + "price": "価格", + "orderPlaced": "注文済み", + "status": "状態" + }, + "emptyState": { + "title": "最近の注文はありません", + "subtitle": "取引注文履歴がここに表示されます。", + "connectTitle": "ウォレットを接続すると注文履歴が表示されます", + "connectSubtitle": "Osmosis での過去の指値注文がここに表示されます。" + } + }, + "aboveMarket": { + "title": "市場価格を上回る制限価格", + "description": "続行すると、注文は市場価格での成行注文として処理されます。" + }, + "belowMarket": { + "title": "市場価格以下の制限価格", + "description": "続行すると、注文は市場価格での成行注文として処理されます。" + }, + "enterAnAmountTo": "金額を入力してください", + "sell": "売る", + "buy": "買う", + "pay": "支払う", + "open": "開ける", + "filled": "満たされた", + "claimable": "請求可能", + "claimAndClose": "請求して終了", + "accept": "受け入れる", + "cancelled": "キャンセル", + "cancel": "キャンセル", + "daysAgo": "{days}日前", + "hoursAgo": "{hours}前", + "buyWith": "{coinA}または{coinB}で購入", + "orderHistory": "注文履歴", + "tradeAnotherAssetOr": "別の資産を{coinA}または{coinB}と交換する", + "tradeAnotherAsset": "別の資産を{coinDenom}と交換する", + "sellAnAsset": "資産を売る", + "swapAnAsset": "資産を交換する", + "of": "の", + "insufficientFunds": "残高不足", + "startTrading": "取引を始める", + "filledOrdersToClaim": "請求する注文を完了", + "tradeFees": "取引手数料(注文成立時)", + "youNeed": "必要なのは", + "stablecoin": "ステーブルコイン", + "quoteUpdated": "引用を更新しました", + "fundsOsmosisToBuyAssets": "資産を購入するための資金をOsmosisに提供します。", + "chooseAnOption": "続行するにはオプションを選択してください。", + "lowerSlippageToleranceRecommended": "滑り許容値を低くすることをお勧めします。", + "tryHigherSlippage": "最大スリップ許容値を高く設定してみてください。", + "reviewTrade": "取引のレビュー", + "errors": { + "noAssetAvailable": "Osmosis には取引できる{coinName}資金がありません。続行するにはオプションを選択してください。", + "tradeMayResultInLossOfValue": "取引により価値が大幅に損なわれる可能性があります", + "tradeMayNotExecuted": "取引が実行されない可能性があります" + }, + "transferFromAnotherNetwork": "別のネットワークまたはウォレットからの送金", + "whatIsOrderClaim": { + "title": "注文請求とは何ですか?", + "description": "ほとんどの場合、指値注文が約定すると、資金は自動的に残高に追加されます。ただし、まれに、約定した注文から資金を手動で請求する必要がある場合もあります。この請求プロセスでは、未請求のすべての約定済み注文に対してウォレット内の単一のトランザクションを承認する必要があり、少額のネットワーク料金が発生します。" + }, + "whatIsAStablecoin": { + "title": "ステーブルコインとは何ですか?", + "description": "ステーブルコインは、法定通貨や金などの別の資産に価値が固定され、安定した価格を維持する暗号通貨の一種です。Osmosis では、資産の売買に使用される主なステーブルコインは USDC と USDT です。" + }, + "claimAll": "すべてを主張する", + "addFunds": "資金を追加", + "selectAnAssetTo": { + "buy": "購入する資産を選択", + "sell": "売却する資産を選択する" + }, + "payWith": "お支払い方法", + "receive": "受け取る", + "swapFromAnotherAsset": "別の資産からのスワップ", + "connectYourWallet": "ウォレットを接続する", + "toSeeYourBalances": "残高を確認する", + "searchAssets": "アセットを検索", + "marketPrice": "市場価格", + "below": "下に", + "above": "その上", + "currentPrice": "現在の価格", + "whenDenomPriceIs": "{denom}価格が", + "orderType": "注文タイプ", + "confirm": "確認する", + "market": "市場", + "limit": "制限", + "trade": "貿易", + "swapToAnotherAsset": "別の資産に交換する", + "invalidPrice": "無効な価格", + "unavailable": "{denom}では利用できません" } } diff --git a/packages/web/localizations/ko.json b/packages/web/localizations/ko.json index d505ec6762..0933e42645 100644 --- a/packages/web/localizations/ko.json +++ b/packages/web/localizations/ko.json @@ -411,6 +411,7 @@ "txTimedOutError": "거래 시간이 초과되었습니다. 다시 시도해 주세요.", "insufficientFee": "거래 수수료 잔액이 부족합니다. 계속하려면 자금을 추가하세요.", "noData": "데이터 없음", + "noOrderbook": "페어 주문서 없음", "uhOhSomethingWentWrong": "아, 뭔가 잘못됐어", "sorryForTheInconvenience": "불편을 드려 죄송합니다. 나중에 다시 시도 해주십시오.", "startAgain": "다시 시작" @@ -447,7 +448,6 @@ "pools": "풀", "stake": "스테이킹", "store": "앱", - "swap": "교환", "vote": "투표", "featureRequests": "기능 요청", "trade": "프로 트레이딩", @@ -798,9 +798,11 @@ "MAX": "최대", "minimumSlippage": "슬리피지 이후 받을 최소수량 ({slippage})", "pool": "풀 #{id}", - "priceImpact": "가격 변동", + "priceImpact": "시장 영향", "routerTooltipFee": "회비", "routerTooltipSpreadFactor": "스프레드 팩터", + "showDetails": "세부정보 표시", + "hideDetails": "숨다", "continueAnyway": "계속 진행", "warning": { "exceedsSpendLimit": "이 교환은 1-클릭 거래에 대한 남은 지출 한도를 초과합니다.", @@ -812,7 +814,17 @@ "title": "트랜잭션 설정" }, "title": "거래", - "dynamicSpreadFactor": "동적" + "dynamicSpreadFactor": "동적", + "gas": { + "oneClickTradingError": "현재로서는 네트워크 수수료를 추정할 수 없습니다. 수수료가 1-Click Trading 네트워크 수수료 한도를 초과하는 경우 지갑에서 수동 승인을 진행할 수 있습니다.", + "error": "현재로서는 네트워크 수수료를 추정할 수 없습니다. 거래를 승인하기 전에 지갑의 네트워크 수수료를 검토하세요.", + "gasEstimationError": "네트워크 요금을 추정할 수 없습니다.", + "unknown": "알려지지 않은", + "additionalNetworkFee": "추가 네트워크 요금" + }, + "noRoutes": "경로를 찾을 수 없습니다.", + "route": "노선", + "routes": "노선" }, "walletSelect": { "gettingStarted": "시작하기", @@ -1198,5 +1210,101 @@ "pagination": { "older": "이전", "newer": "최신" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "주문하다", + "amount": "양", + "price": "가격", + "orderPlaced": "주문 완료", + "status": "상태" + }, + "emptyState": { + "title": "최근 주문 없음", + "subtitle": "귀하의 거래 주문 내역이 여기에 표시됩니다.", + "connectTitle": "지갑을 연결하여 주문 내역을 확인하세요", + "connectSubtitle": "Osmosis의 과거 지정가 주문이 여기에 표시됩니다." + } + }, + "aboveMarket": { + "title": "시장가보다 높은 가격 제한", + "description": "계속 진행하시면 귀하의 주문은 시장가로 시장가 주문으로 처리됩니다." + }, + "belowMarket": { + "title": "가격을 시장가 이하로 제한", + "description": "계속 진행하시면 귀하의 주문은 시장가로 시장가 주문으로 처리됩니다." + }, + "enterAnAmountTo": "금액을 입력하세요.", + "sell": "팔다", + "buy": "구입하다", + "pay": "지불하다", + "open": "열려 있는", + "filled": "채우는", + "claimable": "청구 가능", + "claimAndClose": "청구 및 종료", + "accept": "수용하다", + "cancelled": "취소 된", + "cancel": "취소", + "daysAgo": "{days} 일 전", + "hoursAgo": "{hours} 시간 전", + "buyWith": "{coinA} 또는 {coinB} 로 구매하세요.", + "orderHistory": "주문 내역", + "tradeAnotherAssetOr": "다른 자산을 {coinA} 또는 {coinB} 로 교환하세요", + "tradeAnotherAsset": "다른 자산을 {coinDenom} 으로 교환하세요", + "sellAnAsset": "자산 판매", + "swapAnAsset": "자산 교환", + "of": "~의", + "insufficientFunds": "자금 부족", + "startTrading": "거래 시작", + "filledOrdersToClaim": "청구할 주문이 완료되었습니다.", + "tradeFees": "거래 수수료(주문 완료 시)", + "youNeed": "당신은 필요", + "stablecoin": "스테이블코인", + "quoteUpdated": "견적이 업데이트되었습니다.", + "fundsOsmosisToBuyAssets": "자산을 구매하기 위한 Osmosis 자금.", + "chooseAnOption": "계속하려면 옵션을 선택하세요.", + "lowerSlippageToleranceRecommended": "낮은 미끄러짐 허용 오차를 권장합니다.", + "tryHigherSlippage": "최대 미끄러짐 허용 오차를 더 높이십시오.", + "reviewTrade": "거래 검토", + "errors": { + "noAssetAvailable": "Osmosis에 거래할 수 {coinName} 자금이 없습니다. 계속하려면 옵션을 선택하세요.", + "tradeMayResultInLossOfValue": "귀하의 거래로 인해 상당한 가치 손실이 발생할 수 있습니다", + "tradeMayNotExecuted": "귀하의 거래가 실행되지 않을 수 있습니다" + }, + "transferFromAnotherNetwork": "다른 네트워크나 지갑에서 전송", + "whatIsOrderClaim": { + "title": "주문 청구란 무엇입니까?", + "description": "대부분의 경우 지정가 주문이 체결되면 자금이 자동으로 잔액에 추가됩니다. 그러나 드문 경우지만, 체결된 주문에서 자금을 수동으로 청구해야 합니다.\n이 청구 프로세스에는 소액의 네트워크 수수료가 발생하는 청구되지 않은 모든 주문에 대해 지갑에서 단일 거래를 승인하는 작업이 포함됩니다." + }, + "whatIsAStablecoin": { + "title": "스테이블 코인이란 무엇입니까?", + "description": "스테이블코인은 안정적인 가격을 유지하기 위해 명목화폐나 금과 같은 다른 자산에 가치가 고정되어 있는 암호화폐의 일종입니다. Osmosis에서 자산을 사고 파는 데 사용되는 주요 스테이블코인은 USDC와 USDT입니다." + }, + "claimAll": "모두 청구", + "addFunds": "자금 추가", + "selectAnAssetTo": { + "buy": "구매할 자산을 선택하세요", + "sell": "판매할 자산을 선택하세요" + }, + "payWith": "지불", + "receive": "받다", + "swapFromAnotherAsset": "다른 자산에서 교체", + "connectYourWallet": "지갑을 연결하세요", + "toSeeYourBalances": "잔액을 보려면", + "searchAssets": "자산 검색", + "marketPrice": "시장 가격", + "below": "아래에", + "above": "~ 위에", + "currentPrice": "현재 가격", + "whenDenomPriceIs": "{denom} 가격이 다음과 같은 경우", + "orderType": "주문 유형", + "confirm": "확인하다", + "market": "시장", + "limit": "한계", + "trade": "거래", + "swapToAnotherAsset": "다른 자산으로 교체", + "invalidPrice": "잘못된 가격", + "unavailable": "{denom} 에는 사용할 수 없습니다." } } diff --git a/packages/web/localizations/pl.json b/packages/web/localizations/pl.json index 2ca5b5bd5f..29b8b3d4ed 100644 --- a/packages/web/localizations/pl.json +++ b/packages/web/localizations/pl.json @@ -411,6 +411,7 @@ "txTimedOutError": "Upłynął limit czasu transakcji. Proszę spróbuj ponownie.", "insufficientFee": "Niewystarczające saldo opłat transakcyjnych. Dodaj środki, aby kontynuować.", "noData": "Brak danych", + "noOrderbook": "Brak księgi zamówień dla pary", "uhOhSomethingWentWrong": "Oj, coś poszło nie tak", "sorryForTheInconvenience": "Przepraszam za niedogodności. Spróbuj ponownie później.", "startAgain": "Zacznij jeszcze raz" @@ -447,7 +448,6 @@ "pools": "Pule", "stake": "Stakuj", "store": "Aplikacje", - "swap": "Wymień", "vote": "Głosuj", "featureRequests": "Żądania funkcji", "trade": "Profesjonalny handel", @@ -798,9 +798,11 @@ "MAX": "MAKSIMUM", "minimumSlippage": "Minimalna wartość uwzględniając odchylenie ({slippage})", "pool": "Pula #{id}", - "priceImpact": "Wpływ na cenę", + "priceImpact": "Wpływ na rynek", "routerTooltipFee": "Opłata", "routerTooltipSpreadFactor": "Współczynnik rozrzutu", + "showDetails": "Pokaż szczegóły", + "hideDetails": "Ukrywać", "continueAnyway": "Kontynuować mimo to", "warning": { "exceedsSpendLimit": "Ta zamiana przekracza pozostały limit wydatków w ramach handlu jednym kliknięciem.", @@ -812,7 +814,17 @@ "title": "Ustawienia Transakcji" }, "title": "Wymień", - "dynamicSpreadFactor": "Dynamiczny" + "dynamicSpreadFactor": "Dynamiczny", + "gas": { + "oneClickTradingError": "W tej chwili nie można oszacować opłaty sieciowej. Jeśli opłata przekroczy limit opłat w sieci 1-Click Trading, będziesz mógł kontynuować ręczną akceptację w swoim portfelu.", + "error": "W tej chwili nie można oszacować opłaty sieciowej. Przed zatwierdzeniem transakcji sprawdź opłaty sieciowe w swoim portfelu.", + "gasEstimationError": "Nie można oszacować opłaty sieciowej", + "unknown": "Nieznany", + "additionalNetworkFee": "Dodatkowa opłata sieciowa" + }, + "noRoutes": "Nie znaleziono żadnych tras", + "route": "trasa", + "routes": "trasy" }, "walletSelect": { "gettingStarted": "Pierwsze kroki", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Starszy", "newer": "Nowsza" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Zamówienie", + "amount": "Kwota", + "price": "Cena", + "orderPlaced": "Zamówienie złożone", + "status": "Status" + }, + "emptyState": { + "title": "Brak ostatnich zamówień", + "subtitle": "Tutaj pojawi się historia Twoich zleceń handlowych.", + "connectTitle": "Podłącz swój portfel, aby zobaczyć historię swoich zamówień", + "connectSubtitle": "Tutaj pojawią się Twoje wcześniejsze zamówienia z limitem na Osmozie." + } + }, + "aboveMarket": { + "title": "Cena graniczna powyżej ceny rynkowej", + "description": "Jeśli będziesz kontynuować, Twoje zlecenie zostanie przetworzone jako zlecenie rynkowe po cenie rynkowej." + }, + "belowMarket": { + "title": "Cena graniczna poniżej ceny rynkowej", + "description": "Jeśli będziesz kontynuować, Twoje zlecenie zostanie przetworzone jako zlecenie rynkowe po cenie rynkowej." + }, + "enterAnAmountTo": "Wpisz kwotę do", + "sell": "Sprzedać", + "buy": "Kupić", + "pay": "Płacić", + "open": "otwarty", + "filled": "Wypełniony", + "claimable": "Możliwość roszczenia", + "claimAndClose": "Złóż wniosek i zamknij", + "accept": "Zaakceptować", + "cancelled": "Odwołany", + "cancel": "Anulować", + "daysAgo": "{days} d temu", + "hoursAgo": "{hours} godz. temu", + "buyWith": "Kupuj za pomocą {coinA} lub {coinB}", + "orderHistory": "Historia zamówień", + "tradeAnotherAssetOr": "Zamień inny zasób na {coinA} lub {coinB}", + "tradeAnotherAsset": "Zamień inny zasób na {coinDenom}", + "sellAnAsset": "Sprzedaj aktywa", + "swapAnAsset": "Zamień zasób", + "of": "z", + "insufficientFunds": "Niewystarczające środki", + "startTrading": "Zacznij handlować", + "filledOrdersToClaim": "Wypełnione zamówienia do odbioru", + "tradeFees": "Opłaty handlowe (po zrealizowaniu zamówienia)", + "youNeed": "Potrzebujesz", + "stablecoin": "moneta stabilna", + "quoteUpdated": "Cytat zaktualizowany", + "fundsOsmosisToBuyAssets": "fundusze na Osmozie na zakup aktywów.", + "chooseAnOption": "Wybierz opcję, aby kontynuować.", + "lowerSlippageToleranceRecommended": "Zalecana jest niższa tolerancja poślizgu.", + "tryHigherSlippage": "Wypróbuj wyższą maksymalną tolerancję poślizgu.", + "reviewTrade": "Przejrzyj handel", + "errors": { + "noAssetAvailable": "Nie masz żadnych środków {coinName} na platformie Osmosis, którymi mógłbyś handlować. Wybierz opcję, aby kontynuować.", + "tradeMayResultInLossOfValue": "Twoja transakcja może spowodować znaczną utratę wartości", + "tradeMayNotExecuted": "Twoja transakcja może nie zostać zrealizowana" + }, + "transferFromAnotherNetwork": "Przelew z innej sieci lub portfela", + "whatIsOrderClaim": { + "title": "Co oznacza żądanie zamówienia?", + "description": "W większości przypadków po zrealizowaniu zlecenia z limitem środki są automatycznie dodawane do Twojego salda. Jednak w niektórych rzadkich przypadkach środki należy pobrać ręcznie z wypełnionego zamówienia.\nTen proces zgłaszania roszczeń obejmuje zatwierdzenie pojedynczej transakcji w Twoim portfelu dla wszystkich nieodebranych, zrealizowanych zamówień, co wiąże się z symboliczną opłatą sieciową." + }, + "whatIsAStablecoin": { + "title": "Co to jest stablecoin?", + "description": "Stablecoiny to rodzaj kryptowaluty, której wartość jest powiązana z innym aktywem, takim jak waluta fiducjarna lub złoto, w celu utrzymania stabilnej ceny. Na platformie Osmosis głównymi monetami stabilnymi do kupna i sprzedaży aktywów są USDC i USDT." + }, + "claimAll": "Zażądaj wszystkiego", + "addFunds": "Dodać fundusze", + "selectAnAssetTo": { + "buy": "Wybierz zasób do kupienia", + "sell": "Wybierz zasób do sprzedania" + }, + "payWith": "Zapłacić", + "receive": "Odbierać", + "swapFromAnotherAsset": "Zamiana z innego zasobu", + "connectYourWallet": "Podłącz swój portfel", + "toSeeYourBalances": "aby zobaczyć saldo", + "searchAssets": "Wyszukaj zasoby", + "marketPrice": "Cena rynkowa", + "below": "poniżej", + "above": "powyżej", + "currentPrice": "aktualna cena", + "whenDenomPriceIs": "Gdy cena {denom} wynosi", + "orderType": "Typ zamówienia", + "confirm": "Potwierdzać", + "market": "Rynek", + "limit": "Limit", + "trade": "Handel", + "swapToAnotherAsset": "Zamień na inny zasób", + "invalidPrice": "Nieprawidłowa cena", + "unavailable": "Niedostępne dla {denom}" } } diff --git a/packages/web/localizations/pt-br.json b/packages/web/localizations/pt-br.json index 00e3ee24c5..402bda8b28 100644 --- a/packages/web/localizations/pt-br.json +++ b/packages/web/localizations/pt-br.json @@ -411,6 +411,7 @@ "txTimedOutError": "A transação expirou. Por favor tente novamente.", "insufficientFee": "Saldo insuficiente para taxas de transação. Adicione fundos para continuar.", "noData": "Sem dados", + "noOrderbook": "Sem carteira de pedidos para par", "uhOhSomethingWentWrong": "Ah, ah, algo deu errado", "sorryForTheInconvenience": "Desculpe pela inconveniência. Por favor, tente novamente mais tarde.", "startAgain": "Comece de novo" @@ -447,7 +448,6 @@ "pools": "Piscinas", "stake": "Stake", "store": "Aplicativos", - "swap": "Trocar", "vote": "Votar", "featureRequests": "Solicitações de recursos", "trade": "Negociação profissional", @@ -798,9 +798,11 @@ "MAX": "MÁXIMO", "minimumSlippage": "Mínimo recebido após o spread ({slippage})", "pool": "Piscina #{id}", - "priceImpact": "Impacto sobre o preço", + "priceImpact": "Impacto no mercado", "routerTooltipFee": "Taxa", "routerTooltipSpreadFactor": "Fator de propagação", + "showDetails": "Mostrar detalhes", + "hideDetails": "Esconder", "continueAnyway": "Continue de qualquer maneira", "warning": { "exceedsSpendLimit": "Esta troca excede o limite de gastos restantes para negociação em 1 clique.", @@ -812,7 +814,17 @@ "title": "Configurações de Transação" }, "title": "Trocar", - "dynamicSpreadFactor": "Dinâmico" + "dynamicSpreadFactor": "Dinâmico", + "gas": { + "oneClickTradingError": "A taxa de rede não pode ser estimada neste momento. Se a taxa exceder o limite de taxas da rede 1-Click Trading, você poderá prosseguir com a aprovação manual em sua carteira.", + "error": "A taxa de rede não pode ser estimada neste momento. Revise as taxas de rede em sua carteira antes de aprovar a transação.", + "gasEstimationError": "A taxa de rede não pode ser estimada", + "unknown": "Desconhecido", + "additionalNetworkFee": "Taxa de rede adicional" + }, + "noRoutes": "Nenhuma rota encontrada", + "route": "rota", + "routes": "rotas" }, "walletSelect": { "gettingStarted": "Começando", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Mais velho", "newer": "Mais recente" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Ordem", + "amount": "Quantia", + "price": "Preço", + "orderPlaced": "Pedido feito", + "status": "Status" + }, + "emptyState": { + "title": "Nenhum pedido recente", + "subtitle": "Seu histórico de ordens comerciais aparecerá aqui.", + "connectTitle": "Conecte sua carteira para ver seu histórico de pedidos", + "connectSubtitle": "Seus pedidos com limite anteriores no Osmosis aparecerão aqui." + } + }, + "aboveMarket": { + "title": "Preço limite acima do preço de mercado", + "description": "Se você prosseguir, sua ordem será processada como uma ordem de mercado a preço de mercado." + }, + "belowMarket": { + "title": "Preço limite abaixo do preço de mercado", + "description": "Se você prosseguir, sua ordem será processada como uma ordem de mercado a preço de mercado." + }, + "enterAnAmountTo": "Insira um valor para", + "sell": "Vender", + "buy": "Comprar", + "pay": "Pagar", + "open": "Abrir", + "filled": "Preenchido", + "claimable": "Exigível", + "claimAndClose": "Reivindique e feche", + "accept": "Aceitar", + "cancelled": "Cancelado", + "cancel": "Cancelar", + "daysAgo": "{days} atrás", + "hoursAgo": "{hours} horas atrás", + "buyWith": "Compre com {coinA} ou {coinB}", + "orderHistory": "Histórico de pedidos", + "tradeAnotherAssetOr": "Negocie outro ativo por {coinA} ou {coinB}", + "tradeAnotherAsset": "Negocie outro ativo por {coinDenom}", + "sellAnAsset": "Vender um ativo", + "swapAnAsset": "Trocar um ativo", + "of": "de", + "insufficientFunds": "Fundos insuficientes", + "startTrading": "Comece a negociar", + "filledOrdersToClaim": "Pedidos preenchidos para reivindicar", + "tradeFees": "Taxas comerciais (quando o pedido é preenchido)", + "youNeed": "Você precisa", + "stablecoin": "moeda estável", + "quoteUpdated": "Cotação atualizada", + "fundsOsmosisToBuyAssets": "fundos em Osmose para comprar ativos.", + "chooseAnOption": "Escolha uma opção para continuar.", + "lowerSlippageToleranceRecommended": "Recomenda-se uma menor tolerância ao deslizamento.", + "tryHigherSlippage": "Experimente uma tolerância máxima de escorregamento mais alta.", + "reviewTrade": "Revise o comércio", + "errors": { + "noAssetAvailable": "Você não tem nenhum fundo {coinName} no Osmosis para negociar. Escolha uma opção para continuar.", + "tradeMayResultInLossOfValue": "Sua negociação pode resultar em perda significativa de valor", + "tradeMayNotExecuted": "Sua negociação pode não ser executada" + }, + "transferFromAnotherNetwork": "Transferir de outra rede ou carteira", + "whatIsOrderClaim": { + "title": "O que o pedido está reivindicando?", + "description": "Na maioria dos casos, quando uma ordem com limite é executada, os fundos são automaticamente adicionados ao seu saldo. No entanto, em alguns casos raros, os fundos devem ser reivindicados manualmente a partir do pedido preenchido.\nEste processo de reivindicação envolve a aprovação de uma única transação em sua carteira para todos os pedidos preenchidos não reclamados, o que incorre em uma taxa de rede nominal." + }, + "whatIsAStablecoin": { + "title": "O que é uma moeda estável?", + "description": "Stablecoins são um tipo de criptomoeda cujo valor está atrelado a outro ativo, como uma moeda fiduciária ou ouro, para manter um preço estável. No Osmosis, as principais stablecoins para negociação de ativos são USDC e USDT." + }, + "claimAll": "Reivindique tudo", + "addFunds": "Adicionar fundos", + "selectAnAssetTo": { + "buy": "Selecione um ativo para comprar", + "sell": "Selecione um ativo para vender" + }, + "payWith": "Pagar com", + "receive": "Receber", + "swapFromAnotherAsset": "Trocar de outro ativo", + "connectYourWallet": "Conecte sua carteira", + "toSeeYourBalances": "para ver seus saldos", + "searchAssets": "Pesquisar ativos", + "marketPrice": "preço de mercado", + "below": "abaixo", + "above": "acima", + "currentPrice": "preço atual", + "whenDenomPriceIs": "Quando {denom} o preço é", + "orderType": "Tipo de pedido", + "confirm": "confirme", + "market": "Mercado", + "limit": "Limite", + "trade": "Troca", + "swapToAnotherAsset": "Trocar por outro ativo", + "invalidPrice": "Preço inválido", + "unavailable": "Indisponível para {denom}" } } diff --git a/packages/web/localizations/ro.json b/packages/web/localizations/ro.json index e628966471..8efc5b41f5 100644 --- a/packages/web/localizations/ro.json +++ b/packages/web/localizations/ro.json @@ -411,6 +411,7 @@ "txTimedOutError": "Tranzacția a expirat. Vă rugăm să reîncercați.", "insufficientFee": "Sold insuficient pentru taxele de tranzacție. Vă rugăm să adăugați fonduri pentru a continua.", "noData": "Nu există date", + "noOrderbook": "Fără carnet de comandă pentru pereche", "uhOhSomethingWentWrong": "Uh oh, ceva a mers prost", "sorryForTheInconvenience": "Îmi pare rău pentru neplăcerile create. Vă rugăm să încercați din nou mai târziu.", "startAgain": "Incepe din nou" @@ -447,7 +448,6 @@ "pools": "Pool-uri", "stake": "Actiuni", "store": "Aplicații", - "swap": "Schimba", "vote": "Vot", "featureRequests": "Cereri de caracteristici", "trade": "Pro Trading", @@ -798,9 +798,11 @@ "MAX": "MAXIM", "minimumSlippage": "Minimum rezultat cu toleranta ({slippage})", "pool": "Pool #{id}", - "priceImpact": "Impact pret", + "priceImpact": "Impactul pieței", "routerTooltipFee": "Taxa", "routerTooltipSpreadFactor": "Factorul de răspândire", + "showDetails": "Arata detaliile", + "hideDetails": "Ascunde", "continueAnyway": "Continua oricum", "warning": { "exceedsSpendLimit": "Acest schimb depășește limita de cheltuieli rămasă pentru tranzacționarea cu 1 clic.", @@ -812,7 +814,17 @@ "title": "Setari tranzactie" }, "title": "Schimb", - "dynamicSpreadFactor": "Dinamic" + "dynamicSpreadFactor": "Dinamic", + "gas": { + "oneClickTradingError": "Taxa de rețea nu poate fi estimată în acest moment. Dacă taxa depășește limita taxei de rețea de tranzacționare cu 1 clic, veți putea continua cu aprobarea manuală în portofel.", + "error": "Taxa de rețea nu poate fi estimată în acest moment. Verificați taxele de rețea în portofel înainte de a aproba tranzacția.", + "gasEstimationError": "Taxa de rețea nu poate fi estimată", + "unknown": "Necunoscut", + "additionalNetworkFee": "Taxă suplimentară de rețea" + }, + "noRoutes": "Nu au fost găsite rute", + "route": "traseu", + "routes": "trasee" }, "walletSelect": { "gettingStarted": "Noțiuni de bază", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Mai batran", "newer": "Mai nou" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Ordin", + "amount": "Cantitate", + "price": "Preț", + "orderPlaced": "Comandă plasată", + "status": "stare" + }, + "emptyState": { + "title": "Fără comenzi recente", + "subtitle": "Istoricul comenzilor dvs. comerciale va apărea aici.", + "connectTitle": "Conectați-vă portofelul pentru a vedea istoricul comenzilor", + "connectSubtitle": "Comenzile dumneavoastră limită anterioare pentru Osmosis vor apărea aici." + } + }, + "aboveMarket": { + "title": "Preț limită peste prețul pieței", + "description": "Dacă continuați, comanda dumneavoastră va fi procesată ca un ordin de piață la prețul pieței." + }, + "belowMarket": { + "title": "Preț limită sub prețul pieței", + "description": "Dacă continuați, comanda dumneavoastră va fi procesată ca un ordin de piață la prețul pieței." + }, + "enterAnAmountTo": "Introduceți o sumă la", + "sell": "Vinde", + "buy": "Cumpără", + "pay": "A plati", + "open": "Deschis", + "filled": "Umplut", + "claimable": "Revendicabil", + "claimAndClose": "Revendicați și închideți", + "accept": "Accept", + "cancelled": "Anulat", + "cancel": "Anulare", + "daysAgo": "{days} zi în urmă", + "hoursAgo": "{hours} h în urmă", + "buyWith": "Cumpărați cu {coinA} sau {coinB}", + "orderHistory": "Istoric comenzi", + "tradeAnotherAssetOr": "Schimbă alt activ pentru {coinA} sau {coinB}", + "tradeAnotherAsset": "Schimbă alt activ pentru {coinDenom}", + "sellAnAsset": "Vinde un activ", + "swapAnAsset": "Schimbați un activ", + "of": "de", + "insufficientFunds": "Fonduri insuficiente", + "startTrading": "Începeți să tranzacționați", + "filledOrdersToClaim": "Comenzi completate pentru revendicare", + "tradeFees": "Taxe de tranzacționare (când comanda este completată)", + "youNeed": "Ai nevoie", + "stablecoin": "stablecoin", + "quoteUpdated": "Citat actualizat", + "fundsOsmosisToBuyAssets": "fonduri pe Osmosis pentru a cumpăra active.", + "chooseAnOption": "Alegeți o opțiune pentru a continua.", + "lowerSlippageToleranceRecommended": "Se recomandă o toleranță mai mică la alunecare.", + "tryHigherSlippage": "Încercați o toleranță maximă la alunecare mai mare.", + "reviewTrade": "Examinați comerțul", + "errors": { + "noAssetAvailable": "Nu aveți fonduri {coinName} pe Osmosis cu care să tranzacționați. Alegeți o opțiune pentru a continua.", + "tradeMayResultInLossOfValue": "Tranzacția dvs. poate duce la pierderi semnificative de valoare", + "tradeMayNotExecuted": "Este posibil ca tranzacția dvs. să nu fie executată" + }, + "transferFromAnotherNetwork": "Transfer din altă rețea sau portofel", + "whatIsOrderClaim": { + "title": "Ce este revendicarea ordinului?", + "description": "În majoritatea cazurilor, atunci când un ordin limită este completat, fondurile sunt adăugate automat la soldul dvs. Cu toate acestea, în unele cazuri rare, fondurile trebuie solicitate manual din comanda completată.\nAcest proces de revendicare implică aprobarea unei singure tranzacții în portofel pentru toate comenzile completate nerevendicate care implică o taxă de rețea nominală." + }, + "whatIsAStablecoin": { + "title": "Ce este o monedă stabilă?", + "description": "Monedele stabile sunt un tip de criptomonedă a cărui valoare este legată de un alt activ, cum ar fi o monedă fiat sau aurul, pentru a menține un preț stabil. Pe Osmosis, principalele monede stabile pentru cumpărarea și vânzarea de active sunt USDC și USDT." + }, + "claimAll": "Revendica totul", + "addFunds": "Adăuga fonduri", + "selectAnAssetTo": { + "buy": "Selectați un activ de cumpărat", + "sell": "Selectați un activ pentru a vinde" + }, + "payWith": "Plateste cu", + "receive": "A primi", + "swapFromAnotherAsset": "Schimbați de la un alt activ", + "connectYourWallet": "Conectați-vă portofelul", + "toSeeYourBalances": "pentru a vă vedea soldurile", + "searchAssets": "Căutați active", + "marketPrice": "pretul din magazin", + "below": "de mai jos", + "above": "de mai sus", + "currentPrice": "pretul curent", + "whenDenomPriceIs": "Când prețul {denom} este", + "orderType": "Tip de comandă", + "confirm": "A confirma", + "market": "Piaţă", + "limit": "Limită", + "trade": "Comerț", + "swapToAnotherAsset": "Schimbați cu un alt activ", + "invalidPrice": "Preț nevalid", + "unavailable": "Indisponibil pentru {denom}" } } diff --git a/packages/web/localizations/ru.json b/packages/web/localizations/ru.json index 0f852a9140..a44562f3c6 100644 --- a/packages/web/localizations/ru.json +++ b/packages/web/localizations/ru.json @@ -411,6 +411,7 @@ "txTimedOutError": "Время транзакции истекло. Пожалуйста, повторите попытку.", "insufficientFee": "Недостаточно средств для оплаты комиссий за транзакции. Пожалуйста, добавьте средства, чтобы продолжить.", "noData": "Нет данных", + "noOrderbook": "Нет книги заказов для пары", "uhOhSomethingWentWrong": "Ой-ой, что-то пошло не так", "sorryForTheInconvenience": "Приносим извинения за неудобства. Пожалуйста, повторите попытку позже.", "startAgain": "Начать заново" @@ -447,7 +448,6 @@ "pools": "Бассейны", "stake": "Ставка", "store": "Программы", - "swap": "Менять", "vote": "Голосование", "featureRequests": "Запросы функций", "trade": "Профессиональная торговля", @@ -798,9 +798,11 @@ "MAX": "МАКС", "minimumSlippage": "Минимум, полученный после проскальзывания ( {slippage} )", "pool": "Пул № {id}", - "priceImpact": "Влияние на цену", + "priceImpact": "Влияние на рынок", "routerTooltipFee": "Платеж", "routerTooltipSpreadFactor": "Фактор распространения", + "showDetails": "Показать детали", + "hideDetails": "Скрывать", "continueAnyway": "Продолжай в любом случае", "warning": { "exceedsSpendLimit": "Этот обмен превышает оставшийся лимит расходов для торговли в один клик.", @@ -812,7 +814,17 @@ "title": "Настройки транзакции" }, "title": "Менять", - "dynamicSpreadFactor": "Динамический" + "dynamicSpreadFactor": "Динамический", + "gas": { + "oneClickTradingError": "В настоящее время невозможно оценить стоимость сети. Если комиссия превышает лимит комиссии сети 1-Click Trading, вы сможете продолжить одобрение вручную в своем кошельке.", + "error": "В настоящее время невозможно оценить стоимость сети. Прежде чем одобрить транзакцию, проверьте комиссию сети в своем кошельке.", + "gasEstimationError": "Плату за сеть невозможно оценить", + "unknown": "Неизвестный", + "additionalNetworkFee": "Дополнительная плата за сеть" + }, + "noRoutes": "Маршруты не найдены", + "route": "маршрут", + "routes": "маршруты" }, "walletSelect": { "gettingStarted": "Начиная", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Старшая", "newer": "Новее" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Заказ", + "amount": "Количество", + "price": "Цена", + "orderPlaced": "Заказ размещен", + "status": "Положение дел" + }, + "emptyState": { + "title": "Нет недавних заказов", + "subtitle": "Здесь появится история ваших торговых заказов.", + "connectTitle": "Подключите свой кошелек, чтобы просмотреть историю заказов", + "connectSubtitle": "Здесь появятся ваши прошлые лимитные ордера на Osmosis." + } + }, + "aboveMarket": { + "title": "Лимитная цена выше рыночной цены", + "description": "Если вы продолжите, ваш заказ будет обработан как рыночный ордер по рыночной цене." + }, + "belowMarket": { + "title": "Лимитная цена ниже рыночной цены", + "description": "Если вы продолжите, ваш заказ будет обработан как рыночный ордер по рыночной цене." + }, + "enterAnAmountTo": "Введите сумму для", + "sell": "Продавать", + "buy": "Купить", + "pay": "Платить", + "open": "Открыть", + "filled": "Заполненный", + "claimable": "Заявленный", + "claimAndClose": "Заявить права и закрыть", + "accept": "Принимать", + "cancelled": "Отменено", + "cancel": "Отмена", + "daysAgo": "{days} назад", + "hoursAgo": "{hours} час назад", + "buyWith": "Купите с помощью {coinA} или {coinB}", + "orderHistory": "История заказов", + "tradeAnotherAssetOr": "Обменяйте другой актив на {coinA} или {coinB}", + "tradeAnotherAsset": "Обменяйте другой актив на {coinDenom}", + "sellAnAsset": "Продать актив", + "swapAnAsset": "Обменять актив", + "of": "из", + "insufficientFunds": "Недостаточно средств", + "startTrading": "Начать торговать", + "filledOrdersToClaim": "Выполненные заказы на претензии", + "tradeFees": "Торговые сборы (при заполнении заказа)", + "youNeed": "Тебе нужно", + "stablecoin": "стейблкоин", + "quoteUpdated": "Цитата обновлена", + "fundsOsmosisToBuyAssets": "средства на Osmosis для покупки активов.", + "chooseAnOption": "Выберите вариант, чтобы продолжить.", + "lowerSlippageToleranceRecommended": "Рекомендуется более низкий допуск на проскальзывание.", + "tryHigherSlippage": "Попробуйте увеличить максимальный допуск на проскальзывание.", + "reviewTrade": "Обзор сделки", + "errors": { + "noAssetAvailable": "У вас нет средств {coinName} на Osmosis для торговли. Выберите вариант, чтобы продолжить.", + "tradeMayResultInLossOfValue": "Ваша сделка может привести к значительной потере стоимости", + "tradeMayNotExecuted": "Ваша сделка может быть не исполнена" + }, + "transferFromAnotherNetwork": "Перевод из другой сети или кошелька", + "whatIsOrderClaim": { + "title": "Что такое требование заказа?", + "description": "В большинстве случаев при исполнении лимитного ордера средства автоматически добавляются на ваш баланс. Однако в некоторых редких случаях средства необходимо запросить вручную из заполненного заказа.\nЭтот процесс подачи заявки включает в себя утверждение одной транзакции в вашем кошельке для всех невостребованных выполненных заказов, за которую взимается номинальная сетевая комиссия." + }, + "whatIsAStablecoin": { + "title": "Что такое стейблкоин?", + "description": "Стейблкоины — это тип криптовалюты, стоимость которой привязана к другому активу, например, бумажной валюте или золоту, для поддержания стабильной цены. На Osmosis основными стейблкоинами для покупки и продажи активов являются USDC и USDT." + }, + "claimAll": "Заявить права на все", + "addFunds": "Добавить средства", + "selectAnAssetTo": { + "buy": "Выберите актив для покупки", + "sell": "Выберите актив для продажи" + }, + "payWith": "Оплатить с", + "receive": "Получать", + "swapFromAnotherAsset": "Обмен с другого актива", + "connectYourWallet": "Подключите свой кошелек", + "toSeeYourBalances": "чтобы увидеть свой баланс", + "searchAssets": "Поиск активов", + "marketPrice": "рыночная цена", + "below": "ниже", + "above": "выше", + "currentPrice": "текущая цена", + "whenDenomPriceIs": "Когда цена {denom} равна", + "orderType": "Тип заказа", + "confirm": "Подтверждать", + "market": "Рынок", + "limit": "Лимит", + "trade": "Торговля", + "swapToAnotherAsset": "Обмен на другой актив", + "invalidPrice": "Неверная цена", + "unavailable": "Недоступно для {denom}" } } diff --git a/packages/web/localizations/scripts/remove-key.js b/packages/web/localizations/scripts/remove-key.js index 180649eb41..e828e419fe 100644 --- a/packages/web/localizations/scripts/remove-key.js +++ b/packages/web/localizations/scripts/remove-key.js @@ -10,11 +10,23 @@ const jsonFiles = fs function removeKey(obj, keyPath) { const keys = keyPath.split("."); let current = obj; + const stack = []; for (let i = 0; i < keys.length - 1; i++) { + stack.push(current); current = current[keys[i]]; if (current === undefined) return; } delete current[keys[keys.length - 1]]; + + // Remove empty parent objects + for (let i = keys.length - 2; i >= 0; i--) { + const parent = stack.pop(); + if (Object.keys(parent[keys[i]]).length === 0) { + delete parent[keys[i]]; + } else { + break; + } + } } // Process all JSON files @@ -22,5 +34,5 @@ jsonFiles.forEach((file) => { const filePath = path.join(process.cwd(), file); const obj = JSON.parse(fs.readFileSync(filePath, "utf8")); removeKey(obj, process.argv[2]); - fs.writeFileSync(filePath, JSON.stringify(obj, null, 2)); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + "\n"); }); diff --git a/packages/web/localizations/tr.json b/packages/web/localizations/tr.json index fba31e1253..edd336365e 100644 --- a/packages/web/localizations/tr.json +++ b/packages/web/localizations/tr.json @@ -411,6 +411,7 @@ "txTimedOutError": "İşlem zaman aşımına uğradı. Lütfen tekrar deneyiniz.", "insufficientFee": "İşlem ücretleri için yeterli bakiye yok. Devam etmek için lütfen para ekleyin.", "noData": "Veri yok", + "noOrderbook": "Çift için Sipariş Defteri Yok", "uhOhSomethingWentWrong": "Ah, bir şeyler ters gitti", "sorryForTheInconvenience": "Rahatsızlıktan dolayı özür dileriz. Lütfen daha sonra tekrar deneyiniz.", "startAgain": "Tekrar başla" @@ -447,7 +448,6 @@ "pools": "Havuzlar", "stake": "Stake", "store": "Uygulamalar", - "swap": "Takas", "vote": "Oy Kullan", "featureRequests": "Özellik talepleri", "trade": "Profesyonel Ticaret", @@ -798,9 +798,11 @@ "MAX": "MAKS.", "minimumSlippage": "Slipaj dahil elde edilecek minimum ({slippage})", "pool": "Havuz #{id}", - "priceImpact": "Fiyata Etki", + "priceImpact": "Pazar Etkisi", "routerTooltipFee": "Ücret", "routerTooltipSpreadFactor": "Yayılma Faktörü", + "showDetails": "Detayları göster", + "hideDetails": "Saklamak", "continueAnyway": "Her halükarda devam et", "warning": { "exceedsSpendLimit": "Bu takas, Tek Tıklamayla Ticaret için kalan harcama limitinizi aşıyor.", @@ -812,7 +814,17 @@ "title": "İşlem Ayarları" }, "title": "Takas", - "dynamicSpreadFactor": "Dinamik" + "dynamicSpreadFactor": "Dinamik", + "gas": { + "oneClickTradingError": "Ağ ücreti şu anda tahmin edilemiyor. Ücret, Tek Tıkla Ticaret ağ ücreti limitinizi aşarsa, cüzdanınızda manuel onay işlemine devam edebileceksiniz.", + "error": "Ağ ücreti şu anda tahmin edilemiyor. İşlemi onaylamadan önce cüzdanınızdaki ağ ücretlerini inceleyin.", + "gasEstimationError": "Ağ ücreti tahmin edilemiyor", + "unknown": "Bilinmeyen", + "additionalNetworkFee": "Ek ağ ücreti" + }, + "noRoutes": "Rota bulunamadı", + "route": "rota", + "routes": "rotalar" }, "walletSelect": { "gettingStarted": "Başlarken", @@ -1198,5 +1210,101 @@ "pagination": { "older": "Daha eski", "newer": "Daha yeni" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "Emir", + "amount": "Miktar", + "price": "Fiyat", + "orderPlaced": "Sipariş Verildi", + "status": "Durum" + }, + "emptyState": { + "title": "Yeni sipariş yok", + "subtitle": "Ticari sipariş geçmişiniz burada görünecektir.", + "connectTitle": "Sipariş geçmişinizi görmek için cüzdanınızı bağlayın", + "connectSubtitle": "Osmosis'teki geçmiş limit emirleriniz burada görünecektir." + } + }, + "aboveMarket": { + "title": "Piyasa fiyatının üzerinde limit fiyat", + "description": "Devam etmeniz halinde emriniz piyasa fiyatı üzerinden piyasa emri olarak işleme alınacaktır." + }, + "belowMarket": { + "title": "Limit fiyatı piyasa fiyatının altında", + "description": "Devam etmeniz halinde emriniz piyasa fiyatı üzerinden piyasa emri olarak işleme alınacaktır." + }, + "enterAnAmountTo": "Bir miktar girin", + "sell": "Satmak", + "buy": "Satın almak", + "pay": "Ödemek", + "open": "Açık", + "filled": "Dolu", + "claimable": "Hak talebinde bulunulabilir", + "claimAndClose": "Talep et ve kapat", + "accept": "Kabul etmek", + "cancelled": "İptal edildi", + "cancel": "İptal etmek", + "daysAgo": "{days} gün önce", + "hoursAgo": "{hours} saat önce", + "buyWith": "{coinA} veya {coinB} ile satın alın", + "orderHistory": "Sipariş Geçmişi", + "tradeAnotherAssetOr": "{coinA} veya {coinB} ile başka bir varlıkla işlem yapın", + "tradeAnotherAsset": "{coinDenom} ile başka bir varlığı takas edin", + "sellAnAsset": "Bir varlık satmak", + "swapAnAsset": "Bir varlığı takas etme", + "of": "ile ilgili", + "insufficientFunds": "Yetersiz bakiye", + "startTrading": "Ticarete başla", + "filledOrdersToClaim": "Talep edilecek siparişler tamamlandı", + "tradeFees": "Ticaret ücretleri (sipariş tamamlandığında)", + "youNeed": "ihtiyacın var", + "stablecoin": "stabilcoin", + "quoteUpdated": "Teklif güncellendi", + "fundsOsmosisToBuyAssets": "varlık satın almak için Osmosis'e fon sağlıyor.", + "chooseAnOption": "Devam etmek için bir seçenek seçin.", + "lowerSlippageToleranceRecommended": "Daha düşük bir kayma toleransı tavsiye edilir.", + "tryHigherSlippage": "Daha yüksek bir maksimum kayma toleransı deneyin.", + "reviewTrade": "Ticareti inceleyin", + "errors": { + "noAssetAvailable": "Osmosis'te ticaret yapabileceğiniz {coinName} fonunuz yok. Devam etmek için bir seçenek seçin.", + "tradeMayResultInLossOfValue": "Ticaretiniz önemli değer kaybına neden olabilir", + "tradeMayNotExecuted": "İşleminiz gerçekleştirilemeyebilir" + }, + "transferFromAnotherNetwork": "Başka bir ağdan veya cüzdandan aktarma", + "whatIsOrderClaim": { + "title": "Sipariş talebi nedir?", + "description": "Çoğu durumda, bir limit emri yerine getirildiğinde fonlar otomatik olarak bakiyenize eklenir. Ancak bazı nadir durumlarda, fonların doldurulan siparişten manuel olarak talep edilmesi gerekir.\nBu hak talebinde bulunma süreci, nominal bir ağ ücreti gerektiren tüm talep edilmemiş doldurulmuş siparişler için cüzdanınızda tek bir işlemin onaylanmasını içerir." + }, + "whatIsAStablecoin": { + "title": "Stablecoin nedir?", + "description": "Stablecoin'ler, sabit bir fiyatı korumak için değeri fiat para birimi veya altın gibi başka bir varlığa sabitlenen bir tür kripto para birimidir. Osmosis'te varlık alım ve satımında kullanılan başlıca stablecoin'ler USDC ve USDT'dir." + }, + "claimAll": "Tümünü talep edin", + "addFunds": "Fon Ekle", + "selectAnAssetTo": { + "buy": "Satın almak için bir varlık seçin", + "sell": "Satılacak bir varlık seçin" + }, + "payWith": "İle ödemek", + "receive": "Almak", + "swapFromAnotherAsset": "Başka bir varlıktan takas", + "connectYourWallet": "Cüzdanınızı bağlayın", + "toSeeYourBalances": "bakiyenizi görmek için", + "searchAssets": "Varlıkları arayın", + "marketPrice": "Market fiyatı", + "below": "altında", + "above": "üstünde", + "currentPrice": "Mevcut fiyat", + "whenDenomPriceIs": "{denom} fiyatı şu olduğunda", + "orderType": "Sipariş türü", + "confirm": "Onaylamak", + "market": "Pazar", + "limit": "Sınır", + "trade": "Ticaret", + "swapToAnotherAsset": "Başka bir varlığa geç", + "invalidPrice": "Geçersiz fiyat", + "unavailable": "{denom} için kullanılamıyor" } } diff --git a/packages/web/localizations/zh-cn.json b/packages/web/localizations/zh-cn.json index 0202e529e6..5ec503a9fe 100644 --- a/packages/web/localizations/zh-cn.json +++ b/packages/web/localizations/zh-cn.json @@ -411,6 +411,7 @@ "txTimedOutError": "交易超时。请重试。", "insufficientFee": "余额不足,无法支付交易费用。请添加资金以继续。", "noData": "没有数据", + "noOrderbook": "暂无订单簿", "uhOhSomethingWentWrong": "哦,出问题了", "sorryForTheInconvenience": "抱歉造成不便。请稍后重试。", "startAgain": "重新开始" @@ -447,7 +448,6 @@ "pools": "资金池", "stake": "质押", "store": "应用", - "swap": "兑换", "vote": "投票", "featureRequests": "功能请求", "trade": "专业交易", @@ -798,9 +798,11 @@ "MAX": "最大", "minimumSlippage": "扣除滑点后最少获得 ({slippage})", "pool": "资金池 #{id}", - "priceImpact": "价格影响", + "priceImpact": "市场影响", "routerTooltipFee": "费用", "routerTooltipSpreadFactor": "扩频因子", + "showDetails": "显示详细资料", + "hideDetails": "隐藏", "continueAnyway": "无论如何继续", "warning": { "exceedsSpendLimit": "此交换超出了您一键交易的剩余支出限额。", @@ -812,7 +814,17 @@ "title": "交易设置" }, "title": "兑换", - "dynamicSpreadFactor": "动态的" + "dynamicSpreadFactor": "动态的", + "gas": { + "oneClickTradingError": "目前无法估算网络费用。如果费用超出您的 1-Click Trading 网络费用限额,您将能够继续在钱包中进行手动批准。", + "error": "目前无法估算网络费用。在批准交易之前,请先检查钱包中的网络费用。", + "gasEstimationError": "网络费用无法估算", + "unknown": "未知", + "additionalNetworkFee": "额外网络费用" + }, + "noRoutes": "未找到任何路线", + "route": "路线", + "routes": "路线" }, "walletSelect": { "gettingStarted": "入门", @@ -1198,5 +1210,101 @@ "pagination": { "older": "较旧", "newer": "较新" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "命令", + "amount": "数量", + "price": "价格", + "orderPlaced": "订单已下", + "status": "地位" + }, + "emptyState": { + "title": "暂无近期订单", + "subtitle": "您的交易订单历史记录将显示在这里。", + "connectTitle": "连接您的钱包以查看您的订单历史记录", + "connectSubtitle": "您过去在 Osmosis 上的限价订单将显示在这里。" + } + }, + "aboveMarket": { + "title": "限制价格高于市场价格", + "description": "如果您继续,您的订单将按市场价格作为市价订单处理。" + }, + "belowMarket": { + "title": "限制价格低于市场价格", + "description": "如果您继续,您的订单将按市场价格作为市价订单处理。" + }, + "enterAnAmountTo": "输入金额", + "sell": "卖", + "buy": "买", + "pay": "支付", + "open": "打开", + "filled": "填充", + "claimable": "可索偿", + "claimAndClose": "认领并关闭", + "accept": "接受", + "cancelled": "取消", + "cancel": "取消", + "daysAgo": "{days}天前", + "hoursAgo": "{hours}小时前", + "buyWith": "使用{coinA}或{coinB}购买", + "orderHistory": "订单历史", + "tradeAnotherAssetOr": "将另一种资产交易为{coinA}或{coinB}", + "tradeAnotherAsset": "用{coinDenom}交易另一种资产", + "sellAnAsset": "出售资产", + "swapAnAsset": "交换资产", + "of": "的", + "insufficientFunds": "不充足的资金", + "startTrading": "开始交易", + "filledOrdersToClaim": "已填写订单并领取", + "tradeFees": "交易费(订单成交时)", + "youNeed": "你需要", + "stablecoin": "稳定币", + "quoteUpdated": "报价已更新", + "fundsOsmosisToBuyAssets": "Osmosis 上的资金用于购买资产。", + "chooseAnOption": "选择一个选项以继续。", + "lowerSlippageToleranceRecommended": "建议降低滑动公差。", + "tryHigherSlippage": "尝试更高的最大滑动容忍度。", + "reviewTrade": "回顾贸易", + "errors": { + "noAssetAvailable": "您在 Osmosis 上没有任何{coinName}资金可供交易。选择一个选项以继续。", + "tradeMayResultInLossOfValue": "您的交易可能会导致重大价值损失", + "tradeMayNotExecuted": "您的交易可能无法执行" + }, + "transferFromAnotherNetwork": "从其他网络或钱包转移", + "whatIsOrderClaim": { + "title": "什么是订单索取?", + "description": "在大多数情况下,当限价订单成交时,资金会自动添加到您的余额中。但在极少数情况下,必须手动从已成交的订单中领取资金。此认领流程涉及批准您钱包中所有未认领的已完成订单的单笔交易,这会产生象征性的网络费用。" + }, + "whatIsAStablecoin": { + "title": "什么是稳定币?", + "description": "稳定币是一种加密货币,其价值与另一种资产(例如法定货币或黄金)挂钩,以保持稳定的价格。在 Osmosis 上,用于购买和出售资产的主要稳定币是 USDC 和 USDT。" + }, + "claimAll": "索取全部", + "addFunds": "增加资金", + "selectAnAssetTo": { + "buy": "选择要购买的资产", + "sell": "选择要出售的资产" + }, + "payWith": "使用。。。支付", + "receive": "收到", + "swapFromAnotherAsset": "从另一项资产交换", + "connectYourWallet": "连接你的钱包", + "toSeeYourBalances": "查看你的余额", + "searchAssets": "搜索资产", + "marketPrice": "市场价", + "below": "以下", + "above": "多于", + "currentPrice": "时价", + "whenDenomPriceIs": "当{denom}价格为", + "orderType": "订单类型", + "confirm": "确认", + "market": "市场", + "limit": "限制", + "trade": "贸易", + "swapToAnotherAsset": "转换为其他资产", + "invalidPrice": "价格无效", + "unavailable": "不适用于{denom}" } } diff --git a/packages/web/localizations/zh-hk.json b/packages/web/localizations/zh-hk.json index 7a5c9f0d54..245c0d5830 100644 --- a/packages/web/localizations/zh-hk.json +++ b/packages/web/localizations/zh-hk.json @@ -411,6 +411,7 @@ "txTimedOutError": "交易超時。請重試。", "insufficientFee": "餘額不足,無法支付交易費用。請添加資金以繼續。", "noData": "沒有數據", + "noOrderbook": "沒有配對訂單簿", "uhOhSomethingWentWrong": "呃哦,出了點問題", "sorryForTheInconvenience": "帶來不便敬請諒解。請稍後再試。", "startAgain": "重新開始" @@ -447,7 +448,6 @@ "pools": "流動性池", "stake": "質押", "store": "應用", - "swap": "兌換", "vote": "投票", "featureRequests": "功能請求", "trade": "專業交易", @@ -798,9 +798,11 @@ "MAX": "全部", "minimumSlippage": "({slippage}) 包括滑價({slippage})後最少能獲得", "pool": "流動性池 #{id}", - "priceImpact": "價格影響", + "priceImpact": "市場影響", "routerTooltipFee": "費", "routerTooltipSpreadFactor": "擴頻因子", + "showDetails": "顯示詳細資料", + "hideDetails": "隱藏", "continueAnyway": "無論如何繼續", "warning": { "exceedsSpendLimit": "此交換超出了您一鍵交易的剩餘支出限額。", @@ -812,7 +814,17 @@ "title": "兌換設定" }, "title": "兌換", - "dynamicSpreadFactor": "動態的" + "dynamicSpreadFactor": "動態的", + "gas": { + "oneClickTradingError": "目前無法估計網路費用。如果費用超過您的一鍵交易網路費用限額,您將能夠在錢包中進行手動批准。", + "error": "目前無法估計網路費用。在批准交易之前,請檢查錢包中的網路費用。", + "gasEstimationError": "網路費用無法估算", + "unknown": "未知", + "additionalNetworkFee": "額外網路費" + }, + "noRoutes": "沒有找到路線", + "route": "路線", + "routes": "路線" }, "walletSelect": { "gettingStarted": "入門", @@ -1198,5 +1210,101 @@ "pagination": { "older": "年長的", "newer": "較新" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "命令", + "amount": "數量", + "price": "價格", + "orderPlaced": "訂單已下", + "status": "地位" + }, + "emptyState": { + "title": "最近沒有訂單", + "subtitle": "您的交易訂單歷史記錄將顯示在這裡。", + "connectTitle": "連接您的錢包以查看您的訂單歷史記錄", + "connectSubtitle": "您過去在 Osmosis 上的限價訂單將顯示在此。" + } + }, + "aboveMarket": { + "title": "限價高於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "belowMarket": { + "title": "限制價格低於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "enterAnAmountTo": "輸入金額至", + "sell": "賣", + "buy": "買", + "pay": "支付", + "open": "打開", + "filled": "填充", + "claimable": "可索賠", + "claimAndClose": "索賠並關閉", + "accept": "接受", + "cancelled": "取消", + "cancel": "取消", + "daysAgo": "{days}天前", + "hoursAgo": "{hours}前", + "buyWith": "使用{coinA}或{coinB}購買", + "orderHistory": "訂單歷史", + "tradeAnotherAssetOr": "將另一種資產交易為{coinA}或{coinB}", + "tradeAnotherAsset": "將另一種資產交易為{coinDenom}", + "sellAnAsset": "出售資產", + "swapAnAsset": "交換資產", + "of": "的", + "insufficientFunds": "不充足的資金", + "startTrading": "開始交易", + "filledOrdersToClaim": "已填寫訂單以領取", + "tradeFees": "交易費用(訂單成交時)", + "youNeed": "你需要", + "stablecoin": "穩定幣", + "quoteUpdated": "報價已更新", + "fundsOsmosisToBuyAssets": "Osmosis 上的資金用於購買資產。", + "chooseAnOption": "選擇一個選項繼續。", + "lowerSlippageToleranceRecommended": "建議採用較低的滑移容差。", + "tryHigherSlippage": "嘗試更高的最大滑動容差。", + "reviewTrade": "審查交易", + "errors": { + "noAssetAvailable": "您在 Osmosis 上沒有任何可用於交易的{coinName}資金。選擇一個選項繼續。", + "tradeMayResultInLossOfValue": "您的交易可能會導致重大價值損失", + "tradeMayNotExecuted": "您的交易可能無法執行" + }, + "transferFromAnotherNetwork": "從另一個網路或錢包轉賬", + "whatIsOrderClaim": { + "title": "什麼是訂單索賠?", + "description": "在大多數情況下,當限價訂單被執行時,資金會自動加到您的餘額中。但在極少數情況下,必須從已執行的訂單中手動領取資金。此認領過程涉及批准您錢包中所有無人認領的已完成訂單的單筆交易,這會產生象徵性的網路費用。" + }, + "whatIsAStablecoin": { + "title": "什麼是穩定幣?", + "description": "穩定幣是一種加密貨幣,其價值與另一種資產(例如法定貨幣或黃金)掛鉤,以維持穩定的價格。在 Osmosis 上,買賣資產的主要穩定幣是 USDC 和 USDT。" + }, + "claimAll": "索取全部", + "addFunds": "增加資金", + "selectAnAssetTo": { + "buy": "選擇要購買的資產", + "sell": "選擇要出售的資產" + }, + "payWith": "使用。", + "receive": "收到", + "swapFromAnotherAsset": "從另一種資產交換", + "connectYourWallet": "連接你的錢包", + "toSeeYourBalances": "查看您的餘額", + "searchAssets": "搜尋資產", + "marketPrice": "市價", + "below": "以下", + "above": "多於", + "currentPrice": "時價", + "whenDenomPriceIs": "當{denom}價格為", + "orderType": "訂單類型", + "confirm": "確認", + "market": "市場", + "limit": "限制", + "trade": "貿易", + "swapToAnotherAsset": "交換到另一種資產", + "invalidPrice": "價格無效", + "unavailable": "不適用於{denom}" } } diff --git a/packages/web/localizations/zh-tw.json b/packages/web/localizations/zh-tw.json index 27d66033dc..305db72060 100644 --- a/packages/web/localizations/zh-tw.json +++ b/packages/web/localizations/zh-tw.json @@ -411,6 +411,7 @@ "txTimedOutError": "交易超時。請重試。", "insufficientFee": "餘額不足,無法支付交易費用。請添加資金以繼續。", "noData": "沒有數據", + "noOrderbook": "沒有配對訂單簿", "uhOhSomethingWentWrong": "呃哦,出了點問題", "sorryForTheInconvenience": "帶來不便敬請諒解。請稍後再試。", "startAgain": "重新開始" @@ -447,7 +448,6 @@ "pools": "流動性池", "stake": "質押", "store": "應用", - "swap": "兌換", "vote": "投票", "featureRequests": "功能請求", "trade": "專業交易", @@ -798,9 +798,11 @@ "MAX": "全部", "minimumSlippage": "({slippage}) 包括滑價({slippage})後最少能獲得", "pool": "流動性池 #{id}", - "priceImpact": "價格影響", + "priceImpact": "市場影響", "routerTooltipFee": "費", "routerTooltipSpreadFactor": "擴頻因子", + "showDetails": "顯示詳細資料", + "hideDetails": "隱藏", "continueAnyway": "無論如何繼續", "warning": { "exceedsSpendLimit": "此交換超出了您一鍵交易的剩餘支出限額。", @@ -812,7 +814,17 @@ "title": "兌換設定" }, "title": "兌換", - "dynamicSpreadFactor": "動態的" + "dynamicSpreadFactor": "動態的", + "gas": { + "oneClickTradingError": "目前無法估計網路費用。如果費用超過您的一鍵交易網路費用限額,您將能夠在錢包中進行手動批准。", + "error": "目前無法估計網路費用。在批准交易之前,請檢查錢包中的網路費用。", + "gasEstimationError": "網路費用無法估算", + "unknown": "未知", + "additionalNetworkFee": "額外網路費" + }, + "noRoutes": "沒有找到路線", + "route": "路線", + "routes": "路線" }, "walletSelect": { "gettingStarted": "入門", @@ -1198,5 +1210,101 @@ "pagination": { "older": "年長的", "newer": "較新" + }, + "limitOrders": { + "historyTable": { + "columns": { + "order": "命令", + "amount": "數量", + "price": "價格", + "orderPlaced": "訂單已下", + "status": "地位" + }, + "emptyState": { + "title": "最近沒有訂單", + "subtitle": "您的交易訂單歷史記錄將顯示在這裡。", + "connectTitle": "連接您的錢包以查看您的訂單歷史記錄", + "connectSubtitle": "您過去在 Osmosis 上的限價訂單將顯示在此。" + } + }, + "aboveMarket": { + "title": "限價高於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "belowMarket": { + "title": "限制價格低於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "enterAnAmountTo": "輸入金額至", + "sell": "賣", + "buy": "買", + "pay": "支付", + "open": "打開", + "filled": "填充", + "claimable": "可索賠", + "claimAndClose": "索賠並關閉", + "accept": "接受", + "cancelled": "取消", + "cancel": "取消", + "daysAgo": "{days}天前", + "hoursAgo": "{hours}前", + "buyWith": "使用{coinA}或{coinB}購買", + "orderHistory": "訂單歷史", + "tradeAnotherAssetOr": "將另一種資產交易為{coinA}或{coinB}", + "tradeAnotherAsset": "將另一種資產交易為{coinDenom}", + "sellAnAsset": "出售資產", + "swapAnAsset": "交換資產", + "of": "的", + "insufficientFunds": "不充足的資金", + "startTrading": "開始交易", + "filledOrdersToClaim": "已填寫訂單以領取", + "tradeFees": "交易費用(訂單成交時)", + "youNeed": "你需要", + "stablecoin": "穩定幣", + "quoteUpdated": "報價已更新", + "fundsOsmosisToBuyAssets": "Osmosis 上的資金用於購買資產。", + "chooseAnOption": "選擇一個選項繼續。", + "lowerSlippageToleranceRecommended": "建議採用較低的滑移容差。", + "tryHigherSlippage": "嘗試更高的最大滑動容差。", + "reviewTrade": "審查交易", + "errors": { + "noAssetAvailable": "您在 Osmosis 上沒有任何可用於交易的{coinName}資金。選擇一個選項繼續。", + "tradeMayResultInLossOfValue": "您的交易可能會導致重大價值損失", + "tradeMayNotExecuted": "您的交易可能無法執行" + }, + "transferFromAnotherNetwork": "從另一個網路或錢包轉賬", + "whatIsOrderClaim": { + "title": "什麼是訂單索賠?", + "description": "在大多數情況下,當限價訂單被執行時,資金會自動加到您的餘額中。但在極少數情況下,必須從已執行的訂單中手動領取資金。此認領過程涉及批准您錢包中所有無人認領的已完成訂單的單筆交易,這會產生象徵性的網路費用。" + }, + "whatIsAStablecoin": { + "title": "什麼是穩定幣?", + "description": "穩定幣是一種加密貨幣,其價值與另一種資產(例如法定貨幣或黃金)掛鉤,以維持穩定的價格。在 Osmosis 上,買賣資產的主要穩定幣是 USDC 和 USDT。" + }, + "claimAll": "索取全部", + "addFunds": "增加資金", + "selectAnAssetTo": { + "buy": "選擇要購買的資產", + "sell": "選擇要出售的資產" + }, + "payWith": "使用。", + "receive": "收到", + "swapFromAnotherAsset": "從另一種資產交換", + "connectYourWallet": "連接你的錢包", + "toSeeYourBalances": "查看您的餘額", + "searchAssets": "搜尋資產", + "marketPrice": "市價", + "below": "以下", + "above": "多於", + "currentPrice": "時價", + "whenDenomPriceIs": "當{denom}價格為", + "orderType": "訂單類型", + "confirm": "確認", + "market": "市場", + "limit": "限制", + "trade": "貿易", + "swapToAnotherAsset": "交換到另一種資產", + "invalidPrice": "價格無效", + "unavailable": "不適用於{denom}" } } diff --git a/packages/web/modals/add-funds.tsx b/packages/web/modals/add-funds.tsx new file mode 100644 index 0000000000..1ce2edde97 --- /dev/null +++ b/packages/web/modals/add-funds.tsx @@ -0,0 +1,373 @@ +import { CoinPretty, PricePretty } from "@keplr-wallet/unit"; +import { MinimalAsset } from "@osmosis-labs/types"; +import classNames from "classnames"; +import Image from "next/image"; +import { parseAsString, useQueryStates } from "nuqs"; +import { useCallback } from "react"; + +import { Icon } from "~/components/assets"; +import { Tooltip } from "~/components/tooltip"; +import { useTranslation } from "~/hooks"; +import { useBridge } from "~/hooks/bridge"; +import { ModalBase } from "~/modals/base"; + +interface AddFundsModalProps { + isOpen: boolean; + onRequestClose: () => void; + from?: "buy" | "swap"; + fromAsset?: + | (MinimalAsset & + Partial<{ + amount: CoinPretty; + usdValue: PricePretty; + }>) + | undefined; + setFromAssetDenom?: (value: string) => void; + setToAssetDenom?: (value: string) => void; + standalone?: boolean; +} + +export function AddFundsModal({ + isOpen, + onRequestClose, + from, + fromAsset, + setFromAssetDenom: _setFromAssetDenom, + setToAssetDenom: _setToAssetDenom, + standalone, +}: AddFundsModalProps) { + const { t } = useTranslation(); + const { bridgeAsset } = useBridge(); + + const [, set] = useQueryStates({ + tab: parseAsString, + to: parseAsString, + from: parseAsString, + }); + + const setFromAssetDenom = useCallback( + (value: string) => + _setFromAssetDenom ? _setFromAssetDenom(value) : set({ from: value }), + [_setFromAssetDenom, set] + ); + + const setToAssetDenom = useCallback( + (value: string) => + _setToAssetDenom ? _setToAssetDenom(value) : set({ to: value }), + [_setToAssetDenom, set] + ); + + return ( + +
+
+
{t("limitOrders.addFunds")}
+ +
+
+ {from === "buy" ? ( + + {t("limitOrders.youNeed")} {" "} + {t("limitOrders.fundsOsmosisToBuyAssets")} + {t("limitOrders.chooseAnOption")} + + ) : ( + + {t("limitOrders.errors.noAssetAvailable", { + coinName: fromAsset?.coinName ?? "", + })} + + )} +
+
+ {from === "buy" ? ( + + ) : ( + + )} + {from === "buy" ? ( + + ) : ( + + )} + {from === "buy" ? ( + + ) : ( + + )} +
+
+ +
+
+
+ ); +} + +function StableCoinsInfoTooltip() { + const { t } = useTranslation(); + + return ( + + +
+ + {t("limitOrders.whatIsAStablecoin.title")} + + + {t("limitOrders.whatIsAStablecoin.description")} + +
+ + + + + + + + + + + +
+ } + className="underline decoration-dotted underline-offset-4" + > + <>{t("limitOrders.stablecoin")} + + ); +} diff --git a/packages/web/modals/base.tsx b/packages/web/modals/base.tsx index f3e4cacf17..8fae634c04 100644 --- a/packages/web/modals/base.tsx +++ b/packages/web/modals/base.tsx @@ -1,6 +1,5 @@ import classNames from "classnames"; -import React, { PropsWithChildren } from "react"; -import { ReactNode } from "react"; +import React, { PropsWithChildren, ReactNode } from "react"; import ReactModal, { setAppElement } from "react-modal"; import { useUnmount } from "react-use"; @@ -59,7 +58,7 @@ export const ModalBase = ({ overlayClassName )} className={classNames( - "absolute flex max-h-[95vh] w-full max-w-modal flex-col overflow-auto rounded-3xl bg-osmoverse-800 p-8 outline-none md:w-[98%] md:px-4", + "absolute mx-10 my-8 flex max-h-[95vh] w-full max-w-modal flex-col overflow-auto rounded-3xl bg-osmoverse-800 p-8 outline-none sm:max-h-full sm:w-full sm:px-4", className )} closeTimeoutMS={150} diff --git a/packages/web/modals/review-order.tsx b/packages/web/modals/review-order.tsx new file mode 100644 index 0000000000..6e4e11a943 --- /dev/null +++ b/packages/web/modals/review-order.tsx @@ -0,0 +1,672 @@ +import { + CoinPretty, + Dec, + IntPretty, + PricePretty, + RatePretty, +} from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { ObservableSlippageConfig } from "@osmosis-labs/stores"; +import classNames from "classnames"; +import Image from "next/image"; +import { parseAsString, useQueryState } from "nuqs"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import AutosizeInput from "react-input-autosize"; + +import { Icon } from "~/components/assets"; +import { Button } from "~/components/buttons"; +import { GenericDisclaimer } from "~/components/tooltip/generic-disclaimer"; +import { RecapRow } from "~/components/ui/recap-row"; +import { Skeleton } from "~/components/ui/skeleton"; +import { EventName, EventPage } from "~/config/analytics-events"; +import { + useAmplitudeAnalytics, + useOneClickTradingSession, + useTranslation, +} from "~/hooks"; +import { isValidNumericalRawInput } from "~/hooks/input/use-amount-input"; +import { useSwap } from "~/hooks/use-swap"; +import { ModalBase } from "~/modals"; +import { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; + +interface ReviewOrderProps { + isOpen: boolean; + onClose: () => void; + confirmAction: () => void; + isConfirmationDisabled: boolean; + slippageConfig?: ObservableSlippageConfig; + outAmountLessSlippage?: IntPretty; + outFiatAmountLessSlippage?: PricePretty; + outputDifference?: RatePretty; + showOutputDifferenceWarning?: boolean; + percentAdjusted?: Dec; + limitPriceFiat?: PricePretty; + limitSetPriceLock?: (lock: boolean) => void; + baseDenom?: string; + title: string; + gasAmount?: PricePretty; + isGasLoading?: boolean; + gasError?: Error | null; + expectedOutput?: CoinPretty; + expectedOutputFiat?: PricePretty; + inAmountToken?: CoinPretty; + inAmountFiat?: PricePretty; + fromAsset?: ReturnType["fromAsset"]; + toAsset?: ReturnType["toAsset"]; + page?: EventPage; + isBeyondOppositePrice?: boolean; +} + +export function ReviewOrder({ + isOpen, + onClose, + confirmAction, + isConfirmationDisabled, + slippageConfig, + outAmountLessSlippage, + outFiatAmountLessSlippage, + outputDifference, + showOutputDifferenceWarning, + percentAdjusted, + limitPriceFiat, + baseDenom, + title, + gasAmount, + isGasLoading, + gasError, + limitSetPriceLock, + expectedOutput, + expectedOutputFiat, + inAmountToken, + inAmountFiat, + toAsset, + fromAsset, + page, + isBeyondOppositePrice = false, +}: ReviewOrderProps) { + const { t } = useTranslation(); + + const { logEvent } = useAmplitudeAnalytics(); + const [manualSlippage, setManualSlippage] = useState(""); + const [isEditingSlippage, setIsEditingSlippage] = useState(false); + const [tab] = useQueryState("tab", parseAsString.withDefault("swap")); + const { isOneClickTradingEnabled } = useOneClickTradingSession(); + const [orderType] = useQueryState( + "type", + parseAsString.withDefault("market") + ); + + const isManualSlippageTooHigh = +manualSlippage > 1; + const isManualSlippageTooLow = manualSlippage !== "" && +manualSlippage < 0.1; + + //Value is memoized as it must be frozen when the component is mounted + const initialOutput = useMemo( + () => outAmountLessSlippage ?? new IntPretty(0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const { diffGteSlippage, restart } = useMemo( + () => { + let originalValue = initialOutput; + return { + diffGteSlippage: slippageConfig + ? originalValue + .sub(outAmountLessSlippage ?? new IntPretty(0)) + .toDec() + .gte(slippageConfig?.slippage.toDec()) + : false, + restart: () => { + originalValue = outAmountLessSlippage ?? new IntPretty(0); + }, + }; + }, + + /** + * Dependencies are disabled for this hook as we only want to update the + * current slippage amount when the outAmountLessSlippage changes. + * + * This is to monitor if the output amount changes too much from the original + * quote so as to warn the user. + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + [outAmountLessSlippage, slippageConfig] + ); + + const handleManualSlippageChange = useCallback( + (value: string) => { + if (value.length > 3) return; + + if (value === "") { + setManualSlippage(""); + slippageConfig?.setManualSlippage( + slippageConfig?.defaultManualSlippage + ); + return; + } + + if (!isValidNumericalRawInput(value)) { + return; + } + + setManualSlippage(value); + slippageConfig?.setManualSlippage(new Dec(+value).toString()); + }, + [slippageConfig] + ); + + useEffect(() => { + if (limitSetPriceLock && orderType === "limit") limitSetPriceLock(isOpen); + }, [limitSetPriceLock, isOpen, orderType]); + + const gasFeeError = useMemo(() => { + if (!!gasAmount && !gasError) return; + + return isOneClickTradingEnabled + ? t("swap.gas.oneClickTradingError") + : t("swap.gas.error"); + }, [gasAmount, isOneClickTradingEnabled, gasError, t]); + + const GasEstimation = useMemo(() => { + return !!gasFeeError ? ( + + + {" "} + {t("swap.gas.unknown")} + + + ) : ( + + + {gasAmount && gasAmount.toString()} + + ); + }, [gasAmount, isGasLoading, gasFeeError, t]); + + return ( + +
+
+
{title}
+ +
+
+ {orderType === "limit" && tab !== "swap" && ( +
+
+
+ {(tab === "buy" && !isBeyondOppositePrice) || + (tab === "sell" && isBeyondOppositePrice) ? ( + + + + ) : ( + + )} +
+ + If {baseDenom} price reaches{" "} + {limitPriceFiat && + formatPretty( + limitPriceFiat, + getPriceExtendedFormatOptions(limitPriceFiat.toDec()) + )} + + {percentAdjusted && ( +
+
+ {!percentAdjusted.isZero() && ( + + )} +
+ + {formatPretty(percentAdjusted.mul(new Dec(100)).abs(), { + maxDecimals: 3, + })} + % + +
+ )} +
+
+ )} +
+
+
+ {fromAsset && ( + {`${fromAsset.coinDenom} + )} +
+

+ {tab === "buy" + ? t("limitOrders.pay") + : t("limitOrders.sell")} +

+ {inAmountToken && ( + + {formatPretty(inAmountToken)} + + )} +
+
+
+

+ {formatPretty( + inAmountFiat ?? new PricePretty(DEFAULT_VS_CURRENCY, 0), + { + ...getPriceExtendedFormatOptions( + inAmountFiat?.toDec() ?? new Dec(0) + ), + } + )} +

+
+
+
+
+
+ +
+
+
+
+
+ {toAsset && ( + {`${toAsset.coinDenom} + )} +
+

+ {tab === "sell" + ? t("limitOrders.receive") + : t("portfolio.buy")} +

+ + {expectedOutput && ( + <> + {formatPretty(expectedOutput.toDec(), { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + })}{" "} + {toAsset?.coinDenom} + + )} + +
+
+
+

+ {outputDifference && ( + {`-${outputDifference}`} + )} + + {formatPretty(expectedOutputFiat ?? new Dec(0), { + ...getPriceExtendedFormatOptions( + expectedOutputFiat?.toDec() ?? new Dec(0) + ), + })} + +

+
+
+
+
+
+ + {tab === "buy" + ? t("limitOrders.aboveMarket.title") + : t("limitOrders.belowMarket.title")} + + } + body={ + + {tab === "buy" + ? t("limitOrders.aboveMarket.description") + : t("limitOrders.belowMarket.description")} + + } + > +
+ {isBeyondOppositePrice && ( + + )} + {orderType === "limit" + ? t("limitOrders.limit") + : t("limitOrders.market")} +
+ + } + /> + {slippageConfig && orderType === "market" && ( +
+ +
+ { + slippageConfig?.setIsManualSlippage(true); + setIsEditingSlippage(true); + }} + onBlur={() => { + if ( + isManualSlippageTooHigh && + +manualSlippage > 50 + ) { + handleManualSlippageChange( + (+manualSlippage).toString().split("")[0] + ); + } + setIsEditingSlippage(false); + }} + onChange={(e) => { + handleManualSlippageChange(e.target.value); + + logEvent([ + EventName.Swap.slippageToleranceSet, + { + fromToken: fromAsset?.coinDenom, + toToken: toAsset?.coinDenom, + isOnHome: true, + percentage: + slippageConfig?.slippage.toString(), + page, + }, + ]); + }} + /> + {manualSlippage !== "" && ( + + % + + )} +
+
+ } + /> + {isManualSlippageTooHigh && ( +
+ +
+ + {t("limitOrders.errors.tradeMayResultInLossOfValue")} + + + {t("limitOrders.lowerSlippageToleranceRecommended")} + +
+
+ )} + {isManualSlippageTooLow && ( +
+ + + +
+ + {t("limitOrders.errors.tradeMayNotExecuted")} + + + {t("limitOrders.tryHigherSlippage")} + +
+
+ )} +
+ )} + {orderType === "market" && ( +
+ )} + {orderType === "market" ? ( + + {outAmountLessSlippage && + outFiatAmountLessSlippage && + toAsset && ( + + {formatPretty(outAmountLessSlippage, { + maxDecimals: 6, + })}{" "} + {toAsset.coinDenom} + + )}{" "} + {outFiatAmountLessSlippage && ( + + (~ + {formatPretty(outFiatAmountLessSlippage, { + ...getPriceExtendedFormatOptions( + outFiatAmountLessSlippage.toDec() + ), + })} + ) + + )} + + } + /> + ) : ( + + {t("transfer.free")} + + } + /> + )} + + {!isGasLoading ? ( + GasEstimation + ) : ( + + )} + + } + /> +
+ {isBeyondOppositePrice && ( +
+ +
+ + {tab === "buy" + ? t("limitOrders.aboveMarket.title") + : t("limitOrders.belowMarket.title")} + + + {tab === "buy" + ? t("limitOrders.aboveMarket.description") + : t("limitOrders.belowMarket.description")} + +
+
+ )} + {!diffGteSlippage && ( +
+ +
+ )} +
+
+ {diffGteSlippage && ( +
+
+
+ +
+ + {t("limitOrders.quoteUpdated")} + + +
+
+ )} +
+ + ); +} diff --git a/packages/web/modals/token-select-modal-limit.tsx b/packages/web/modals/token-select-modal-limit.tsx new file mode 100644 index 0000000000..89d90738dd --- /dev/null +++ b/packages/web/modals/token-select-modal-limit.tsx @@ -0,0 +1,427 @@ +import { PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import classNames from "classnames"; +import { debounce } from "debounce"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { + ChangeEvent, + FunctionComponent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; + +import { Icon } from "~/components/assets"; +import { Intersection } from "~/components/intersection"; +import { Spinner } from "~/components/loaders"; +import { + useFilteredData, + useTranslation, + useWalletSelect, + useWindowKeyActions, +} from "~/hooks"; +import { useDraggableScroll } from "~/hooks/use-draggable-scroll"; +import { useKeyboardNavigation } from "~/hooks/use-keyboard-navigation"; +import { SwapAsset, useRecommendedAssets } from "~/hooks/use-swap"; +import { ActivateUnverifiedTokenConfirmation, ModalBase } from "~/modals"; +import { useStore } from "~/stores"; +import { UnverifiedAssetsState } from "~/stores/user-settings"; +import { formatPretty } from "~/utils/formatter"; + +interface TokenSelectModalLimitProps { + isOpen: boolean; + onClose?: () => void; + onSelect?: (tokenDenom: string) => void; + showRecommendedTokens?: boolean; + showSearchBox?: boolean; + selectableAssets: SwapAsset[]; + isLoadingSelectAssets?: boolean; + isFetchingNextPageAssets?: boolean; + hasNextPageAssets?: boolean; + fetchNextPageAssets?: () => void; + headerTitle: string; + hideBalances?: boolean; + assetQueryInput?: string; + setAssetQueryInput?: (input: string) => void; +} + +export const TokenSelectModalLimit: FunctionComponent = + observer( + ({ + isOpen, + onClose: onCloseProp, + onSelect: onSelectProp, + showSearchBox = true, + showRecommendedTokens, + selectableAssets, + isLoadingSelectAssets = false, + isFetchingNextPageAssets = false, + hasNextPageAssets = false, + fetchNextPageAssets, + headerTitle, + hideBalances, + setAssetQueryInput, + assetQueryInput, + }) => { + const { t } = useTranslation(); + + const { userSettings, accountStore } = useStore(); + const { onOpenWalletSelect } = useWalletSelect(); + const recommendedAssets = useRecommendedAssets(); + + const isWalletConnected = accountStore.getWallet( + accountStore.osmosisChainId + )?.isWalletConnected; + + const [_isRequestingClose, setIsRequestingClose] = useState(false); + const [confirmUnverifiedAssetDenom, setConfirmUnverifiedAssetDenom] = + useState(null); + + const showUnverifiedAssetsSetting = + userSettings.getUserSettingById( + "unverified-assets" + ); + const shouldShowUnverifiedAssets = + showUnverifiedAssetsSetting?.state.showUnverifiedAssets; + + const searchBoxRef = useRef(null); + const quickSelectRef = useRef(null); + + const { onMouseDown: onMouseDownQuickSelect } = + useDraggableScroll(quickSelectRef); + + const onSelect = (coinDenom: string) => { + onSelectProp?.(coinDenom); + onClose(); + }; + + const onClickAsset = (coinDenom: string) => { + let isRecommended = false; + const selectedAsset = + selectableAssets.find((asset) => asset?.coinDenom === coinDenom) ?? + recommendedAssets.find((asset) => { + if (asset.coinDenom === coinDenom) { + isRecommended = true; + return true; + } + return false; + }); + + // shouldn't happen, but doing nothing is better + if (!selectedAsset) return; + + if ( + !isRecommended && + !shouldShowUnverifiedAssets && + !selectedAsset.isVerified + ) { + return setConfirmUnverifiedAssetDenom(coinDenom); + } + + onSelect(coinDenom); + }; + + const [filterValue, setQuery, results] = useFilteredData( + selectableAssets, + ["coinDenom", "coinName"] + ); + + const { + selectedIndex: keyboardSelectedIndex, + setSelectedIndex: setKeyboardSelectedIndex, + itemContainerKeyDown, + searchBarKeyDown, + setItemAttribute, + } = useKeyboardNavigation({ + items: results, + onSelectItem: (item) => { + if (item) { + onSelect(item.coinDenom); + } + }, + searchBoxRef, + }); + + const onClose = () => { + setIsRequestingClose(true); + setKeyboardSelectedIndex(0); + onCloseProp?.(); + setQuery(""); + if (setAssetQueryInput) setAssetQueryInput(""); + }; + + useWindowKeyActions({ + Escape: onClose, + }); + + const searchValue = useMemo( + () => (!!assetQueryInput ? assetQueryInput : filterValue), + [assetQueryInput, filterValue] + ); + + const onSearch = useCallback( + (nextValue: string) => { + setKeyboardSelectedIndex(0); + if (setAssetQueryInput) { + setAssetQueryInput(nextValue); + } else { + setQuery(nextValue); + } + }, + [setAssetQueryInput, setKeyboardSelectedIndex, setQuery] + ); + + const assetToActivate = useMemo( + () => + selectableAssets.find( + (asset) => asset && asset.coinDenom === confirmUnverifiedAssetDenom + ), + [confirmUnverifiedAssetDenom, selectableAssets] + ); + + if (!isOpen) return; + + return ( +
+ { + if (!confirmUnverifiedAssetDenom) return; + showUnverifiedAssetsSetting?.setState({ + showUnverifiedAssets: true, + }); + onSelect(confirmUnverifiedAssetDenom); + }} + onRequestClose={() => { + setConfirmUnverifiedAssetDenom(null); + }} + /> + +
+
+
{headerTitle}
+ +
+ {!isWalletConnected && ( +
+ +

+ {t("limitOrders.toSeeYourBalances")} +

+
+ )} +
+ {showSearchBox && ( +
e.stopPropagation()}> +
+
+ +
+ ) => + onSearch(e.target.value), + 300 + )} + placeholder={t("limitOrders.searchAssets")} + className="h-6 w-full bg-transparent text-base leading-6 placeholder:tracking-[0.5px] placeholder:text-osmoverse-500" + /> +
+
+ )} + {showRecommendedTokens && ( +
+ {recommendedAssets.map(({ coinDenom, coinImageUrl }) => { + return ( + + ); + })} +
+ )} +
+ + {isLoadingSelectAssets ? ( +
+ +
+ ) : ( +
+ {/* TODO: fix typing */} + {results.length > 0 ? ( + (results as any[]).map( + ( + { + coinDenom, + coinMinimalDenom, + coinImageUrl, + coinName, + amount, + usdValue, + isVerified, + }, + index + ) => { + return ( + + ); + } + ) + ) : ( +
+ + + No results for "{searchValue}" + + + Try adjusting your search query + +
+ )} + { + // If this element becomes visible at bottom of list, fetch next page + if (!isFetchingNextPageAssets && hasNextPageAssets) { + fetchNextPageAssets?.(); + } + }} + /> +
+ )} +
+
+
+ ); + } + ); diff --git a/packages/web/modals/trade-tokens.tsx b/packages/web/modals/trade-tokens.tsx index ae7a93592a..a8dce22845 100644 --- a/packages/web/modals/trade-tokens.tsx +++ b/packages/web/modals/trade-tokens.tsx @@ -1,8 +1,10 @@ import { FunctionComponent } from "react"; +import { Icon } from "~/components/assets"; import { SwapTool } from "~/components/swap-tool"; +import { AltSwapTool } from "~/components/swap-tool/alt"; import { EventPage } from "~/config"; -import { useConnectWalletModalRedirect } from "~/hooks"; +import { useConnectWalletModalRedirect, useFeatureFlags } from "~/hooks"; import { ModalBase, ModalBaseProps } from "~/modals/base"; export const TradeTokens: FunctionComponent< @@ -23,25 +25,60 @@ export const TradeTokens: FunctionComponent< }) => { const { showModalBase, accountActionButton, walletConnected } = useConnectWalletModalRedirect({}, modalProps.onRequestClose); + const featureFlags = useFeatureFlags(); + + if (!featureFlags.limitOrders) { + return ( + + + + ); + } return ( - +
+ Swap +
+ +
+
+
+ +
); }; diff --git a/packages/web/pages/_app.tsx b/packages/web/pages/_app.tsx index b89ec4efd9..b4e299ee64 100644 --- a/packages/web/pages/_app.tsx +++ b/packages/web/pages/_app.tsx @@ -1,5 +1,5 @@ -import "../styles/globals.css"; // eslint-disable-line no-restricted-imports import "react-toastify/dist/ReactToastify.css"; // some styles overridden in globals.css +import "../styles/globals.css"; // eslint-disable-line no-restricted-imports import { apiClient } from "@osmosis-labs/utils"; import { useQuery } from "@tanstack/react-query"; @@ -15,10 +15,13 @@ import { enableStaticRendering, observer } from "mobx-react-lite"; import type { AppProps } from "next/app"; import Image from "next/image"; import { useRouter } from "next/router"; -import { ComponentType, useMemo } from "react"; -import { FunctionComponent } from "react"; -import { ReactNode } from "react"; -import { useEffect } from "react"; +import { + ComponentType, + FunctionComponent, + ReactNode, + useEffect, + useMemo, +} from "react"; import { ErrorBoundary } from "react-error-boundary"; import { Bounce, ToastContainer } from "react-toastify"; import { WagmiProvider } from "wagmi"; @@ -135,6 +138,14 @@ const MainLayoutWrapper: FunctionComponent<{ onClose: onCloseLeavingOsmosisToLevana, } = useDisclosure(); + useEffect(() => { + if (flags.limitOrders && flags._isInitialized) { + document.body.classList.add("!bg-osmoverse-1000"); + } else { + document.body.classList.remove("!bg-osmoverse-1000"); + } + }, [flags.limitOrders, flags._isInitialized]); + const menus = useMemo(() => { let conditionalMenuItems: (MainLayoutMenu | null)[] = []; @@ -195,7 +206,7 @@ const MainLayoutWrapper: FunctionComponent<{ let menuItems: (MainLayoutMenu | null)[] = [ { - label: t("menu.swap"), + label: t("limitOrders.trade"), link: "/", icon: , selectionTest: /\/$/, diff --git a/packages/web/pages/apps.tsx b/packages/web/pages/apps.tsx index c92f2f0e96..f98a26bdaa 100644 --- a/packages/web/pages/apps.tsx +++ b/packages/web/pages/apps.tsx @@ -91,7 +91,7 @@ export const AppStore: React.FC = ({ apps }) => { }, [width]); return ( -
+
= observer( ({ tweets }) => { const { t } = useTranslation(); const router = useRouter(); + const featureFlags = useFeatureFlags(); const { title, details, coinGeckoId, asset: asset } = useAssetInfo(); @@ -80,6 +88,17 @@ const AssetInfoView: FunctionComponent = observer( coinGeckoId ); + const swapToolProps: SwapToolProps = useMemo( + () => ({ + fixedWidth: true, + useQueryParams: false, + useOtherCurrencies: true, + initialSendTokenDenom: asset.coinDenom === "USDC" ? "OSMO" : "USDC", + initialOutTokenDenom: asset.coinDenom, + page: "Token Info Page", + }), + [asset.coinDenom] + ); useAmplitudeAnalytics({ onLoadEvent: [ EventName.TokenInfo.pageViewed, @@ -125,15 +144,10 @@ const AssetInfoView: FunctionComponent = observer( [assetInfoConfig] ); - const SwapTool_ = ( - + const SwapTool_ = featureFlags.limitOrders ? ( + + ) : ( + ); return ( diff --git a/packages/web/pages/index.tsx b/packages/web/pages/index.tsx index 5674315226..00c5b99693 100644 --- a/packages/web/pages/index.tsx +++ b/packages/web/pages/index.tsx @@ -1,13 +1,15 @@ import dayjs from "dayjs"; import { observer } from "mobx-react-lite"; +import Image from "next/image"; import { useLocalStorage } from "react-use"; import { AdBanners } from "~/components/ad-banner"; import { ErrorBoundary } from "~/components/error/error-boundary"; import { ProgressiveSvgImage } from "~/components/progressive-svg-image"; import { SwapTool } from "~/components/swap-tool"; +import { TradeTool } from "~/components/trade-tool"; import { EventName } from "~/config"; -import { useAmplitudeAnalytics, useFeatureFlags } from "~/hooks"; +import { useAmplitudeAnalytics, useFeatureFlags, useNavBar } from "~/hooks"; import { api } from "~/utils/trpc"; export const SwapPreviousTradeKey = "swap-previous-trade"; @@ -17,6 +19,44 @@ export type PreviousTrade = { }; const Home = () => { + const featureFlags = useFeatureFlags(); + if (!featureFlags._isInitialized) return null; + return featureFlags.limitOrders ? : ; +}; + +const HomeNew = () => { + const featureFlags = useFeatureFlags(); + + useAmplitudeAnalytics({ + onLoadEvent: [EventName.Swap.pageViewed, { isOnHome: true }], + }); + + useNavBar({ title: " " }); + + return ( +
+
+
+ +
+
+
+ {featureFlags.swapsAdBanner && } + +
+
+
+
+ ); +}; + +const HomeV1 = () => { const featureFlags = useFeatureFlags(); const [previousTrade, setPreviousTrade] = useLocalStorage(SwapPreviousTradeKey); @@ -26,7 +66,7 @@ const Home = () => { }); return ( -
+
+
{ ); return ( -
+
diff --git a/packages/web/pages/transactions.tsx b/packages/web/pages/transactions.tsx index bbc3d03731..f96b9fcc1e 100644 --- a/packages/web/pages/transactions.tsx +++ b/packages/web/pages/transactions.tsx @@ -1,6 +1,7 @@ import { observer } from "mobx-react-lite"; import Image from "next/image"; import { useRouter } from "next/router"; +import { useQueryState } from "nuqs"; import { useEffect, useMemo, useState } from "react"; import { LinkButton } from "~/components/buttons/link-button"; @@ -8,9 +9,10 @@ import { TransactionContent } from "~/components/transactions/transaction-conten import { TransactionDetailsModal } from "~/components/transactions/transaction-details/transaction-details-modal"; import { TransactionDetailsSlideover } from "~/components/transactions/transaction-details/transaction-details-slideover"; import { EventName } from "~/config"; -import { useFeatureFlags, useNavBar } from "~/hooks"; import { useAmplitudeAnalytics, + useFeatureFlags, + useNavBar, useTranslation, useWalletSelect, useWindowSize, @@ -79,6 +81,8 @@ const Transactions: React.FC = observer(() => { onLoadEvent: [EventName.TransactionsPage.pageViewed], }); + const [fromPage] = useQueryState("fromPage"); + const { t } = useTranslation(); useNavBar({ @@ -94,9 +98,13 @@ const Transactions: React.FC = observer(() => { className="text-osmoverse-200" /> } - label={t("menu.portfolio")} - ariaLabel={t("menu.portfolio")} - href="/portfolio" + label={ + fromPage === "swap" ? t("limitOrders.trade") : t("menu.portfolio") + } + ariaLabel={ + fromPage === "swap" ? t("limitOrders.trade") : t("menu.portfolio") + } + href={fromPage === "swap" ? "/" : "/portfolio"} /> ), ctas: [], diff --git a/packages/web/public/icons/sprite.svg b/packages/web/public/icons/sprite.svg index b898ad2386..45c52a50a4 100644 --- a/packages/web/public/icons/sprite.svg +++ b/packages/web/public/icons/sprite.svg @@ -1056,7 +1056,7 @@ /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web/public/images/quote-swap-from-another-asset.png b/packages/web/public/images/quote-swap-from-another-asset.png new file mode 100644 index 0000000000000000000000000000000000000000..8c6c23a13c60cd3d6c05de3e1b9bc8c9c736fa83 GIT binary patch literal 11679 zcmV;QEnw1#P)vH0ET^7ey|aN0Y#_?n zj;?eBLJu{eCnThA$?nd~|9Rfo*_KU%p#OV5nau3WJM+FX^POkDPkG;w&@5&>+$IYgx34&R?Rs#dR^Hh}O}B zd==U@6RayUkynIPkgTk%6boH{4Jy?yWl2;&oif)Qte@5uX`&5Uz66=-$I61Dq9U|{ zWM$>1#G5=5pke^s({7rEK2%ql1TBrQAxjc!=-TY1Hn=oR%GXR-Wiw&Zwtef4<19tO z8A<@O(`d>ikX{-%7Oggp$qQ0oNnh4-qUSOM|V7AGEl`RExUQR`d*PAen_RFQ? z=Nc!YMVy)zSdv5=B1N=~AEen3pFO|#Dg%nVvHRJ#6L zKXP$qLSU9=p9z>5v~jX~ilj*sqU$%C%}8&PMst(+tf#_MKP z`(|h1h7z2Cv>3o)z~w20VaULg46H(+CGbN4R}1V%Sfiq9V6N%A7_ibL%#}@;*%Xjj z+xipy(==I|9%xG|h)S(A&2_5E%vlAtqQ**w0e5N=3d1g~U7TQ+!OrtwET@!B^pi}1#xOmhs3>|&}diLsu&RsepF3uXUpQ@_Zy=yN@ zPo2hhUoS)c)?GMUbX063EV!uwa|kKV6%?&6LJOot04Bi900UP;sz9o)*mN`oaT^X= zDk34EdBb#~771)BMaQ8zua%G|S)`e<>VLAIZ-ykR)9D~70P5+~RuN6kduq2OX8kAe z&}&)nfG{vZ*S zpq4_sBOHy))nru~E!lK!oR{Oy^7MFg!!@w(r7~)_sJU0b_Jck zmSeZuareEq;*KddAtfalCB>((YUNt&*tQFY4i+`*_}SgMVDR~aNNW#AyY?AGFHYl~ zx8~vFd7s1ObaRm+x(B11qm7#cFoA0d9qdI!8f4)bmS}-aY4uGyqX=ybR-{F0+u&M6 z!O8%8B^~yjzYOsu(}O%KHeFEl@VihHY zG!+1+4w<2;RsnosY>2JPrAk@V?)lZ8za-PvS?5ygHXx*uw0m;1<|mWUNA=?;ub=MV zJtkzrB&l;)>ki84j~8D0D>`<{#O~dDvGCKyDA>IZhEhvOO~$}M{gIZQip9QPl6UYXns961J zHENEgQWvMuel5tjU0wHZPS}|+*1VL4Qn@H+0yTWV7_?N2;6k@c(loAL&u+km;d>O zSWnpc^Pa+We{JYk4FXtzS4AEI8dd-o8o~@P!*zY%&UT}wI2E#HG8&Ab1m2si~#LXum5sxZ(L&`^N+@0dWG|$Atzp zkmP`-)qw}Y>7Z#rmsrr_ASiKA;|8cEA~doVR(HrlA*hG`02F+$;lqc0^_oYX0!Y?c zTymF!OYTb~Z7O5^XXV(mKt|PJI~!mDTafo-@XF#CNgXRN_1`wwQ!QBXFBi6d=R-oe zg4|!&FmQ|=s!PK1*Q>DRgLr6uE3#|$;+i8L;pdVc5{}^y87+T!9EoY}2=`5U0yVYHMvo&So&cPRL^|Gc1m4`sMq&Zcg@vDfDKy#D*It1eCS8YwgaovP zc)dP+_VFTowfMi{_e3%bZ=84?){&NeVCti&tMk&mRj(>2+W1}czNY9E!eCSJH7&-& zK&OC2klO~`VEPUS6p(@xAxIYtSA_tmp|XwNVs`_flHnPyIHBRahZOj|;f*2(4*}BF z)`{DnlX2lB2llS3#kgu%;3Fz%qX$Gf;+e;w_FAOh*a6&{l27;toH^q3}JHPb7 zQxmCqWq4ul>=Zz3+ubVMGd`aOQ=W6cnn*{N6?j};IIDapKdPYQfQA!0fn&QA47=5g zP6N&O;wcv@cBY4@4IkHM#aGYx&~=~_?YbqR+a)q?KJf_@)q~a+25#cCkw}<+5oB7f znYk=xxoPc}pjP&wrNXqKSC4MN-#_N>!2C}a>D#UzgWQXUi)Gf%JZ;X8O;1b`^H>uB z^@$vE4ux=BM9X*MO#ETD+ z;rH)X=cceHgo+Ig-zNgVnj(z0u39*F36Gjbl{~wv;V-U}RJY)jk}1WCxW0+7WX1=4 zQ9*-FU_0b)3xG4kl!H_RVkbx;-b>zs6X6#~6WZ67;l|WgZD`v~caE!)3EBOV$r#g6 zcH9qzxNd^o3|p#%oG~`UO&~9W?1!&bf|=fO&T=0%epO9~jfX-lG^?T0_dCs4@_Hq1 zd)5x;%vDf|D$&|v&CN#2oJlZcHSS1Y-dk*T;!Gs>lHtMSy+jr6C#qn`%GK-f#_VT; z)Z(^D6Ji3GjW!1F`4lak2g^Vs|%B#wj#ZIJgQH+@xw>8*zui$niC0h>^Na~u^UYfj*z$1 zT#YtefsO;o@jgOEuMr7|Pc!4Oh3VM-t%B7bIN>~%7~%ijv($`}%Ss^sbp_6aFo2r^ zFH?uJ@i-U;8_WDpzo@sJjW`3qy?SQX+s@R1jW(;*ihsPACw#LXefXK!cJF<6H4D5f zu5bSirR)IWhTetvVN>8){crg8Er+R74iay80nL@)JbXW%`pXN%jlaNDBy9iOhW#6Sc>Pz$G4GLT9N+K5$UEck=$Cd}^Mn)j z%xc<`L&yrfc@j`~&$$6_b7d}R-?I@%Vq$PPF>E>3y4n~=R80QWziT$TQG8G9FHEB%Ht}wERJ%SQE^B6 zQt}iy$SdK-q8TRCRVNUAXzaAL;D39j6XXAojPrBbpkQet=09k`i<3&RbD0~%Cne#* z_Y*MW_FDK!!#li{=zd}kelB6{`%U5@oQ-VW8q(gk-85c!Bn;tYUT4VO{gHJ^1Opl5 zrJ-_D@4FpGj~vJ9@7I&7EFCx9dLw==xo-SeWVCC8-Mb2~duM@&{&5JK0#MU9CxgrA z8rZ}cWxA&HmwTn^#l=FqYO00+p%s((jezzw)`e|%{aA^brKJH!vEg!K60J#raIt34 zitvUOZV?nZ_*)Xn-jA`rNgxZAUVqQmLMHcCD8H?7;WAx`>YL1F6s%PPn%aVj1L;`s zhXlMdxs;4O6RvwK6%Wl#K>yTzI1^#RjHy9Wlbjr1Z{P6k+uK*+Y$e>Y(4l=h!Fd_D zJn=HGGh`KU;)wH*bcfTJv6qU_?uYMvBG%tHX*_uw%njD5oGujfAbis5YV~a=ccO0d ze8GuXwAwQGPwar^sX=ok!JdFA_e>Vc>?`J=$+g#y(Y3JTxs%uHt&>E8j?j!%i;IP; z!FcTl;}8J}z5N50SNLXHsQ#L`WRe*P=ck0CZpN{q(Wf6mTl-NI_ocu?YE~5njcJQx zdt9hGl1$GSH??`LC557Ky+)YHh*MCsO^x&wQX@|9O~+ePT^M$=gwYS$Fv7DHEpeJL zr*+J@T+Gi@G75heTwZwXHNbA1m9SAZkGu}I-84b`W{(5|*H|`$%dA+vE@EE^x zyKg};1wD3b-z~K7sN9Pht@wFXlG?(Jf*!M6Emq=K}rBZ_exw%lS9M+U;UA zC4&YJKzf^0?4gkL;lsy7^3eSE7xE^4HM}eb=tB-e%}+*~XJ>Ul!y?3W4J{k7j0i3) zG`5f2TSwi*oe#oaS{DIcaiI*4gFf*iqmkcND&7}KA5`MaBW|Qf8d2IxqSlgXp>@so zi?T77(Z__tJKZpQ9rd;gpNVS6rjO$A{-h)DZ#&i8LnC3z`AA=J?^%I2T#g+(MmOgy z(HoE#UU@S@6Ekqb!OMmmmkAd(-XwZEaV|pN{=KmF2VFZ~aph>7EAgM)1@~7oP?x{3 z$+B!ijlJq}vAk~0M!}7H^b*%TmuZCApbw0untjd?*VR>W{kT~ua1o#sx{#|4zLGit zVvYfny;6wwdF)gVB=3afP=peCG_7!RePBv*9r|5n5mj2_BD2VvBO3AAj#=qg^J7(1 znoxKpRIkO0Hm8oDxnjyng=5yWXieF;etomb zLU1n-elK%3AOWsJ8=PpbhLHm;?B;7B%Al9m3o?k6QXl#I0x#hJQarn=QU2}`q}|me z;Ce9P7U9_wl2o#{KC5k;?L}IrL{jQLbm&RXnCzk8f(^};c3myxexvA75mS7+fZ${u zpYR`>WH&ktbRp;3I1Iep2K8@lICnMgxc2EIL=Ylel%k3z_mfg=4eqyV-Ihl8Z74j< znw5hS^FR9n&%gXeMETxfTGx=?J-dl*;bG*w-~Sc|4wAd<(& zoXN(XkzoYj`R^~_P3M^jhdD-a3>F{|p9Na|fzdMZPM)WO%Iz;?-&E0~kdlodgUt->6 zf-{H9scbzrWk>sEdeFWBVIJEn1|SWYJn4Fov%s3yFxX<1H#o783&G%JE&ags=fw7m zw&^Ge^pMEz))g%msnMS-mL8)b0nDN`j~eznuMd7$xfUl+6r+2$rI*o{WKWky=FMsziYiB8WQb%Kd{dv(t&L}Td>NPf_ z8);6jjL)8`!DBOR6PS?KT zj5Mk0M5GIiB`HHfErHKdnu;G6RAT5YNoe1{2E{ua&EmrLWTp-@+1;4-dO8y5^?hKy z2Om9Fj-y-b)NXsAsS+H%=7)~`hbt&l5;ETG(?${%sDg9W8l0CL6mYY2lkJUqPk z+a(d&wkggV4&rc-7al%=w6qio;l{!1)ibC%bj(DniArz5>XYytTunyXXAzs?E}Tr% zs#CKL(=JnwG`d`F5f$X5`I6#ODdvE-lo+rIDZ@12LY7Vk8`h5J9WV!+rGawW?sJCuwMr<+l{jWn(rNk3W&UKa1l zLO5sF*S73ZYqRws*zP_b|6fRRqCK&83*@+PH~4cc{^Q$GJMUi9ZThfTGop;k@NwATr2r3sP~lX1cQ!$JA0&+nWf$L=OmnUINbB zyOJ=qZxvGGlz=fOL8*xX8-Yg+qY12a6;)qmZSLpw{5x#f$J>;^TxB=9S}(pk;*^%<|JLR$CpxiJ1T><-%37E;^6AlUHH%UtQjn%L##@CY zJcEqq^WT1%*69z=xT`O(XRxuWEEXpYU*=vJ;YM6iq76rnpAx=hTYx&0l$7FZMNa7q zbJoy^bs{@${T$M|A4IJ8?Oz3RhsG0z*!#d@v0B6%g2$JTBzeDC6GIIli@}(jVL{1p zp8(`3p9!y&ZDuhWSyEBNq{a_m{|Ah46B zcU6ny*zBb3n;;9Nw)QPDqx7H$Z{6;}?iCIZa^?hhccobvafxl3?)Fk+PR7{?Yh(85Mr&Kv!j>G}nvbQ4 z<0teJ^Y(swqsM>b4}Wd2_XJD0!DbkC4Af|e1}__MoEOotLtF9t(19X~%IOK`JGY;^ z%skCiPINT#O)i<6rg24w4vTqKR_D+IreUkBsmeCPgV4E`6>Nwq0WRz#HOfZcs$(|1 zyd?>1isJ=H-99N2j7osh`lR4*clhrB-*p+>*Wkr|=H2xHTzMoORVVy7?^5PuVFm>S zH#}7`zIv$|E8Z*>V71z0NXbr^RPKxC7iB7r5kJxtq-8YbYtUdztwripNjO_!ZCp0^ zW%6@IQW0~G4hS~>qQyS#BB6zQcvBlawgE~8+=|uf#q0k>af#5*VtrJ2n9J{t0bpJi z-IsPPR^GB>E(H&URS#&S@0yv*OgAI2_*Y!DSCrP_!-$ z{Vwxh^?Oci|Bke9u?1~=L4cR@AUNrndw6udz=JD)X+rjecAPq-;hkrE7;Mi%M`sb5 z5;oA{E{&aq7Dtqze!fG9I2cN{S$=k7^nMpm3$)gY5SI=N$iRQ$S zAm&^VFHVu+wr%T9j2JmgbmaW@>*ct1+}K9Pd{E1a;NA0G%x&DQh^NLxicf;=%HKEH zS8n-|@5OxNB|}lLd%rO#;07L^Rrn-G$*v-0o6Czg|I&f{b*BludMJ$p7J-2s!c%#~ z-*KlIrEU{}oD4t$vBN^{FB5@TbWsa8^!QKQ=F_!PV2_Fz$DW z7&V2=OtK5R^d&$^9+Aw`A*TkHPmM$GOOjAiT8IBU@4}{!DUebZkBo%uW&xNrsBKE~ ze7%O&HH4QL#L)&H%g|xN&lsC92sm#?p%q7K1)Gyfo`$3&5p;*x7MKc zL~P!wNnZ3E<-?GP7V;PRvFY;~)VVAeb!R+!3`>Mc?O*<0Db{^pgSRRU#*k7rzZbXf zpN+UCy3;4momejt7fYB6GbfH^yv6cbaPQmEms~97XDZ>;fjRPRQy&U)-e~Vc{nSdC z?sEnlNe+Cw>{ANSmf&wQpBG8(PyOT14Gv(Ud8)`W71?Izmrp>eE{3J=SXgPQ+xRa0 z$F_+icGk91Vq1znG%u%xql8a9k|zNC>_2D1Vm4#^)pw%0s@hHd-)tUJSiB#YMo>Ut zw$asR@`DZ#t^)my^Z|fnSqlW)v|!MUCpj2S*3*205*|1(5`&_~6bN)%CXKD4hpnvM z2~-|)VCBE6(Di%=x?bSGo>eXZ-mst_B0J$*vG)fXzWlozML+s+#RF;hzgOF!=kR#4 zbSW@mmC${to!oF{jCokaWe@ss`LF%R&GSIMZ!lUzyekhi2woP$YvFo#OVH>`N=m|% zd;Sk+Bb+B}$h=R#j4;@E`O9xY!IujLM~~dYzIZ2Us%tUtg9SnZ_dl z2!Rzn5>MlxyDjnNP{>ds4Y~Gx8@djzM4!tNp^&E3_yP!bnJn7CmtM2D(YCt}y~uCg zceE8<2c$u^a34PMTDbjq?XHuw&W@}B6b#9h&}+CAwj>K=lhIn@8&~Dx)JI#ORn|3l zK{*#9mPf%525)nM7fdgz?p}Z@ED*GWx$^`$8FR>ZZ78VGlsF=K9oIHmHde1>Rddqy zwBwyOKEQ;DSL4BlrsA=OXNcjR2IZV5(iwkB*|cFZzWHj2$OFFVmhocb%K0BGWRN)t zU4J*q1p&-j*jJ&{Us!O=YuTYCg@HJzjRPQ*16lC(z^(VQ zhcUWI_&$1+kLU!szym_<*)6TcM;^At?&s9@*~sYvVW5tM?%_3?=cQYqNc zxpx9=X?76~uQ;mU`*~H^@m~!c&QoyXlc{LiF&Xd3 zhF+D5im~S*<>So_UQ`{t4FmX0#CRas5pz>Y?=v17f;n$6y^?|-(H@KOSBGfJiIcIq zP_ZhgrLeMuhadVAKKcA@+khR^3?kHX&z4y`LP%GPU4(rMemhr3%HPcaUUW%U%bA zAY)2k|VIpV3W@}mQNK1tyZ0z~S-?BsG{*Ie)HC}t^9lShC z^yMomC|sW%(cb6-%zQkLoKV``^xC)C9kMj|l6b5qpRD2)DKX^Y(&!|0!10_lGf&2{ zDX=VlO9-wrj6pFs_7ls-dR|A`G@%*?ooVEC2)S=qLwm^&yukp9qb|Jq zi&Ct9(}LYA6L4Y&Mc_`G3B1-|;*L+1;Vk!H*iB@FSZh$Y-iq+Fxr8=O-218nNf~Cb zZtu!kELzb8Jt%&Ct_JsON`|>Rh%q9(Qp+;Jis)uvICx-C3ukrdgt3>85>t4Wdd@3; z;gvTUIZ_=ds|0M>yaU~PW??)5h(pY4*KEW;o|-Lkg)=*}!?_Udk2mv=&mbu|3ByNR zh~9mA;`>$W@TcED%^0Yp8#^L;aKHKhes*T%pPu-031LR`&rV40L1)T3$7F36`+>HdI4x41aw{}g?=0Rae3Z1fr1rD4h@H^lEU5(!d!%pJPKRB zsUwew2Yqtw7bOAnAw42T&OGAvCX^ghVQb^UsN2b1W2-^QUO(#U z{OHiX4wIj?qFqlbIouU|`%(qI{)ZXPx>UF=4s@$2z|SF^otCE9usY#bi}&RYo)-}jjtwOKf-O#N^7B+9(DgeekgIN>z>Dw!qy>_<3 zqwmc7;8VOm_hZqvtgJ4=kmRx19)9SLVjYptTlN;M-w?fTgQUiHTFE0M%+SwCA4Koj zsl_Ne>=VP53hKbLK>%5&6gn+HfL4Tlhf@Yi160ByX@YJz`X!>>Lp`+bKar&s@5k0c z+{HezeI>?DkH?@fb^+SLEpF^v?Zc668crNg;CIH0VWWipj&tD-@*VdX(S|fR+1nLv zq-5Ge!uxi5yDX*XX7!0Uk$P#w)c)58xW0~faECxbJojM!PfG{5=E1Ec2kiC){N?H2 zCQGU7tZQG{f#+$}BId(*2!YPp()@{Zo^FK#f*=bSN?RVXROGjUX zZ@*fO#~+;uZyo1GHatpuqpVyxt;`^un-_3_iP0y&e6r|G z?-8xYb!eMK9J!B#cHPaQU!B#a>(e@Nyi}i(QF_#mBbzk}Gh1O&tbu-eQF#)fD^saz zzTf_4*E2l2A)WtE{gD@`k{;dPUYa$pQWEw5^dEU4&G%aw?J9`f&E=20^zPFGufFjQ zG5Fu{V<+(byB}l6w$Pv~4CbtEUBz}DqKX3}oV!~~{$>7}QAGuLhqmoRWu+ecU}e*% zUr$UVBa;U#IYzFsM~E`;_cxgHlCrsN_u=*5G}g#w`9<6nT9!%CRv3B1#=s=yC;V9c zW*PRas)gcGbWJSRrx3Az+Z-}Fj7~+HAt^}e=g^EGiauZp@-32dgOsU%=IV(YElc)# zS5RxS>bFi0dB^VM7J3@OPgtwBZ`4K6lzhc1-SAAl{?Wsecp{6;*A+ml`deS9RrwTl zxgitJ6!CBV%$5Reh4vHGPm)hD${WHzvqhI_IW1;0@4e~P>+y^G??49vDg%?V!x)(R z_cc90A>ZF#y}J`&dt($)g?1Dkt|VZ8_}*ti>x$kXl0;D+ZDOl@W-DBLrM^6RvCPo( z6|@L=6m4Ohl9Ks9myrv9w{k$DnS00o%rJOukrQbr4&Z55n7kZ|D&L2 z!_#P{wAeH4K1tfxr1C|)JDeK_K@gnlUl9yM=QK@gxGp+Ig{o;USv~SBDO+kZl*J!C z^wpX2$U`jZgsD1`Ay^V6I zRtmpsXZQt8u3uZ#9NU;x2ie7jh61+FUqhe>NRvh@28*j5_*$K%O(cK zDCVyWRaR7z*P)(QX(ipe&+8EXUQ2-+JOi|X7+@wq45CBwP*ud01AI+alU2HwA#SH? zMSe-!7Vj}FYAI+Nk|zkvvdNb#Qv^|yT?0D*2)@eoXk1r5nVg5o49NGux~Qd~ZAc6-)3JG!(Lr>{v!eJi z8D!$ioBVbB!^GfZ1y?CmxH`S%PFeIs*v{yO3 ptPAKK9HQH@gS73UGX-t9{6G2d!PRze>-GQu002ovPDHLkV1i;=t!V%N literal 0 HcmV?d00001 diff --git a/packages/web/server/api/edge-router.ts b/packages/web/server/api/edge-router.ts index c8abdc8b38..e25eb2ac24 100644 --- a/packages/web/server/api/edge-router.ts +++ b/packages/web/server/api/edge-router.ts @@ -3,6 +3,7 @@ import { chainsRouter, createTRPCRouter, earnRouter, + orderbookRouter, paramsRouter, poolsRouter, stakingRouter, @@ -16,6 +17,7 @@ export const edgeRouter = createTRPCRouter({ staking: stakingRouter, earn: earnRouter, transactions: transactionsRouter, + orderbooks: orderbookRouter, chains: chainsRouter, params: paramsRouter, }); diff --git a/packages/web/stores/index.tsx b/packages/web/stores/index.tsx index ee1351351a..9dd720703f 100644 --- a/packages/web/stores/index.tsx +++ b/packages/web/stores/index.tsx @@ -28,6 +28,8 @@ export function refetchUserQueries(apiUtils: ReturnType) { apiUtils.local.balances.getUserBalances.invalidate(); apiUtils.local.bridgeTransfer.getSupportedAssetsBalances.invalidate(); apiUtils.edge.assets.getImmersiveBridgeAssets.invalidate(); + apiUtils.edge.orderbooks.getAllOrders.invalidate(); + apiUtils.edge.orderbooks.getClaimableOrders.invalidate(); } const EXCEEDS_1CT_NETWORK_FEE_LIMIT_TOAST_ID = "exceeds-1ct-network-fee-limit"; diff --git a/packages/web/tailwind.config.js b/packages/web/tailwind.config.js index 0d4a9dfcf0..689100d47e 100644 --- a/packages/web/tailwind.config.js +++ b/packages/web/tailwind.config.js @@ -59,6 +59,10 @@ module.exports = { 900: "#140F34", 1000: "#090524", }, + "osmoverse-alpha": { + 800: "#3E386A", + 850: "#3C356D4A", + }, ammelia: { 300: "#E196DB", 400: "#D779CF", @@ -172,7 +176,7 @@ module.exports = { "gradient-dummy-notifications": "linear-gradient(0deg, #282750 0%, rgba(40, 39, 80, 0.00) 100%)", "gradient-token-details-shadow": - "linear-gradient(0deg, #140f34 6.87%, rgba(20, 15, 52, 0) 100%);", + "linear-gradient(0deg, #090524 6.87%, rgba(20, 15, 52, 0) 100%);", "gradient-scrollable-allocation-list": "linear-gradient(0deg, #201B43 20%, rgba(20, 15, 52, 0) 100%);", "gradient-scrollable-allocation-list-reverse": diff --git a/packages/web/utils/__tests__/formatter.spec.ts b/packages/web/utils/__tests__/formatter.spec.ts index ecbaa4f2c5..0d128fdd76 100644 --- a/packages/web/utils/__tests__/formatter.spec.ts +++ b/packages/web/utils/__tests__/formatter.spec.ts @@ -1,4 +1,8 @@ -import { compressZeros } from "../formatter"; +import { Dec, PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import cases from "jest-in-case"; + +import { compressZeros, formatFiatPrice } from "../formatter"; describe("compressZeros function", () => { it("should not compress zeros with and handle the absence of currency symbol", () => { @@ -86,3 +90,45 @@ describe("compressZeros function", () => { }); }); }); + +cases( + "formatFiatPrice", + ({ input, output, maxDecimals }) => { + const inputPrice = new PricePretty(DEFAULT_VS_CURRENCY, new Dec(input)); + expect(formatFiatPrice(inputPrice, maxDecimals)).toEqual(output); + }, + [ + { + name: "Standard formatting", + input: "1.24", + output: "$1.24", + }, + { + name: "1c Value", + input: "0.01", + output: "$0.01", + }, + { + name: "Sub 1c value", + input: "0.001", + output: "<$0.01", + }, + { + name: "Large value with too many decimals", + input: "12345.12345", + output: "$12,345.12", + maxDecimals: 2, + }, + { + name: "Large value with too few decimals", + input: "12345.1", + output: "$12,345.10", + maxDecimals: 2, + }, + { + name: "Extremely small value", + input: "0.000000000012", + output: "<$0.01", + }, + ] +); diff --git a/packages/web/utils/__tests__/number.spec.ts b/packages/web/utils/__tests__/number.spec.ts index f397f7f861..e82c14c435 100644 --- a/packages/web/utils/__tests__/number.spec.ts +++ b/packages/web/utils/__tests__/number.spec.ts @@ -2,6 +2,7 @@ import cases from "jest-in-case"; import { + countDecimals, getDecimalCount, getNumberMagnitude, leadingZerosCount, @@ -268,3 +269,32 @@ cases( }, ] ); + +cases( + "countDecimals", + ({ input, output }) => { + expect(countDecimals(input)).toEqual(output); + }, + [ + { + name: "Two decimal places", + input: "1.24", + output: 2, + }, + { + name: "No decimal places", + input: "1", + output: 0, + }, + { + name: "Ends with .", + input: "1.", + output: 0, + }, + { + name: "Ends with ..", + input: "1..", + output: 0, + }, + ] +); diff --git a/packages/web/utils/formatter.ts b/packages/web/utils/formatter.ts index 94cf409889..88e067a732 100644 --- a/packages/web/utils/formatter.ts +++ b/packages/web/utils/formatter.ts @@ -354,3 +354,24 @@ export const compressZeros = ( decimalDigits: otherDigits, }; }; + +/** + * Formats a fiat price using `getPriceExtendedFormatOptions` and displays `<0.01` if the price is less than $0.01. + * Rounds to the provided `maxDecimals` parameter. + */ +export function formatFiatPrice(price: PricePretty, maxDecimals = 2) { + if (!price.toDec().isZero() && price.toDec().lt(new Dec(0.01))) { + return "<$0.01"; + } + + const truncatedAmount = new Dec( + parseFloat(price.toDec().toString()).toFixed(maxDecimals).toString() + ); + const truncatedPrice = new PricePretty(price.fiatCurrency, truncatedAmount); + const formattedPrice = formatPretty(truncatedPrice, { + ...getPriceExtendedFormatOptions(truncatedPrice.toDec()), + }); + + const split = formattedPrice.split("."); + return split[0] + "." + split[1].slice(0, maxDecimals); +} diff --git a/packages/web/utils/number.ts b/packages/web/utils/number.ts index 6762060649..8522d6e6e4 100644 --- a/packages/web/utils/number.ts +++ b/packages/web/utils/number.ts @@ -76,3 +76,11 @@ export function addCommasToNumber(number: string | number): string { export function removeCommasFromNumber(number: string): string { return number.replace(/,/g, ""); } + +export function countDecimals(value: string) { + const split = value.split("."); + if (split.length > 1) { + return split[1].length; + } + return 0; +}