diff --git a/babel.config.json b/babel.config.json index a6c42326bb..522c7e1cdd 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,3 +1,12 @@ { - "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] -} + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ] + ] +} \ No newline at end of file diff --git a/lerna.json b/lerna.json index 11928ca8bf..998f95a1f2 100644 --- a/lerna.json +++ b/lerna.json @@ -16,4 +16,4 @@ "message": "chore(release): publish" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 2b83731728..42c8ba01e2 100644 --- a/package.json +++ b/package.json @@ -82,4 +82,4 @@ "@keplr-wallet/types": "0.10.24-ibc.go.v7.hot.fix", "@keplr-wallet/unit": "0.10.24-ibc.go.v7.hot.fix" } -} +} \ No newline at end of file diff --git a/packages/keplr-stores/src/price/index.ts b/packages/keplr-stores/src/price/index.ts index 1510ae0da0..36c3e389e1 100644 --- a/packages/keplr-stores/src/price/index.ts +++ b/packages/keplr-stores/src/price/index.ts @@ -12,7 +12,7 @@ import type { AppCurrency } from "@osmosis-labs/types"; class Throttler { protected fns: (() => void)[] = []; - private timeoutId?: NodeJS.Timeout; + private timeoutId?: number; constructor(public readonly duration: number) {} diff --git a/packages/math/src/pool/concentrated/__tests__/tick.spec.ts b/packages/math/src/pool/concentrated/__tests__/tick.spec.ts index ad2537902f..8ab2411ce8 100644 --- a/packages/math/src/pool/concentrated/__tests__/tick.spec.ts +++ b/packages/math/src/pool/concentrated/__tests__/tick.spec.ts @@ -2,7 +2,7 @@ import { Dec, Int } from "@keplr-wallet/unit"; 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 +217,38 @@ describe("priceToTick", () => { }); }); +describe("tickToPrice", () => { + const testCases: Record = { + "Tick Zero": { + tick: new Int("0"), + priceExpected: new Dec("1"), + }, + "Large Positive Tick": { + tick: new Int("1000000"), + priceExpected: new Dec("2"), + }, + "Large Negative Tick": { + tick: new Int("-5000000"), + priceExpected: new Dec("0.5"), + }, + "Max Tick": { + tick: new Int("182402823"), + priceExpected: new Dec("340282300000000000000"), + }, + "Min Tick": { + tick: new Int("-108000000"), + priceExpected: new Dec("0.000000000001"), + }, + }; + + Object.values(testCases).forEach(({ tick, priceExpected }, i) => { + it(Object.keys(testCases)[i], () => { + const price = tickToPrice(tick); + expect(price.toString()).toEqual(priceExpected.toString()); + }); + }); +}); + // 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 fe05822c40..da04132741 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 "./pools"; export * from "./staking"; export * from "./swap-routers"; 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..355bf85f69 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/active-orders.ts @@ -0,0 +1,32 @@ +import { Chain } from "@osmosis-labs/types"; +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { LimitOrder, queryOrderbookActiveOrders } from "../../osmosis"; + +const activeOrdersCache = new LRUCache(DEFAULT_LRU_OPTIONS); + +export function getOrderbookActiveOrders({ + orderbookAddress, + userOsmoAddress, + chainList, +}: { + orderbookAddress: string; + userOsmoAddress: string; + chainList: Chain[]; +}) { + return cachified({ + cache: activeOrdersCache, + key: `orderbookActiveOrders-${orderbookAddress}-${userOsmoAddress}`, + ttl: 1000 * 3, // 3 seconds + getFreshValue: () => + queryOrderbookActiveOrders({ + orderbookAddress, + userAddress: userOsmoAddress, + chainList, + }).then( + ({ data }: { data: { count: number; orders: LimitOrder[] } }) => data + ), + }); +} 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..12c6d5595c --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/denoms.ts @@ -0,0 +1,28 @@ +import { Chain } from "@osmosis-labs/types"; +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { queryOrderbookDenoms } from "../../osmosis"; + +const orderbookDenomsCache = new LRUCache( + DEFAULT_LRU_OPTIONS +); + +export function getOrderbookDenoms({ + orderbookAddress, + chainList, +}: { + orderbookAddress: string; + chainList: Chain[]; +}) { + return cachified({ + cache: orderbookDenomsCache, + key: `orderbookDenoms-${orderbookAddress}`, + ttl: 1000 * 60 * 60 * 24 * 7, // 7 days + getFreshValue: () => + queryOrderbookDenoms({ orderbookAddress, chainList }).then( + ({ data }) => data + ), + }); +} 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..814b106a8b --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/historical-orders.ts @@ -0,0 +1,36 @@ +import cachified, { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; +import { HistoricalLimitOrder, queryHistoricalOrders } from "../../osmosis"; + +const orderbookHistoricalOrdersCache = new LRUCache( + DEFAULT_LRU_OPTIONS +); + +export function getOrderbookHistoricalOrders({ + userOsmoAddress, +}: { + userOsmoAddress: string; +}) { + return cachified({ + cache: orderbookHistoricalOrdersCache, + key: `orderbookHistoricalOrders-${userOsmoAddress}`, + ttl: 1000 * 3, // 3 seconds + getFreshValue: () => + queryHistoricalOrders(userOsmoAddress).then((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 orders; + }), + }); +} 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..3ea27e45c4 --- /dev/null +++ b/packages/server/src/queries/complex/orderbooks/index.ts @@ -0,0 +1,7 @@ +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"; 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..9702382570 --- /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 * 3, // 3 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 * 3, // 3 seconds + getFreshValue: () => + queryOrderbookTickUnrealizedCancelsById({ + orderbookAddress, + chainList, + tickIds, + }).then(({ data }) => data.ticks), + }); +} 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..1fc7fdb7b7 --- /dev/null +++ b/packages/server/src/queries/osmosis/orderbooks.ts @@ -0,0 +1,222 @@ +import { apiClient } from "@osmosis-labs/utils"; + +import { NUMIA_BASE_URL } from "../../env"; +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}`; + }, +}); + +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/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 3311b6551d..77d995cf2b 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 "./pools"; export * from "./staking"; diff --git a/packages/trpc/src/orderbook-router.ts b/packages/trpc/src/orderbook-router.ts new file mode 100644 index 0000000000..fd4022b73d --- /dev/null +++ b/packages/trpc/src/orderbook-router.ts @@ -0,0 +1,380 @@ +import { Dec, Int } from "@keplr-wallet/unit"; +import { tickToPrice } from "@osmosis-labs/math"; +import { + CursorPaginationSchema, + getOrderbookActiveOrders, + getOrderbookDenoms, + getOrderbookHistoricalOrders, + getOrderbookMakerFee, + getOrderbookPools, + getOrderbookState, + getOrderbookTickState, + getOrderbookTickUnrealizedCancels, + HistoricalLimitOrder, + LimitOrder, + maybeCachePaginatedItems, +} from "@osmosis-labs/server"; +import { dayjs } from "@osmosis-labs/server/build/utils/dayjs"; +import { Chain } from "@osmosis-labs/types"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import { z } from "zod"; + +import { createTRPCRouter, publicProcedure } from "./api"; +import { OsmoAddressSchema, UserOsmoAddressSchema } from "./parameter-types"; + +const GetInfiniteLimitOrdersInputSchema = CursorPaginationSchema.merge( + z.object({ + userOsmoAddress: z.string().startsWith("osmo"), + }) +); + +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; +}; + +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"; +} + +function defaultSortOrders( + orderA: MappedLimitOrder, + orderB: MappedLimitOrder +): number { + if (orderA.status === orderB.status) { + return orderB.placed_at - orderA.placed_at; + } + if (orderA.status === "filled") return -1; + if (orderB.status === "filled") return 1; + return orderB.placed_at - orderA.placed_at; +} + +async function getTickInfoAndTransformOrders( + orderbookAddress: string, + orders: LimitOrder[], + chainList: Chain[], + quoteAsset: ReturnType, + baseAsset: ReturnType +): Promise { + const tickIds = [...new Set(orders.map((o) => o.tick_id))]; + const tickStates = await getOrderbookTickState({ + orderbookAddress, + chainList, + tickIds, + }); + const unrealizedTickCancels = await 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(), + }; + }); +} + +function mapHistoricalToMapped( + historicalOrders: HistoricalLimitOrder[], + userAddress: string, + quoteAsset: ReturnType, + baseAsset: ReturnType +): MappedLimitOrder[] { + return historicalOrders.map((o) => { + 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, + }; + }); +} + +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, + }; + }), + getAllActiveOrders: 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, + }); + + const promises = contractAddresses.map( + async (contractOsmoAddress: string) => { + const resp = await getOrderbookActiveOrders({ + orderbookAddress: contractOsmoAddress, + userOsmoAddress: userOsmoAddress, + chainList: ctx.chainList, + }); + const historicalOrdersForContract = historicalOrders.filter( + (o) => o.contract === contractOsmoAddress + ); + + if ( + resp.orders.length === 0 && + historicalOrdersForContract.length === 0 + ) + return []; + const { base_denom, quote_denom } = await getOrderbookDenoms({ + orderbookAddress: contractOsmoAddress, + chainList: ctx.chainList, + }); + const quoteAsset = getAssetFromAssetList({ + assetLists: ctx.assetLists, + coinMinimalDenom: quote_denom, + }); + + const baseAsset = getAssetFromAssetList({ + assetLists: ctx.assetLists, + coinMinimalDenom: base_denom, + }); + + const mappedOrders = await getTickInfoAndTransformOrders( + contractOsmoAddress, + resp.orders, + ctx.chainList, + quoteAsset, + baseAsset + ); + + const mappedHistoricalOrders = mapHistoricalToMapped( + historicalOrdersForContract, + input.userOsmoAddress, + quoteAsset, + baseAsset + ); + + return [...mappedOrders, ...mappedHistoricalOrders]; + } + ); + const ordersByContracts = await Promise.all(promises); + const allOrders = ordersByContracts.flatMap((p) => p); + return allOrders.sort(defaultSortOrders); + }, + cacheKey: JSON.stringify([ + "all-active-orders", + input.contractAddresses, + input.userOsmoAddress, + ]), + 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 resp = await getOrderbookActiveOrders({ + orderbookAddress: contractOsmoAddress, + userOsmoAddress: userOsmoAddress, + chainList: ctx.chainList, + }); + + if (resp.orders.length === 0) return []; + const { base_denom, quote_denom } = await getOrderbookDenoms({ + orderbookAddress: contractOsmoAddress, + chainList: ctx.chainList, + }); + // TODO: Use actual quote denom here + const quoteAsset = getAssetFromAssetList({ + assetLists: ctx.assetLists, + coinMinimalDenom: quote_denom, + }); + const baseAsset = getAssetFromAssetList({ + assetLists: ctx.assetLists, + coinMinimalDenom: base_denom, + }); + const mappedOrders = await getTickInfoAndTransformOrders( + contractOsmoAddress, + resp.orders, + ctx.chainList, + quoteAsset, + baseAsset + ); + return mappedOrders.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 }) => { + const { userOsmoAddress } = input; + const historicalOrders = await getOrderbookHistoricalOrders({ + userOsmoAddress, + }); + 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/components/complex/asset-fieldset.tsx b/packages/web/components/complex/asset-fieldset.tsx new file mode 100644 index 0000000000..6f1b15206d --- /dev/null +++ b/packages/web/components/complex/asset-fieldset.tsx @@ -0,0 +1,213 @@ +import { MinimalAsset } from "@osmosis-labs/types"; +import classNames from "classnames"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { + ChangeEventHandler, + forwardRef, + PropsWithChildren, + ReactNode, +} from "react"; + +import { Icon } from "~/components/assets"; +import { EventName } from "~/config"; +import { useAmplitudeAnalytics, useDisclosure, useTranslation } from "~/hooks"; +import { TokenSelectModalLimit } from "~/modals/token-select-modal-limit"; + +const AssetFieldset = ({ children }: PropsWithChildren) => ( +
{children}
+); + +const AssetFieldsetHeader = ({ children }: PropsWithChildren) => ( +
+ {children} +
+); + +const AssetFieldsetHeaderLabel = ({ children }: PropsWithChildren) => + children; + +const AssetFieldsetHeaderBalance = ({ + onMax, + availableBalance, + className, +}: { + onMax?: () => void; + availableBalance?: ReactNode; + className?: string; +}) => { + const { t } = useTranslation(); + + return ( +
+ {availableBalance && ( + + {availableBalance} {t("pool.available").toLowerCase()} + + )} + {onMax && ( + + )} +
+ ); +}; + +interface AssetFieldsetInputProps { + inputPrefix?: ReactNode; + onInputChange?: ChangeEventHandler; + inputValue?: string; + outputValue?: ReactNode; +} + +const AssetFieldsetInput = forwardRef< + HTMLInputElement, + AssetFieldsetInputProps +>(({ inputPrefix, inputValue, onInputChange, outputValue }, ref) => ( +
+ {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; +} + +const AssetFieldsetTokenSelector = ({ + selectableAssets, + selectedCoinImageUrl, + selectedCoinDenom, + orderDirection, + onSelect: onOriginalSelect, + isModalExternal, + onSelectorClick, + fetchNextPageAssets, + hasNextPageAssets, + isFetchingNextPageAssets, +}: 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: "Swap Page", + }, + ]); + onOriginalSelect?.(tokenDenom); + }; + + return ( + <> + + {!isModalExternal && selectableAssets && ( + + )} + + ); +}; + +export { + AssetFieldset, + AssetFieldsetFooter, + AssetFieldsetHeader, + AssetFieldsetHeaderBalance, + AssetFieldsetHeaderLabel, + AssetFieldsetInput, + AssetFieldsetTokenSelector, +}; 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..fe84c7d531 --- /dev/null +++ b/packages/web/components/complex/orders-history/cells/actions.tsx @@ -0,0 +1,112 @@ +import { CellContext } from "@tanstack/react-table"; +import { observer } from "mobx-react-lite"; +import { useCallback } from "react"; + +import { DisplayableLimitOrder } from "~/hooks/limit-orders/use-orderbook"; +import { useStore } from "~/stores"; + +export function ActionsCell({ + row, +}: CellContext) { + const component = (() => { + switch (row.original.status) { + case "open": + return ; + case "partiallyFilled": + // TODO: swap to cancel button for partially filled but entirely claimed orders + return ; + case "filled": + return ( + Claimable + ); + default: + return null; + } + })(); + return
{component}
; +} + +const ClaimAndCloseButton = observer( + ({ order }: { order: DisplayableLimitOrder }) => { + const { accountStore } = useStore(); + const account = accountStore.getWallet(accountStore.osmosisChainId); + + const claimAndClose = useCallback(async () => { + if (!account) 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 { + await account.cosmwasm.sendMultiExecuteContractMsg( + "executeWasm", + msgs, + undefined + ); + } catch (error) { + console.error(error); + } + }, [account, order]); + + return ( + + ); + } +); + +const CancelButton = observer(({ order }: { order: DisplayableLimitOrder }) => { + const { accountStore } = useStore(); + const account = accountStore.getWallet(accountStore.osmosisChainId); + + const cancel = useCallback(async () => { + if (!account) return; + const { tick_id, order_id, orderbookAddress } = order; + const claimMsg = { + msg: { + cancel_limit: { order_id, tick_id }, + }, + contractAddress: orderbookAddress, + funds: [], + }; + + try { + await account.cosmwasm.sendMultiExecuteContractMsg( + "executeWasm", + [claimMsg], + undefined + ); + } catch (error) { + console.error(error); + } + }, [account, order]); + + 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..91cf053fb1 --- /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 { MappedLimitOrder } from "@osmosis-labs/trpc"; +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)).round(); + }, [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/columns.tsx b/packages/web/components/complex/orders-history/columns.tsx new file mode 100644 index 0000000000..cc0b1de341 --- /dev/null +++ b/packages/web/components/complex/orders-history/columns.tsx @@ -0,0 +1,225 @@ +import { CoinPretty, Dec, Int, PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { createColumnHelper } from "@tanstack/react-table"; +import classNames from "classnames"; +import dayjs from "dayjs"; +import Image from "next/image"; + +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 { DisplayableLimitOrder } from "~/hooks/limit-orders/use-orderbook"; +import { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; + +const columnHelper = createColumnHelper(); + +export const tableColumns = [ + columnHelper.display({ + id: "order", + header: () => { + return Order; + }, + size: 400, + cell: ({ + row: { + original: { + order_direction, + quoteAsset, + baseAsset, + placed_quantity, + output, + // quantity, + // tick_id, + // percentFilled, + // if 100 -> order filled + // an order is claimable when percent filled is gt percent claimed + }, + }, + }) => { + const baseAssetLogo = + baseAsset?.rawAsset.logoURIs.svg ?? + baseAsset?.rawAsset.logoURIs.png ?? + ""; + return ( +
+
+ +
+
+

+ + {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 + ) + )} + +

+
+ + {order_direction === "bid" ? "Buy" : "Sell"}{" "} + {formatPretty( + 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)) + ) + ) + )}{" "} + of + + {`${baseAsset?.symbol} + {baseAsset?.symbol} +
+
+
+ ); + }, + }), + columnHelper.display({ + id: "price", + header: () => { + return Price; + }, + cell: ({ + row: { + original: { baseAsset, price }, + }, + }) => { + return ( +
+

+ {baseAsset?.symbol} · Limit +

+

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

+
+ ); + }, + }), + columnHelper.display({ + id: "orderPlaced", + header: () => { + return Order Placed; + }, + cell: ({ + row: { + original: { placed_at }, + }, + }) => { + const placedAt = dayjs(placed_at); + const formattedTime = placedAt.format("h:mm A"); + const formattedDate = placedAt.format("MMM D"); + return ( +
+

{formattedTime}

+

{formattedDate}

+
+ ); + }, + }), + columnHelper.display({ + id: "status", + header: () => { + return Status; + }, + cell: ({ row: { original: order } }) => { + const { status } = order; + const statusString = (() => { + switch (status) { + case "open": + case "partiallyFilled": + return "Open"; + case "filled": + case "fullyClaimed": + return "Filled"; + case "cancelled": + return "Cancelled"; + } + })(); + + const statusComponent = (() => { + switch (status) { + case "open": + case "partiallyFilled": + return ; + default: + return; + } + })(); + + return ( +
+ {statusComponent} + + {statusString} + +
+ ); + }, + }), + columnHelper.display({ + id: "actions", + cell: ActionsCell, + }), +]; 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..c84093cffb --- /dev/null +++ b/packages/web/components/complex/orders-history/index.tsx @@ -0,0 +1,377 @@ +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import Link from "next/link"; +import React, { useCallback, useEffect, useMemo } from "react"; + +import { Icon } from "~/components/assets"; +import { tableColumns } from "~/components/complex/orders-history/columns"; +import { Spinner } from "~/components/loaders"; +import { EventName } from "~/config"; +import { useAmplitudeAnalytics } from "~/hooks"; +import { + DisplayableLimitOrder, + useOrderbookAllActiveOrders, + useOrderbookClaimableOrders, +} from "~/hooks/limit-orders/use-orderbook"; +import { useStore } from "~/stores"; + +export type Order = ReturnType["orders"][0]; + +export const OrderHistory = observer(() => { + const { logEvent } = useAmplitudeAnalytics({ + onLoadEvent: [EventName.LimitOrder.pageViewed], + }); + const { accountStore } = useStore(); + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + + const { orders, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage } = + useOrderbookAllActiveOrders({ + userAddress: wallet?.address ?? "", + pageSize: 10, + }); + + const table = useReactTable({ + data: orders, + columns: tableColumns, + getCoreRowModel: getCoreRowModel(), + }); + + const { claimAllOrders } = useOrderbookClaimableOrders({ + userAddress: wallet?.address ?? "", + }); + + const claimOrders = useCallback(async () => { + try { + logEvent([EventName.LimitOrder.claimOrdersStarted]); + await claimAllOrders(); + 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]); + + const filledOrders = useMemo( + () => + table + .getRowModel() + .rows.filter((row) => row.original.status === "filled"), + // eslint-disable-next-line react-hooks/exhaustive-deps + [table, orders] + ); + + const pendingOrders = useMemo( + () => + table + .getRowModel() + .rows.filter( + (row) => + row.original.status === "open" || + row.original.status === "partiallyFilled" + ), + + // eslint-disable-next-line react-hooks/exhaustive-deps + [table, orders] + ); + const pastOrders = useMemo( + () => + table + .getRowModel() + .rows.filter( + (row) => + row.original.status === "cancelled" || + row.original.status === "fullyClaimed" + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [table, orders] + ); + + const { rows } = table.getRowModel(); + + const filledOrdersCount = useMemo(() => filledOrders.length, [filledOrders]); + const pendingOrdersCount = useMemo( + () => pendingOrders.length, + [pendingOrders] + ); + // Whether a filled header was added + const hasFilledOrders = useMemo( + () => (filledOrdersCount > 0 ? 1 : 0), + [filledOrdersCount] + ); + // Whether a pending header was added + const hasPendingOrders = useMemo( + () => (pendingOrdersCount > 0 ? 1 : 0), + [pendingOrdersCount] + ); + // Whether a past orders header was added + const hasPastOrders = useMemo( + () => (pastOrders.length > 0 ? 1 : 0), + [pastOrders] + ); + + const rowVirtualizer = useWindowVirtualizer({ + // To account for headers we add an additional row to virtualization per header added + count: rows.length + (hasFilledOrders + hasPendingOrders + hasPastOrders), + estimateSize: () => 84, + paddingStart: 272, + overscan: 3, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + + const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - + (virtualRows?.[virtualRows.length - 1]?.end || 0) + : 0; + + // pagination + const lastRow = rows[rows.length - 1]; + const lastVirtualRow = virtualRows[virtualRows.length - 1]; + const canLoadMore = !isLoading && !isFetchingNextPage && hasNextPage; + useEffect(() => { + if ( + lastRow && + lastVirtualRow && + lastRow.index === + lastVirtualRow.index - + hasFilledOrders - + hasPendingOrders - + (pastOrders.length > 0 ? 1 : 0) && + canLoadMore + ) + fetchNextPage(); + }, [ + lastRow, + lastVirtualRow, + canLoadMore, + fetchNextPage, + pastOrders, + hasFilledOrders, + hasPendingOrders, + ]); + + const filledOrderRows = useMemo(() => { + const minIndex = 0; + const maxIndex = filledOrdersCount + hasFilledOrders - 1; + return virtualRows + .filter((row) => row.index > minIndex && row.index <= maxIndex) + .map((virtualRow) => { + const row = rows[virtualRow.index - 1]; + return row; + }); + }, [filledOrdersCount, rows, virtualRows, hasFilledOrders]); + + const pendingOrderRows = useMemo(() => { + // Pending orders only adjust for any filled orders and if there was a filled orders header + const minIndex = filledOrdersCount + hasFilledOrders; + const maxIndex = filledOrdersCount + hasFilledOrders + pendingOrdersCount; + return virtualRows + .filter((row) => row.index > minIndex && row.index <= maxIndex) + .map((virtualRow) => { + const row = rows[virtualRow.index - (1 + hasFilledOrders)]; + return row; + }); + }, [ + filledOrdersCount, + hasFilledOrders, + pendingOrdersCount, + rows, + virtualRows, + ]); + const pastOrderRows = useMemo(() => { + // For past orders we must account for all of the previous orders and if they added headers + const minIndex = + filledOrdersCount + + hasFilledOrders + + pendingOrdersCount + + hasPendingOrders; + // Past orders fill the rest of the array so we account for that plus and headers + const maxIndex = virtualRows.length - 1; + return virtualRows + .filter((row) => row.index > minIndex && row.index <= maxIndex) + .map((virtualRow) => { + const row = + rows[virtualRow.index - (1 + hasFilledOrders + hasPendingOrders)]; + return row; + }); + }, [ + filledOrdersCount, + hasFilledOrders, + hasPendingOrders, + pendingOrdersCount, + rows, + virtualRows, + ]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (orders.length === 0) { + return ( +
+ ion thumbs up +
No recent orders
+

+ Your trade order history will appear here. + + Start trading + +

+
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {paddingTop > 0 && paddingTop - 272 > 0 && ( + + + )} + {filledOrdersCount > 0 && ( + <> + + + + {filledOrderRows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ); + })} + + )} + {pendingOrdersCount > 0 && ( + <> +
Pending
+ {pendingOrderRows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ); + })} + + )} + {pastOrders.length > 0 && ( + <> +
Past
+ {pastOrderRows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ); + })} + + )} + {isFetchingNextPage && ( + + + + )} + {paddingBottom > 0 && ( + + + )} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+
+
+
+
+
Filled orders to claim
+
+ {filledOrders.length} +
+
+
+ +
+
+ +
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ +
+
+
+ ); +}); diff --git a/packages/web/components/complex/pool/create/orderbook/add-initial-liquidity.tsx b/packages/web/components/complex/pool/create/orderbook/add-initial-liquidity.tsx new file mode 100644 index 0000000000..a983c399e0 --- /dev/null +++ b/packages/web/components/complex/pool/create/orderbook/add-initial-liquidity.tsx @@ -0,0 +1,255 @@ +import { CoinPretty, Dec, PricePretty } from "@keplr-wallet/unit"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { MinimalAsset } from "@osmosis-labs/types"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { useState } from "react"; + +import { Icon } from "~/components/assets"; +import { TokenSelectorProps } from "~/components/complex/pool/create/cl/set-base-info"; +import { SelectionToken } from "~/components/complex/pool/create/cl-pool"; +import { Spinner } from "~/components/loaders"; +import { useStore } from "~/stores"; +import { formatPretty } from "~/utils/formatter"; +import { api } from "~/utils/trpc"; + +interface AddInitialLiquidityProps { + poolId: string; + selectedBase?: SelectionToken; + selectedQuote?: SelectionToken; + onClose?: () => void; +} + +const isAmountValid = (amount?: string) => !!amount && !/^0*$/.test(amount); + +export const AddInitialLiquidity = observer( + ({ + selectedBase, + selectedQuote, + poolId, + onClose, + }: AddInitialLiquidityProps) => { + const [baseAmount, setBaseAmount] = useState(); + const [quoteAmount, setQuoteAmount] = useState(); + + const [isTxLoading, setIsTxLoading] = useState(false); + const { accountStore } = useStore(); + + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + + const { data: quoteUsdValue } = api.edge.assets.getAssetPrice.useQuery( + { + coinMinimalDenom: selectedQuote?.token.coinMinimalDenom ?? "", + }, + { enabled: !!selectedQuote?.token.coinMinimalDenom } + ); + + const { + data: baseAssetBalanceData, + isLoading: isLoadingBaseAssetBalanceData, + } = api.edge.assets.getUserAsset.useQuery( + { + userOsmoAddress: wallet?.address ?? "", + findMinDenomOrSymbol: selectedBase?.token.coinMinimalDenom ?? "", + }, + { enabled: !!wallet?.address } + ); + + const { + data: quoteAssetBalanceData, + isLoading: isLoadingQuoteAssetBalanceData, + } = api.edge.assets.getUserAsset.useQuery( + { + userOsmoAddress: wallet?.address ?? "", + findMinDenomOrSymbol: selectedQuote?.token.coinMinimalDenom ?? "", + }, + { enabled: !!wallet?.address } + ); + + const account = accountStore.getWallet(accountStore.osmosisChainId); + + if (!selectedBase || !selectedQuote) return; + + return ( + <> +
+ + + Initial liquidity will be deposited as a full range position + +
+
+ + +
+ {isAmountValid(baseAmount) && + isAmountValid(quoteAmount) && + quoteUsdValue && ( + + Implied value: 1 {selectedBase.token.coinDenom}{" "} + + ≈{" "} + {formatPretty( + new PricePretty( + DEFAULT_VS_CURRENCY, + new Dec(quoteAmount!) + .mul(quoteUsdValue?.toDec()) + .quo(new Dec(baseAmount!)) + ) + )} + + + )} +
+ + +
+ + ); + } +); + +const TokenLiquiditySelector = observer( + ({ + selectedAsset, + isQuote, + value, + setter, + assetPrice, + balanceData, + isLoadingBalanceData, + }: Omit & { + value?: string; + setter: (value?: string) => void; + isQuote?: boolean; + assetPrice?: PricePretty; + balanceData?: MinimalAsset & + Partial<{ + amount: CoinPretty; + usdValue: PricePretty; + }>; + isLoadingBalanceData?: boolean; + }) => { + if (!selectedAsset) return; + + return ( +
+
+ {`${selectedAsset.token.coinDenom}`} +
+ {selectedAsset.token.coinDenom} +
+
+
+ + { + // we might have to adjust this treshold + if (e.target.value.length > 32) return; + if (e.target.value === "") return setter(); + + setter(e.target.value); + }} + /> + + {isQuote && + value && + "~" + + formatPretty( + new PricePretty( + DEFAULT_VS_CURRENCY, + new Dec(value).mul(assetPrice?.toDec() ?? new Dec(0)) + ) + )} + +
+
+ ); + } +); diff --git a/packages/web/components/complex/pool/create/orderbook/set-base-info.tsx b/packages/web/components/complex/pool/create/orderbook/set-base-info.tsx new file mode 100644 index 0000000000..2819d73cb4 --- /dev/null +++ b/packages/web/components/complex/pool/create/orderbook/set-base-info.tsx @@ -0,0 +1,375 @@ +import { + Checkbox, + Field, + Label, + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, + Transition, +} from "@headlessui/react"; +import { CoinPretty, RatePretty } from "@keplr-wallet/unit"; +import { getAssets } from "@osmosis-labs/server"; +import { ConcentratedLiquidityParams } from "@osmosis-labs/stores"; +import { getAssetFromAssetList } from "@osmosis-labs/utils"; +import { useQuery } from "@tanstack/react-query"; +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import React, { Fragment, useMemo, useState } from "react"; + +import { Icon } from "~/components/assets/icon"; +import { SelectionToken } from "~/components/complex/pool/create/cl-pool"; +import { Spinner } from "~/components/loaders"; +import { AssetLists } from "~/config/generated/asset-lists"; +import { useDisclosure, useFilteredData } from "~/hooks"; +import { TokenSelectModal } from "~/modals"; +import { useStore } from "~/stores"; +import { formatPretty } from "~/utils/formatter"; + +interface SetBaseInfosProps { + advanceStep?: () => void; + selectedBase?: SelectionToken; + selectedQuote: SelectionToken; + setSelectedBase: (value: SelectionToken) => void; + setSelectedQuote: (value: SelectionToken) => void; + setPoolId: (value: string) => void; +} + +export const SetBaseInfos = observer( + ({ + advanceStep, + selectedBase, + selectedQuote, + setSelectedBase, + setSelectedQuote, + setPoolId, + }: SetBaseInfosProps) => { + const { accountStore, queriesStore } = useStore(); + + const queryConcentratedLiquidityParams = queriesStore.get( + accountStore.osmosisChainId + ).osmosis?.queryConcentratedLiquidityParams; + + const { data: clParams, isLoading: isLoadingCLParams } = useQuery({ + queryFn: () => queryConcentratedLiquidityParams?.waitResponse(), + queryKey: ["queryConcentratedLiquidityParams"], + select: (d) => + (d?.data as unknown as { params: ConcentratedLiquidityParams }).params, + cacheTime: 1000 * 60 * 60, + }); + + const account = accountStore.getWallet(accountStore.osmosisChainId); + + const [selectedSpread, setSelectedSpread] = useState( + "0.000000000000000000" + ); + + const { baseTokens, quoteTokens } = useMemo(() => { + return { + baseTokens: getAssets({ + assetLists: AssetLists, + onlyVerified: true, + }).map((asset) => { + const assetListAsset = getAssetFromAssetList({ + assetLists: AssetLists, + coinMinimalDenom: asset.coinMinimalDenom, + }); + + return { + chainName: assetListAsset?.rawAsset.chainName, + token: new CoinPretty( + { + coinDenom: asset.coinDenom, + coinDecimals: asset.coinDecimals, + coinMinimalDenom: asset.coinMinimalDenom, + coinImageUrl: asset.coinImageUrl, + }, + 0 + ).currency, + }; + }) as SelectionToken[], + quoteTokens: clParams + ? (clParams.authorized_quote_denoms + .map((qa): SelectionToken | undefined => { + const asset = getAssetFromAssetList({ + assetLists: AssetLists, + coinMinimalDenom: qa, + }); + + if (!asset) return; + + const { + symbol, + decimals, + coinMinimalDenom, + rawAsset: { logoURIs }, + } = asset; + return { + token: new CoinPretty( + { + coinDenom: symbol, + coinDecimals: decimals, + coinMinimalDenom, + coinImageUrl: logoURIs.svg ?? logoURIs.png ?? "", + }, + 0 + ).currency, + chainName: asset.rawAsset.chainName, + }; + }) + .filter(Boolean) as SelectionToken[]) + : [], + }; + }, [clParams]); + + const [isAgreementChecked, setIsAgreementChecked] = useState(false); + const [isTxLoading, setIsTxLoading] = useState(false); + + return ( + <> +
+
+
+ Base + qc.token.coinDenom !== selectedQuote?.token.coinDenom + )} + selectedAsset={selectedBase} + setSelectedAsset={setSelectedBase} + /> +
+
+ Quote + qc.token.coinDenom !== selectedBase?.token.coinDenom + )} + selectedAsset={selectedQuote} + setSelectedAsset={setSelectedQuote} + /> +
+
+
+ Set swap fee to + {clParams ? ( + + ) : ( +
+ +
+ )} +
+
+
+ + + + + + + + + +
+ + ); + } +); + +export interface TokenSelectorProps { + assets: SelectionToken[]; + setSelectedAsset: (asset: SelectionToken) => void; + selectedAsset?: SelectionToken; +} + +const TokenSelector = observer( + ({ selectedAsset, assets, setSelectedAsset }: TokenSelectorProps) => { + const { isOpen, onClose, onOpen } = useDisclosure(); + + const [query, setQuery, results] = useFilteredData(assets, [ + "token.coinDenom", + ]); + + return ( + <> + + + setSelectedAsset( + assets.find((value) => value.token.coinDenom === selectedDenom)! + ) + } + /> + + ); + } +); + +interface SpreadSelectorProps { + options: string[]; + value: string; + onChange: (v: string) => void; +} + +function SpreadSelector({ options, value, onChange }: SpreadSelectorProps) { + return ( + +
+ + + {formatPretty(new RatePretty(value))} + + + + + + {options.map((option, i) => ( + +
+ + + +
+ + {formatPretty(new RatePretty(option))} + +
+ ))} +
+
+
+
+ ); +} 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..b59bd83dff --- /dev/null +++ b/packages/web/components/control/token-select-limit.tsx @@ -0,0 +1,196 @@ +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 } 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; +} + +export const TokenSelectLimit: FunctionComponent< + TokenSelectLimitProps & Disableable +> = observer( + ({ + dropdownOpen, + setDropdownOpen, + selectableAssets, + onTokenSelect, + canSelectTokens = true, + baseAsset, + baseBalance, + disabled, + orderDirection, + }) => { + 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: "Swap 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 = useMemo( + () => orderDirection === "ask" && isWalletConnected, + [isWalletConnected, orderDirection] + ); + + // const showQuoteBalance = useMemo( + // () => orderDirection === "bid", + // [orderDirection] + // ); + + return ( +
+ + + setIsSelectOpen(false)} + onSelect={onSelect} + showSearchBox + selectableAssets={preSortedTokens} + /> +
+ ); + } +); diff --git a/packages/web/components/input/limit-input.tsx b/packages/web/components/input/limit-input.tsx new file mode 100644 index 0000000000..7a65cba415 --- /dev/null +++ b/packages/web/components/input/limit-input.tsx @@ -0,0 +1,448 @@ +import { Dec } from "@keplr-wallet/unit"; +import { MinimalAsset } from "@osmosis-labs/types"; +import classNames from "classnames"; +import { useQueryState } from "nuqs"; +import { FC, useCallback, useEffect, useMemo, useState } from "react"; +import AutosizeInput from "react-input-autosize"; + +import { Icon } from "~/components/assets"; +import { Spinner } from "~/components/loaders"; +import { useWindowSize } from "~/hooks"; +import { isValidNumericalRawInput } from "~/hooks/input/use-amount-input"; +import { countDecimals, trimPlaceholderZeros } from "~/utils/number"; + +export interface LimitInputProps { + baseAsset: MinimalAsset; + onChange: (value: string) => void; + setMarketAmount: (value: string) => void; + tokenAmount: string; + price: Dec; + disableSwitching?: boolean; + quoteAssetPrice: Dec; + quoteBalance?: Dec; + baseBalance?: Dec; + expectedOutput?: Dec; + expectedOutputLoading: boolean; + insufficientFunds?: boolean; +} + +const calcScale = (numChars: number, isMobile: boolean): string => { + const sizeMapping: { [key: number]: string } = isMobile + ? { + 8: "1", + 10: "0.90", + 12: "0.80", + 18: "0.70", + 100: "0.48", + } + : { + 8: "1", + 10: "0.90", + 12: "0.80", + 18: "0.70", + 100: "0.48", + }; + + for (const [key, value] of Object.entries(sizeMapping)) { + if (numChars <= Number(key)) { + return value; + } + } + + return "1"; +}; + +export enum FocusedInput { + FIAT = "fiat", + TOKEN = "token", +} + +const nonFocusedClasses = + "top-[45%] text-wosmongton-200 hover:cursor-pointer select-none"; +const focusedClasses = "top-[0%] font-h3 font-normal"; + +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 + ? parseFloat(updatedValue).toFixed(decimalCount).toString() + : updatedValue; +}; + +export const LimitInput: FC = ({ + baseAsset, + onChange, + tokenAmount, + price, + disableSwitching, + setMarketAmount, + quoteAssetPrice, + expectedOutput, + expectedOutputLoading, + quoteBalance, + baseBalance, + insufficientFunds, +}) => { + const [fiatAmount, setFiatAmount] = useState(""); + // const [nonMaxAmount, setNonMaxAmount] = useState(""); + // const [max, setMax] = useState(false); + const [tab] = useQueryState("tab", { defaultValue: "buy" }); + const [type] = useQueryState("type", { defaultValue: "market" }); + const [focused, setFocused] = useState( + tab === "buy" ? FocusedInput.FIAT : FocusedInput.TOKEN + ); + + const swapFocus = useCallback(() => { + setFocused((p) => + p === FocusedInput.FIAT ? FocusedInput.TOKEN : FocusedInput.FIAT + ); + }, []); + + // Swap focus every time the tab changes + useEffect(() => swapFocus(), [swapFocus, tab]); + + // Set focus to Fiat / Token on type/tab change + useEffect(() => { + if (type === "market") { + setFocused(tab === "buy" ? FocusedInput.FIAT : FocusedInput.TOKEN); + } + }, [tab, type]); + + // const setFiatAmountSafe = useCallback( + // (value?: string) => { + // if (!value) { + // setMarketAmount(""); + // return setFiatAmount(""); + // } + + // const updatedValue = transformAmount(value, 6); + + // if ( + // !isValidNumericalRawInput(updatedValue) || + // updatedValue.length > 26 || + // (updatedValue.length > 0 && updatedValue.startsWith("-")) + // ) { + // return; + // } + + // const isFocused = focused === FocusedInput.FIAT; + + // // Hacky solution to deal with rounding + // // TODO: Investigate a way to improve this + // if (tab === "buy") { + // setMarketAmount(new Dec(updatedValue).quo(quoteAssetPrice).toString()); + // } + // setFiatAmount( + // parseFloat(updatedValue) !== 0 && !isFocused + // ? trimPlaceholderZeros(updatedValue) + // : updatedValue + // ); + // }, + // [focused, setFiatAmount, tab, setMarketAmount, quoteAssetPrice] + // ); + + // const setTokenAmountSafe = useCallback( + // (value?: string) => { + // if (!value) { + // return onChange(""); + // } + + // const updatedValue = transformAmount(value, baseAsset?.coinDecimals); + + // if ( + // !isValidNumericalRawInput(updatedValue) || + // updatedValue.length > 26 || + // (updatedValue.length > 0 && updatedValue.startsWith("-")) + // ) { + // return; + // } + + // const isFocused = focused === FocusedInput.TOKEN; + // onChange( + // parseFloat(updatedValue) !== 0 && !isFocused + // ? trimPlaceholderZeros(updatedValue) + // : updatedValue + // ); + // }, + // [onChange, focused, baseAsset?.coinDecimals] + // ); + + const setAmountSafe = useCallback( + (type: "fiat" | "token", value?: string) => { + const update = type === "fiat" ? setFiatAmount : onChange; + + if (!value?.trim()) { + if (type === "fiat") { + setMarketAmount(""); + } + return update(""); + } + + const updatedValue = transformAmount( + value, + type === "fiat" ? 6 : baseAsset?.coinDecimals + ).trim(); + + if ( + !isValidNumericalRawInput(updatedValue) || + updatedValue.length > 26 || + (updatedValue.length > 0 && updatedValue.startsWith("-")) + ) { + return; + } + + const isFocused = + focused === FocusedInput[type === "fiat" ? "FIAT" : "TOKEN"]; + + // Hacky solution to deal with rounding + // TODO: Investigate a way to improve this + if (type === "fiat" && tab === "buy") { + setMarketAmount(new Dec(updatedValue).quo(quoteAssetPrice).toString()); + } + + update( + parseFloat(updatedValue) !== 0 && !isFocused + ? trimPlaceholderZeros(updatedValue) + : updatedValue + ); + }, + [ + baseAsset?.coinDecimals, + focused, + onChange, + quoteAssetPrice, + setMarketAmount, + tab, + ] + ); + + const toggleMax = useCallback(() => { + if (tab === "buy") { + return setAmountSafe("fiat", Number(quoteBalance)?.toString() ?? ""); + } + + return setAmountSafe("token", Number(baseBalance)?.toString() ?? ""); + }, [tab, baseBalance, setAmountSafe, quoteBalance]); + + useEffect(() => { + if (tokenAmount.length === 0 && focused === FocusedInput.FIAT) { + setAmountSafe("fiat", ""); + if (tab === "buy") { + setMarketAmount(""); + } + } + if (focused !== FocusedInput.TOKEN || !price) return; + + const value = tokenAmount.length > 0 ? new Dec(tokenAmount) : undefined; + const fiatValue = value ? price.mul(value) : undefined; + + setAmountSafe("fiat", fiatValue ? fiatValue.toString() : undefined); + }, [ + price, + tokenAmount, + setFiatAmount, + focused, + tab, + setMarketAmount, + setAmountSafe, + ]); + + useEffect(() => { + if (focused !== FocusedInput.FIAT || !price) return; + + const value = fiatAmount && fiatAmount.length > 0 ? fiatAmount : undefined; + const tokenValue = value ? new Dec(value).quo(price) : undefined; + setAmountSafe("token", tokenValue ? tokenValue.toString() : undefined); + }, [price, fiatAmount, setAmountSafe, focused]); + + return ( +
+ {(["fiat", "token"] as ("fiat" | "token")[]).map((inputType) => ( + + inputType === "fiat" + ? setAmountSafe("fiat", v) + : setAmountSafe("token", v) + } + disableSwitching={disableSwitching} + loading={ + inputType === "fiat" + ? tab === "sell" && type === "market" && expectedOutputLoading + : tab === "buy" && type === "market" && expectedOutputLoading + } + /> + ))} + +
+ ); +}; + +type AutoInputProps = { + focused: FocusedInput; + swapFocus: () => void; + setter: (v: string) => void; + amount: string; + type: "fiat" | "token"; + loading: boolean; +} & Omit< + LimitInputProps, + | "onChange" + | "price" + | "tokenAmount" + | "setMarketAmount" + | "quoteAssetPrice" + | "expectedOutput" + | "expectedOutputLoading" +>; + +function AutoInput({ + focused, + baseAsset, + swapFocus, + amount, + setter, + type, + disableSwitching, + loading, +}: AutoInputProps) { + const { isMobile } = useWindowSize(); + const currentTypeEnum = useMemo( + () => (type === "fiat" ? FocusedInput.FIAT : FocusedInput.TOKEN), + [type] + ); + + const isFocused = useMemo( + () => focused === currentTypeEnum, + [currentTypeEnum, focused] + ); + + const oppositeTypeEnum = useMemo( + () => (type === "fiat" ? FocusedInput.TOKEN : FocusedInput.FIAT), + [type] + ); + + const scale = useMemo( + () => calcScale(amount.length, isMobile), + [amount, isMobile] + ); + return ( +
0, + } + )} + style={{ + transform: `scale(${isFocused ? scale : 0.45})`, + }} + onClick={ + !disableSwitching && focused === oppositeTypeEnum + ? swapFocus + : undefined + } + > + {loading ? ( +
+ Estimating...{" "} +
+ ) : ( + + )} +
+ ); +} + +function SwapArrows() { + return ( +
+ + +
+ ); +} 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/place-limit-tool/index.tsx b/packages/web/components/place-limit-tool/index.tsx new file mode 100644 index 0000000000..64d4c54a0a --- /dev/null +++ b/packages/web/components/place-limit-tool/index.tsx @@ -0,0 +1,571 @@ +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, + 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; +} + +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 + ? parseFloat(updatedValue).toFixed(decimalCount).toString() + : updatedValue; +}; + +export const PlaceLimitTool: FunctionComponent = observer( + ({ page }: PlaceLimitToolProps) => { + const { accountStore } = useStore(); + const { t } = useTranslation(); + const [reviewOpen, setReviewOpen] = useState(false); + const { + isOpen: isAddFundsModalOpen, + onClose: closeAddFundsModal, + onOpen: openAddFundsModal, + } = useDisclosure(); + + // const fromAssetsPage = useMemo(() => page === "Token Info Page", [page]); + + 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 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]); + + 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 buttonText = useMemo(() => { + if (swapState.error) { + return t(swapState.error); + } else { + return orderDirection === "bid" + ? t("portfolio.buy") + : t("limitOrders.sell"); + } + }, [orderDirection, swapState.error, t]); + + // const price = useMemo( + // () => + // type === "market" + // ? orderDirection === "bid" + // ? swapState.priceState.askSpotPrice! + // : swapState.priceState.bidSpotPrice! + // : swapState.priceState.price, + // [ + // orderDirection, + // swapState.priceState.askSpotPrice, + // swapState.priceState.bidSpotPrice, + // swapState.priceState.price, + // type, + // ] + // ); + + const tokenAmount = useMemo( + () => swapState.inAmountInput.inputAmount, + [swapState.inAmountInput.inputAmount] + ); + + const isMarketLoading = useMemo(() => { + return ( + swapState.isMarket && + (swapState.marketState.isQuoteLoading || + Boolean(swapState.marketState.isLoadingNetworkFee) || + swapState.marketState.inAmountInput.isTyping) && + !Boolean(swapState.marketState.error) + ); + }, [ + swapState.isMarket, + swapState.marketState.isLoadingNetworkFee, + swapState.marketState.isQuoteLoading, + swapState.marketState.inAmountInput.isTyping, + 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 [focused, setFocused] = useState<"fiat" | "token">( + tab === "buy" ? "fiat" : "token" + ); + + const [fiatAmount, setFiatAmount] = useState(""); + + const setAmountSafe = useCallback( + (amountType: "fiat" | "token", value?: string) => { + const update = + amountType === "fiat" + ? setFiatAmount + : swapState.inAmountInput.setAmount; + const setMarketAmount = swapState.marketState.inAmountInput.setAmount; + + if (!value?.trim()) { + if (amountType === "fiat") { + setMarketAmount(""); + } + return update(""); + } + + const updatedValue = transformAmount( + value, + amountType === "fiat" ? 6 : 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, + ] + ); + + // useEffect(() => { + // if (tokenAmount.length === 0 && focused === "fiat") { + // setAmountSafe("fiat", ""); + // if (tab === "buy") { + // swapState.marketState.inAmountInput.setAmount(""); + // } + // } + + // if (focused !== "token" || !price) return; + + // const value = tokenAmount.length > 0 ? new Dec(tokenAmount) : undefined; + // const fiatValue = value ? price.mul(value) : undefined; + + // setAmountSafe("fiat", fiatValue ? fiatValue.toString() : undefined); + // }, [ + // focused, + // price, + // setAmountSafe, + // tokenAmount, + // swapState.marketState.inAmountInput, + // tab, + // ]); + + // useEffect(() => { + // if (focused !== "fiat" || !price) return; + + // const value = + // fiatAmount && fiatAmount.length > 0 ? fiatAmount : undefined; + // const tokenValue = value ? new Dec(value).quo(price) : undefined; + // setAmountSafe("token", tokenValue ? tokenValue.toString() : undefined); + // }, [price, fiatAmount, setAmountSafe, focused]); + + const expectedOutput = useMemo( + () => swapState.marketState.quote?.amount.toDec(), + [swapState.marketState.quote?.amount] + ); + + const toggleMax = useCallback(() => { + if (tab === "buy") { + return setAmountSafe( + "fiat", + swapState.quoteTokenBalance?.toDec().toString() ?? "" + ); + } + + return setAmountSafe( + "token", + swapState.baseTokenBalance?.toDec().toString() ?? "" + ); + }, [ + tab, + setAmountSafe, + swapState.baseTokenBalance, + swapState.quoteTokenBalance, + ]); + + return ( + <> +
+ + + + + {t("limitOrders.enterAnAmountTo")}{" "} + {orderDirection === "bid" + ? t("portfolio.buy").toLowerCase() + : t("limitOrders.sell").toLowerCase()} + + + + +
+ $} + inputValue={ + focused === "fiat" + ? type === "market" && tab === "sell" + ? trimPlaceholderZeros( + (expectedOutput ?? new Dec(0)).toString() + ) + : fiatAmount + : type === "market" && tab === "buy" + ? trimPlaceholderZeros( + (expectedOutput ?? new Dec(0)).toString() + ) + : tokenAmount + } + onInputChange={(e) => setAmountSafe(focused, e.target.value)} + /> + +
+ + + + +
+ {type === "limit" && ( + + )} +
+ {!account?.isWalletConnected ? ( + + ) : hasFunds ? ( + + ) : ( + + )} +
+ +
+ { + setIsSendingTx(true); + await swapState.placeLimit(); + swapState.reset(); + setReviewOpen(false); + setIsSendingTx(false); + }} + outAmountLessSlippage={outAmountLessSlippage} + outFiatAmountLessSlippage={outFiatAmountLessSlippage} + isConfirmationDisabled={isSendingTx} + isOpen={reviewOpen} + onClose={() => setReviewOpen(false)} + swapState={swapState.marketState} + orderType={type} + percentAdjusted={swapState.priceState.percentAdjusted} + limitPriceFiat={swapState.priceState.priceFiat} + baseDenom={swapState.baseAsset?.coinDenom} + slippageConfig={slippageConfig} + /> + set({ from: e })} + setToAssetDenom={(e) => set({ to: e })} + /> + + ); + } +); + +// function SwapArrows() { +// return ( +//
+// +// +//
+// ); +// } 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..b4bd558664 --- /dev/null +++ b/packages/web/components/place-limit-tool/limit-price-selector.tsx @@ -0,0 +1,267 @@ +import { Dec } from "@keplr-wallet/unit"; +import classNames from "classnames"; +import React, { FC, useCallback, useEffect, useMemo, useState } from "react"; +import AutosizeInput from "react-input-autosize"; + +import { Icon } from "~/components/assets"; +import { SkeletonLoader } from "~/components/loaders"; +import { GenericDisclaimer } from "~/components/tooltip/generic-disclaimer"; +import { useTranslation } from "~/hooks"; +import { isValidNumericalRawInput } from "~/hooks/input/use-amount-input"; +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 [input, setInput] = useState(null); + const { t } = useTranslation(); + const { priceState } = swapState; + const [inputMode, setInputMode] = useState(InputMode.Percentage); + + const swapInputMode = useCallback(() => { + setInputMode( + inputMode === InputMode.Percentage + ? InputMode.Price + : InputMode.Percentage + ); + + if (input) input.focus(); + }, [inputMode, input]); + + useEffect(() => { + if ( + priceState.spotPrice && + priceState.orderPrice.length > 0 && + inputMode === InputMode.Price + ) { + const percentAdjusted = new Dec(priceState.orderPrice) + .quo(priceState.spotPrice) + .sub(new Dec(1)) + .mul(new Dec(100)); + + priceState._setPercentAdjustedUnsafe( + percentAdjusted.isZero() + ? "" + : formatPretty(percentAdjusted.abs(), { + maxDecimals: 2, + }).toString() + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [priceState.spotPrice, priceState.orderPrice, inputMode]); + + const priceLabel = useMemo(() => { + if (inputMode === InputMode.Percentage) { + return formatPretty( + priceState.priceFiat, + getPriceExtendedFormatOptions(priceState.priceFiat.toDec()) + ); + } + return priceState.percentAdjusted.isZero() + ? t("limitOrders.marketPrice") + : `${formatPretty(priceState.percentAdjusted.mul(new Dec(100)).abs())}%`; + }, [inputMode, priceState.percentAdjusted, priceState.priceFiat, t]); + + const percentageSuffix = useMemo(() => { + return priceState.percentAdjusted.isZero() + ? `${ + orderDirection === "bid" + ? t("limitOrders.below") + : t("limitOrders.above") + } ${t("limitOrders.currentPrice")}` + : `${ + priceState.percentAdjusted.isNegative() + ? t("limitOrders.below") + : t("limitOrders.above") + } ${t("limitOrders.currentPrice")}`; + }, [t, priceState.percentAdjusted, orderDirection]); + + // const TooltipContent = useMemo(() => { + // const translationId = + // orderDirection === "bid" + // ? "limitOrders.aboveMarket" + // : "limitOrders.belowMarket"; + // return ( + //
+ //
{t(`${translationId}.title`)}
+ // + // {t(`${translationId}.description`)} + // + //
+ // ); + // }, [orderDirection, t]); + + return ( +
+
+ + {orderDirection === "bid" + ? t(`limitOrders.aboveMarket.title`) + : t(`limitOrders.belowMarket.title`)} + + } + body={ + + {orderDirection === "bid" + ? t(`limitOrders.aboveMarket.description`) + : t(`limitOrders.belowMarket.description`)} + + } + > + + +
+
+ + {inputMode === InputMode.Price && $} + {inputMode === InputMode.Price ? ( + { + const value = e.target.value.trim(); + if (!isValidNumericalRawInput(value) || value.length === 0) + return swapState.priceState.setPrice(""); + swapState.priceState.setPrice(value); + }} + /> + ) : ( + + swapState.priceState.setPercentAdjusted( + e.target.value.replace("%", "") + ) + } + /> + )} + {inputMode === InputMode.Percentage && ( + + % + + {percentageSuffix} + + + )} + +
+
+
+ {percentAdjustmentOptions.map(({ label, value, defaultValue }) => ( + + ))} +
+
+ ); +}; diff --git a/packages/web/components/swap-tool/alt.tsx b/packages/web/components/swap-tool/alt.tsx new file mode 100644 index 0000000000..501d28de40 --- /dev/null +++ b/packages/web/components/swap-tool/alt.tsx @@ -0,0 +1,879 @@ +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 { 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, + 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 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 = () => { + // // prompt to select wallet insteaad of swapping + // if (account?.walletStatus !== WalletStatus.Connected) { + // return onOpenWalletSelect({ + // walletOptions: [{ walletType: "cosmos", chainId: chainId }], + // }); + // } + + 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 = useMemo( + // () => + // featureFlags.swapToolSimulateFee && + // !isNil(account?.address) && + // !swapState.inAmountInput.hasErrorWithCurrentBalanceQuote && + // !swapState.inAmountInput?.balance?.toDec().isZero() && + // swapState.inAmountInput.isLoadingCurrentBalanceNetworkFee, + // [ + // account?.address, + // featureFlags.swapToolSimulateFee, + // swapState.inAmountInput?.balance, + // swapState.inAmountInput.hasErrorWithCurrentBalanceQuote, + // swapState.inAmountInput.isLoadingCurrentBalanceNetworkFee, + // ] + // ); + + const isConfirmationDisabled = useMemo(() => { + return ( + isSendingTx || + isWalletLoading || + (account?.walletStatus === WalletStatus.Connected && + (swapState.inAmountInput.isEmpty || + !Boolean(swapState.quote) || + isSwapToolLoading || + Boolean(swapState.error) || + Boolean(swapState.networkFeeError))) + ); + }, [ + account?.walletStatus, + isSendingTx, + isSwapToolLoading, + isWalletLoading, + swapState.error, + swapState.inAmountInput.isEmpty, + swapState.networkFeeError, + swapState.quote, + ]); + + const showTokenSelectRecommendedTokens = useMemo( + () => isNil(forceSwapInPoolId), + [forceSwapInPoolId] + ); + + // const isUnsufficentBalance = useMemo( + // () => swapState.error?.message === "Insufficient balance", + // [swapState.error?.message] + // ); + + const { + isOpen: isAddFundsModalOpen, + onClose: closeAddFundsModal, + // onOpen: openAddFundsModal, + } = useDisclosure(); + + 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 + ) + } + /> + +
+ { + 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() + ), + } + )} + + +
+
+
+ +
+ + + + To + + +
+ + {swapState.quote?.amount + ? formatPretty(swapState.quote.amount.toDec(), { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + }) + : "0"} + + } + /> + + showTokenSelectRecommendedTokens && + setOneTokenSelectOpen("to") + } + /> +
+ + + {swapState.tokenOutFiatValue?.toDec().gt(new Dec(0)) ? ( + + {formatPretty(swapState.tokenOutFiatValue, { + ...getPriceExtendedFormatOptions( + swapState.tokenOutFiatValue.toDec() + ), + })} + {` (-${outputDifference})`} + + ) : ( + "" + )} + + +
+ {/*
+
+
+ From +
+
+
+ { + e.preventDefault(); + if (e.target.value.length <= (isMobile ? 19 : 26)) { + swapState.inAmountInput.setAmount(e.target.value); + } + }} + value={swapState.inAmountInput.inputAmount} + /> +
+
+ {swapState.fromAsset && ( +
+ {`${swapState.fromAsset.coinDenom} + +
+ )} +
+
+
+ + {swapState.inAmountInput?.fiatValue + ?.toDec() + .gt(new Dec(0)) + ? `${formatPretty(swapState.inAmountInput?.fiatValue, { + ...getPriceExtendedFormatOptions( + swapState.inAmountInput?.fiatValue?.toDec() + ), + })}` + : ""} + + {account?.isWalletConnected && ( +
+ + {t("pool.available") + + ": " + + formatPretty( + swapState.inAmountInput.balance?.toDec() ?? + new Dec(0), + { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + } + ) + + " "} + + {swapState.inAmountInput.balance && + swapState.inAmountInput.balance + .toDec() + .gt(new Dec(0)) ? ( + + ) : ( + + )} +
+ )} +
+
+
+
+ +
+
+
+
+ To +
+
+
+
+ {swapState.quote?.amount + ? formatPretty(swapState.quote.amount.toDec(), { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + }) + : "0"} +
+
+
+ {swapState.toAsset && ( +
+ {`${swapState.toAsset.coinDenom} + +
+ )} +
+
+
+ + {swapState.tokenOutFiatValue?.toDec().gt(new Dec(0)) ? ( + + {formatPretty(swapState.tokenOutFiatValue, { + ...getPriceExtendedFormatOptions( + swapState.tokenOutFiatValue.toDec() + ), + })} + {` (-${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)} + swapState={swapState} + confirmAction={sendSwapTx} + isConfirmationDisabled={isConfirmationDisabled} + slippageConfig={slippageConfig} + outAmountLessSlippage={outAmountLessSlippage} + outFiatAmountLessSlippage={outFiatAmountLessSlippage} + outputDifference={outputDifference} + showOutputDifferenceWarning={showOutputDifferenceWarning} + /> + { + 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..4d2737a5a2 --- /dev/null +++ b/packages/web/components/swap-tool/order-type-selector.tsx @@ -0,0 +1,121 @@ +import classNames from "classnames"; +import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs"; +import React, { useEffect, useMemo } from "react"; + +import { EventName } from "~/config"; +import { useAmplitudeAnalytics, useTranslation } from "~/hooks"; +import { useOrderbookSelectableDenoms } from "~/hooks/limit-orders/use-orderbook"; + +interface UITradeType { + // id: "market" | "limit" | "recurring"; + id: "market" | "limit"; + title: string; + disabled: boolean; +} + +// const TRADE_TYPES = ["market", "limit", "recurring"] as const; +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; + } + // 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, + }, + // { + // id: "recurring", + // title: t("limitOrders.recurringOrder.title"), + // description: t("limitOrders.recurringOrder.description"), + // icon: "history-uncolored", + // }, + ], + [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..7ef7f185eb --- /dev/null +++ b/packages/web/components/swap-tool/price-selector.tsx @@ -0,0 +1,470 @@ +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 { Disableable } from "~/components/types"; +import { AssetLists } 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 { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; +import { api } from "~/utils/trpc"; + +interface PriceSelectorProps {} + +type AssetWithBalance = Asset & MaybeUserAssetCoin; + +const UI_DEFAULT_QUOTES = ["USDC", "USDT"]; + +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( + ({ disabled }: PriceSelectorProps & Disableable) => { + 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) => { + 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 }) => amount?.toDec().gt(new Dec(0)) ?? false + ) ?? [], + [userQuotes] + ); + + const selectableQuotes = useMemo(() => { + return tab === "sell" + ? userQuotesWithoutBalances + : defaultQuotesWithBalances; + }, [defaultQuotesWithBalances, tab, userQuotesWithoutBalances]); + + 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} + + +
+
+ )} +
+
+ + +
+ +
+
+ {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("OSMO")); + 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..d6ef633828 --- /dev/null +++ b/packages/web/components/swap-tool/trade-details.tsx @@ -0,0 +1,525 @@ +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 { 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 { Skeleton } from "~/components/ui/skeleton"; +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; + isMakerFeeLoading?: boolean; +} + +export const TradeDetails = ({ + swapState, + inPriceFetching, + treatAsStable, + type, + makerFee, + isMakerFeeLoading, +}: Partial) => { + const { t } = useTranslation(); + + const routesVisDisclosure = useDisclosure(); + + const [outAsBase, setOutAsBase] = useState(true); + + const [details, { height: detailsHeight }] = useMeasure(); + + const isInAmountEmpty = useMemo( + () => swapState?.inAmountInput.error instanceof EmptyAmountError, + [swapState?.inAmountInput.error] + ); + + const isLoading = useMemo( + () => + (swapState?.isLoadingNetworkFee || + swapState?.isQuoteLoading || + swapState?.inAmountInput.isTyping) && + !Boolean(swapState.error), + [ + swapState?.inAmountInput.isTyping, + swapState?.isLoadingNetworkFee, + swapState?.isQuoteLoading, + swapState?.error, + ] + ); + + const priceImpact = useMemo( + () => swapState?.quote?.priceImpactTokenOut, + [swapState?.quote?.priceImpactTokenOut] + ); + + const isPriceImpactHigh = useMemo( + () => priceImpact?.toDec().abs().gt(new Dec(0.1)), + [priceImpact] + ); + + const limitTotalFees = useMemo(() => { + 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 ? t("swap.hideDetails") : t("swap.showDetails")} + +
+
+
+
+ + {type === "market" ? ( + + {t("assets.transfer.priceImpact")} + + } + right={ + +
+ {isPriceImpactHigh && ( + + )} + + -{formatPretty(priceImpact ?? new Dec(0))} + +
+
+ } + /> + ) : ( + Trade fees (when order filled)} + right={ + + {t("transfer.free")} + + } + /> + )} + {type === "market" && ( + + {t("pools.aprBreakdown.swapFees")} + + } + right={ + <> + {swapState?.tokenInFeeAmountFiatValue && ( + <> + {swapState?.tokenInFeeAmountFiatValue + .toDec() + .gt(new Dec(0)) ? ( + + + ~ + {formatPretty( + swapState?.tokenInFeeAmountFiatValue, + { + maxDecimals: 2, + } + )} + + + {swapState?.quote?.swapFee + ? ` (${swapState?.quote?.swapFee})` + : ""} + + + ) : ( + + {t("transfer.free")} + + )} + + )} + + } + /> + )} + + This is the fee charged by the Osmosis protocol at the + time of trade execution in order to reward liquidity + providers and maintain the network.
+
Trade fees for limit orders are currently free. +
+
+ Network fees are additional to every transaction. + + } + > + Additional network fees + + } + right={ + swapState && ( + <> + {!swapState.isLoadingNetworkFee ? ( + + ~ + {type === "market" + ? swapState.networkFee?.gasUsdValueToPay && + formatPretty( + swapState.networkFee?.gasUsdValueToPay, + { + maxDecimals: 2, + } + ) + : limitTotalFees} + + ) : ( + + )} + + ) + } + /> + {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?.length}{" "} + {routes?.length === 1 ? "route" : "routes"} + + +
+
+ + + + + ); + }} +
+ )} +
+
+
+ )} +
+
+ ); +}; + +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(0); + + var baseAsset; + var quoteAsset; + var inQuoteAssetPrice; + var 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/tooltip/generic-disclaimer.tsx b/packages/web/components/tooltip/generic-disclaimer.tsx new file mode 100644 index 0000000000..f372588b82 --- /dev/null +++ b/packages/web/components/tooltip/generic-disclaimer.tsx @@ -0,0 +1,30 @@ +import { PropsWithChildren, ReactNode } from "react"; + +import { Tooltip } from "~/components/tooltip"; + +export function GenericDisclaimer({ + body, + children, + title, + disabled, +}: PropsWithChildren< + Partial<{ title: ReactNode; body: ReactNode; disabled: boolean }> +>) { + return ( + +
+ {title} + {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..42d01255a7 --- /dev/null +++ b/packages/web/components/trade-tool/index.tsx @@ -0,0 +1,151 @@ +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 } 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 [tab, setTab] = useQueryState( + "tab", + parseAsStringEnum(Object.values(SwapToolTab)).withDefault( + SwapToolTab.SWAP + ) + ); + // const { t } = useTranslation(); + + const { accountStore } = useStore(); + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + + // const { count } = useOrderbookClaimableOrders({ + // userAddress: wallet?.address ?? "", + // }); + + const { orders } = useOrderbookAllActiveOrders({ + userAddress: wallet?.address ?? "", + pageSize: 100, + }); + + const openOrders = useMemo( + () => + orders.filter( + ({ status }) => status === "open" || status === "partiallyFilled" + ), + [orders] + ); + + 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; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab]); + return ( + +
+
+ +
+ {tab !== SwapToolTab.SWAP && } + {/* {wallet?.isWalletConnected && ( + + + {count > 0 && ( +
+ {count} +
+ )} + + )} */} +
+
+ {useMemo(() => { + switch (tab) { + case SwapToolTab.BUY: + return ; + case SwapToolTab.SELL: + return ; + case SwapToolTab.SWAP: + default: + return ( + + ); + } + }, [page, swapToolProps, tab])} +
+ {wallet?.isWalletConnected && openOrders.length > 0 && ( + +
+
+ +
+ + Order history + +
+
+ {/* {openOrders.length} */} +
+ +
+
+ + )} +
+ ); + } +); 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..2fa009ccbd 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,9 +6,13 @@ 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"; @@ -15,6 +20,8 @@ import { TransactionsPaginaton } from "~/components/transactions/transaction-pag import { TransactionRows } from "~/components/transactions/transaction-rows"; import { useTranslation } from "~/hooks"; +const TX_PAGE_TABS = ["history", "orders"] as const; + export const TransactionContent = ({ setSelectedTransactionHash, selectedTransactionHash, @@ -46,6 +53,11 @@ export const TransactionContent = ({ const router = useRouter(); + const [tab, setTab] = useQueryState( + "tab", + parseAsStringLiteral(TX_PAGE_TABS).withDefault("history") + ); + const showTransactionContent = wallet && wallet.isWalletConnected && @@ -55,53 +67,86 @@ 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])} + > + + {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 }, + }} + /> + )} +
+ + +
+ + + +
+
- -
+ ); }; 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 4b630f2f5b..03443b72a6 100644 --- a/packages/web/config/analytics-events.ts +++ b/packages/web/config/analytics-events.ts @@ -236,6 +236,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/config/generate-lists.ts b/packages/web/config/generate-lists.ts index f330e42612..ab83a2538f 100644 --- a/packages/web/config/generate-lists.ts +++ b/packages/web/config/generate-lists.ts @@ -98,6 +98,7 @@ async function generateChainListFile({ environment === "mainnet" ? "MainnetChainIds" : "TestnetChainIds"; if (!onlyTypes) { + // TODO: remove logoURIs type after merging with stage !IMPORTANT content += ` import type { Chain, ChainInfoWithExplorer } from "@osmosis-labs/types"; export const ChainList: ( Omit & { chain_id: ${chainIdTypeName}; keplrChain: ChainInfoWithExplorer})[] = ${JSON.stringify( diff --git a/packages/web/hooks/input/use-amount-input.ts b/packages/web/hooks/input/use-amount-input.ts index 2e6e4414c9..af0e5374c5 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"; @@ -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] ); 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..2adeae6a50 --- /dev/null +++ b/packages/web/hooks/limit-orders/use-orderbook.ts @@ -0,0 +1,375 @@ +import { Dec } from "@keplr-wallet/unit"; +import { CoinPrimitive } from "@osmosis-labs/keplr-stores"; +import { MaybeUserAssetCoin, Orderbook } from "@osmosis-labs/server"; +import { MappedLimitOrder } from "@osmosis-labs/trpc"; +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"; + +// const USDC_DENOM = process.env.NEXT_PUBLIC_IS_TESTNET +// ? "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4" +// : "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; +// const USDT_DENOM = process.env.NEXT_PUBLIC_IS_TESTNET ? "" : ""; +// const validDenoms = [USDC_DENOM, USDT_DENOM]; + +/** + * 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, +}: { + userAddress: string; + pageSize?: number; +}) => { + const { orderbooks } = useOrderbooks(); + const addresses = orderbooks.map(({ contractAddress }) => contractAddress); + const { + data: orders, + isLoading, + fetchNextPage, + isFetching, + isFetchingNextPage, + hasNextPage, + refetch, + } = api.edge.orderbooks.getAllActiveOrders.useInfiniteQuery( + { + contractAddresses: addresses, + userOsmoAddress: userAddress, + limit: pageSize, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: 0, + keepPreviousData: true, + refetchInterval: 5000, + enabled: !!userAddress && addresses.length > 0, + } + ); + + const allOrders = useMemo(() => { + return orders?.pages.flatMap((page) => page.items) ?? []; + }, [orders]); + return { + orders: allOrders, + isLoading, + fetchNextPage, + isFetching, + isFetchingNextPage, + hasNextPage, + refetch, + }; +}; + +export const useOrderbookClaimableOrders = ({ + userAddress, +}: { + userAddress: string; +}) => { + const { orderbooks } = useOrderbooks(); + const { accountStore } = useStore(); + const account = accountStore.getWallet(accountStore.osmosisChainId); + const addresses = orderbooks.map(({ contractAddress }) => contractAddress); + const { + data: orders, + isLoading, + isFetching, + } = api.edge.orderbooks.getClaimableOrders.useQuery( + { + contractAddresses: addresses, + userOsmoAddress: userAddress, + }, + { + enabled: !!userAddress && addresses.length > 0, + } + ); + + 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); + } + }, [orders, account, addresses]); + + 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..32ee194ddf --- /dev/null +++ b/packages/web/hooks/limit-orders/use-place-limit.ts @@ -0,0 +1,687 @@ +import { CoinPretty, Dec, Int, PricePretty } from "@keplr-wallet/unit"; +import { priceToTick } from "@osmosis-labs/math"; +import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { isValidNumericalRawInput } from "@osmosis-labs/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { tError } from "~/components/localization"; +import { EventName, EventPage } from "~/config"; +import { 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 { useSwap, useSwapAssets } from "~/hooks/use-swap"; +import { useStore } from "~/stores"; +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({ + orderbookContractAddress, + orderDirection, + baseDenom: baseAsset?.coinMinimalDenom, + }); + + const isMarket = useMemo( + () => type === "market" || priceState.isBeyondOppositePrice, + [type, priceState.isBeyondOppositePrice] + ); + + 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 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; + } + } + + const paymentDenom = paymentTokenValue.toCoin().denom; + // 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, + }, + }; + + 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, + msg, + [ + { + 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, + priceState, + paymentTokenValue, + isMarket, + marketState, + quoteAssetPrice, + normalizationFactor, + paymentFiatValue, + baseAsset, + quoteAsset, + logEvent, + page, + feeUsdValue, + ]); + + const { data: baseTokenBalance, isLoading: isBaseTokenBalanceLoading } = + api.local.balances.getUserBalances.useQuery( + { bech32Address: account?.address ?? "" }, + { + enabled: !!account?.address, + select: (balances) => + balances.find(({ denom }) => denom === baseAsset?.coinMinimalDenom) + ?.coin, + } + ); + const { data: quoteTokenBalance, isLoading: isQuoteTokenBalanceLoading } = + api.local.balances.getUserBalances.useQuery( + { bech32Address: account?.address ?? "" }, + { + enabled: !!account?.address, + select: (balances) => + balances.find(({ denom }) => denom === quoteAsset?.coinMinimalDenom) + ?.coin, + } + ); + + const insufficientFunds = + (orderDirection === "bid" + ? quoteTokenBalance?.toDec()?.lt(paymentTokenValue.toDec() ?? new Dec(0)) + : baseTokenBalance + ?.toDec() + ?.lt(paymentTokenValue.toDec() ?? new Dec(0))) ?? true; + + 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, + ]); + + return { + baseAsset, + quoteAsset, + priceState, + inAmountInput, + placeLimit, + baseTokenBalance, + quoteTokenBalance, + isBalancesFetched: + !isBaseTokenBalanceLoading && !isQuoteTokenBalanceLoading, + insufficientFunds, + paymentFiatValue, + makerFee, + isMakerFeeLoading, + expectedTokenAmountOut, + expectedFiatAmountOut, + marketState, + isMarket, + quoteAssetPrice, + reset, + error, + feeUsdValue, + }; +}; + +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 = ({ + orderbookContractAddress, + orderDirection, + baseDenom, +}: { + orderbookContractAddress: string; + orderDirection: OrderDirection; + baseDenom?: string; +}) => { + const { data, isLoading } = api.edge.orderbooks.getOrderbookState.useQuery( + { + osmoAddress: orderbookContractAddress, + }, + { + enabled: !!orderbookContractAddress, + } + ); + const { + data: assetPrice, + isLoading: loadingSpotPrice, + isRefetching: isSpotPriceRefetching, + } = api.edge.assets.getAssetPrice.useQuery( + { + coinMinimalDenom: baseDenom ?? "", + }, + { refetchInterval: 10000, enabled: !!baseDenom } + ); + + const [orderPrice, setOrderPrice] = useState(""); + const [manualPercentAdjusted, setManualPercentAdjusted] = useState(""); + + // Decimal version of the spot price, defaults to 1 + const spotPrice = useMemo(() => { + return assetPrice ? assetPrice.toDec() : new Dec(1); + }, [assetPrice]); + + // 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) > 2) { + price = parseFloat(price).toFixed(2).toString(); + } + + 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()); + } + + setOrderPrice(price); + + if (price.length === 0) { + setManualPercentAdjusted(""); + } + }, + [setOrderPrice] + ); + + // 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) > 10) { + percentAdjusted = parseFloat(percentAdjusted).toFixed(10).toString(); + } + + 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); + + // Reset the user's manual order price if they adjust percentage + if (orderPrice.length > 0) setOrderPrice(""); + }, + [setManualPercentAdjusted, orderPrice.length, orderDirection] + ); + + // Whether the user's manual order price is a valid price + const isValidInputPrice = useMemo( + () => + Boolean(orderPrice) && + orderPrice.length > 0 && + !new Dec(orderPrice).isZero() && + new Dec(orderPrice).isPositive(), + [orderPrice] + ); + + // 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 (orderPrice && orderPrice.length > 0) { + 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) ?? new Dec(1); + }, [orderPrice, spotPrice, manualPercentAdjusted, orderDirection]); + + // The raw percentage adjusted based on the current order price state + const percentAdjusted = useMemo( + () => price.quo(spotPrice ?? new Dec(1)).sub(new Dec(1)), + [price, spotPrice] + ); + + // If the user is inputting a price that crosses over the spot price + const isBeyondOppositePrice = useMemo(() => { + return orderDirection === "ask" ? spotPrice.gt(price) : spotPrice.lt(price); + }, [orderDirection, price, spotPrice]); + + const priceFiat = useMemo(() => { + return new PricePretty(DEFAULT_VS_CURRENCY, price); + }, [price]); + + const reset = useCallback(() => { + setManualPercentAdjusted(""); + setOrderPrice(""); + }, []); + + // const setPercentAdjusted = useCallback( + // (percentAdjusted: string) => { + // if (!percentAdjusted || percentAdjusted.length === 0) { + // setManualPercentAdjusted(""); + // } else { + // if (countDecimals(percentAdjusted) > 10) { + // percentAdjusted = parseFloat(percentAdjusted).toFixed(10).toString(); + // } + // if ( + // orderDirection === "bid" && + // new Dec(percentAdjusted).gte(new Dec(100)) + // ) { + // return; + // } + + // const split = percentAdjusted.split("."); + // if (split[0].length > 9) { + // return; + // } + + // setManualPercentAdjusted(percentAdjusted); + // } + // }, + // [setManualPercentAdjusted, orderDirection] + // ); + + useEffect(() => { + reset(); + }, [orderDirection, reset]); + + const isValidPrice = useMemo(() => { + return isValidInputPrice || Boolean(spotPrice); + }, [isValidInputPrice, spotPrice]); + + return { + spotPrice, + orderPrice, + price, + priceFiat, + manualPercentAdjusted, + setPercentAdjusted: setManualPercentAdjustedSafe, + _setPercentAdjustedUnsafe: setManualPercentAdjusted, + percentAdjusted, + isLoading: isLoading || loadingSpotPrice, + reset, + setPrice: setManualOrderPrice, + isValidPrice, + isBeyondOppositePrice, + bidSpotPrice: data?.bidSpotPrice, + askSpotPrice: data?.askSpotPrice, + isSpotPriceRefetching, + }; +}; diff --git a/packages/web/hooks/use-feature-flags.ts b/packages/web/hooks/use-feature-flags.ts index ebcdafea24..777dc3ef1b 100644 --- a/packages/web/hooks/use-feature-flags.ts +++ b/packages/web/hooks/use-feature-flags.ts @@ -28,6 +28,7 @@ export type AvailableFlags = | "newAssetsPage" | "newDepositWithdrawFlow" | "oneClickTrading" + | "limitOrders" | "advancedChart"; type ModifiedFlags = @@ -58,6 +59,7 @@ const defaultFlags: Record = { displayDailyEarn: false, newDepositWithdrawFlow: false, oneClickTrading: false, + limitOrders: false, advancedChart: false, _isInitialized: false, _isClientIDPresent: false, @@ -78,7 +80,6 @@ export const useFeatureFlags = () => { const isDevModeWithoutClientID = process.env.NODE_ENV === "development" && !process.env.NEXT_PUBLIC_LAUNCH_DARKLY_CLIENT_SIDE_ID; - return { ...launchdarklyFlags, ...(isDevModeWithoutClientID ? defaultFlags : {}), diff --git a/packages/web/hooks/use-swap.tsx b/packages/web/hooks/use-swap.tsx index 2c9605ceba..ee93c1bacb 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,17 @@ 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 existingParams = router.query; + // router.replace({ + // query: { + // ...existingParams, + // from: toDenomQueryParamStr, + // to: fromDenomQueryParamStr, + // }, + // }); + const temp = fromDenomQueryParam; + setFromDenomQueryParam(toAssetQueryParam); + setToAssetQueryParam(temp); return; } @@ -942,7 +940,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 +1313,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/de.json b/packages/web/localizations/de.json index 3cfaf64da3..a445e5ff2c 100644 --- a/packages/web/localizations/de.json +++ b/packages/web/localizations/de.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Älter", "newer": "Neuere" + }, + "limitOrders": { + "reviewOrder": "Bestellung überprüfen", + "tradeDetails": "Handelsdetails", + "marketOrder": { + "title": "Marktauftrag", + "description": { + "buy": "Sofort zum besten verfügbaren Preis kaufen", + "sell": "Sofort zum besten verfügbaren Preis verkaufen" + } + }, + "limitOrder": { + "title": "Limit-Auftrag", + "description": { + "buy": "Kaufen, wenn der Preis {denom} sinkt", + "sell": "Verkaufen, wenn der Preis {denom} steigt", + "disabled": "Derzeit nicht verfügbar für {denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "Wiederkehrende Bestellung", + "description": "{action} zum Durchschnittspreis im Zeitverlauf" + }, + "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", + "insufficientFunds": "Unzureichende Mittel", + "addFunds": "Guthaben hinzufügen", + "watchOut": "Achtung, Wal im Anmarsch", + "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}", + "estimating": "Schätzen", + "totalFeesWhenFilled": "Gesamtgebühren bei Ausfüllung", + "at": "bei", + "value": "Wert", + "estimatedFees": "Geschätzte Gebühren", + "totalEstimatedFees": "Geschätzte Gesamtgebühren", + "receiveMin": "Erhalten Sie mindestens", + "receiveAsset": "Anlagegut erhalten", + "orderType": "Auftragsart", + "limitPrice": "Limitpreis", + "moreDetails": "Mehr Details", + "confirm": "Bestätigen", + "expectedRate": "Erwarteter Kurs", + "market": "Markt", + "limit": "Grenze", + "increases": "erhöht sich", + "decreases": "nimmt ab", + "estimatingFees": "Gebührenschätzung", + "fees": "Gebühren", + "receiveEstimated": "Erhalten Sie mindestens", + "swapRoute": "Route bestellen", + "trade": "Handel", + "swapToAnotherAsset": "Tauschen Sie gegen ein anderes Asset", + "openOrders": "Offene Aufträge", + "invalidPrice": "Ungültiger Preis", + "unavailable": "Nicht verfügbar für {denom}" + }, + "orderHistory": { + "orders": "Aufträge", + "history": "Geschichte" } } diff --git a/packages/web/localizations/en.json b/packages/web/localizations/en.json index 4197cd2ed4..136d880ae3 100644 --- a/packages/web/localizations/en.json +++ b/packages/web/localizations/en.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Older", "newer": "Newer" + }, + "limitOrders": { + "reviewOrder": "Review Order", + "tradeDetails": "Trade Details", + "marketOrder": { + "title": "Market Order", + "description": { + "buy": "Buy immediately at best available price", + "sell": "Sell immediately at best available price" + } + }, + "limitOrder": { + "title": "Limit Order", + "description": { + "buy": "Buy when {denom} price decreases", + "sell": "Sell when {denom} price increases", + "disabled": "Currently unavailable for {denom}" + } + }, + "recurringOrder": { + "title": "Recurring Order", + "description": "{action} at average price over time" + }, + "aboveMarket": { + "title": "Limit price above market price", + "description": "If you proceed, your order will be processed as a market order at market price." + }, + "belowMarket": { + "title": "Limit price below market price", + "description": "If you proceed, your order will be processed as a market order at market price." + }, + "enterAnAmountTo": "Enter an amount to", + "sell": "Sell", + "insufficientFunds": "Insufficient Funds", + "addFunds": "Add Funds", + "watchOut": "Watch out! Whale incoming", + "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", + "estimating": "Estimating", + "totalFeesWhenFilled": "Total fees when filled", + "at": "at", + "value": "Value", + "estimatedFees": "Estimated fees", + "totalEstimatedFees": "Total Estimated Fees", + "receiveMin": "Receive minimum", + "receiveAsset": "Receive asset", + "orderType": "Order Type", + "limitPrice": "Limit Price", + "moreDetails": "More details", + "confirm": "Confirm", + "expectedRate": "Expected rate", + "market": "Market", + "limit": "Limit", + "increases": "increases", + "decreases": "decreases", + "estimatingFees": "Estimating fees", + "fees": "fees", + "receiveEstimated": "Receive at least", + "swapRoute": "Order route", + "trade": "Trade", + "swapToAnotherAsset": "Swap to another asset", + "openOrders": "Open orders", + "invalidPrice": "Invalid price", + "unavailable": "Unavailable for {denom}" + }, + "orderHistory": { + "orders": "Orders", + "history": "History" } } diff --git a/packages/web/localizations/es.json b/packages/web/localizations/es.json index 06f815176e..04ec9b9934 100644 --- a/packages/web/localizations/es.json +++ b/packages/web/localizations/es.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Más viejo", "newer": "Más nuevo" + }, + "limitOrders": { + "reviewOrder": "Revisar orden", + "tradeDetails": "Detalles comerciales", + "marketOrder": { + "title": "Orden de mercado", + "description": { + "buy": "Compre inmediatamente al mejor precio disponible", + "sell": "Vender inmediatamente al mejor precio disponible." + } + }, + "limitOrder": { + "title": "Orden límite", + "description": { + "buy": "Compre cuando el precio {denom} baje", + "sell": "Vender cuando el precio {denom} aumente", + "disabled": "Actualmente no disponible para {denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "Orden recurrente", + "description": "{action} al precio promedio a lo largo del tiempo" + }, + "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", + "insufficientFunds": "Fondos insuficientes", + "addFunds": "Añadir fondos", + "watchOut": "¡Cuidado! ballena entrante", + "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", + "estimating": "Estimando", + "totalFeesWhenFilled": "Tarifas totales cuando se llenan", + "at": "en", + "value": "Valor", + "estimatedFees": "Tarifas estimadas", + "totalEstimatedFees": "Tarifas totales estimadas", + "receiveMin": "Recibir mínimo", + "receiveAsset": "Recibir activo", + "orderType": "Tipo de orden", + "limitPrice": "Precio límite", + "moreDetails": "Más detalles", + "confirm": "Confirmar", + "expectedRate": "Expectativa salarial", + "market": "Mercado", + "limit": "Límite", + "increases": "aumenta", + "decreases": "disminuye", + "estimatingFees": "Estimación de tarifas", + "fees": "honorarios", + "receiveEstimated": "Recibe al menos", + "swapRoute": "Ruta de pedido", + "trade": "Comercio", + "swapToAnotherAsset": "Cambiar a otro activo", + "openOrders": "Ordenes abiertas", + "invalidPrice": "Precio no válido", + "unavailable": "No disponible para {denom}" + }, + "orderHistory": { + "orders": "Pedidos", + "history": "Historia" } } diff --git a/packages/web/localizations/fa.json b/packages/web/localizations/fa.json index 406a93c22c..da0b70795a 100644 --- a/packages/web/localizations/fa.json +++ b/packages/web/localizations/fa.json @@ -405,6 +405,7 @@ "txTimedOutError": "زمان معامله تمام شد. لطفا دوباره امتحان کنید.", "insufficientFee": "موجودی ناکافی برای کارمزد تراکنش لطفاً برای ادامه بودجه اضافه کنید.", "noData": "اطلاعاتی وجود ندارد", + "noOrderbook": "بدون دفترچه سفارش برای جفت", "uhOhSomethingWentWrong": "اوه اوه، مشکلی پیش آمد", "sorryForTheInconvenience": "با عرض پوزش برای ناراحتی. لطفاً بعداً دوباره امتحان کنید.", "startAgain": "دوباره شروع کن" @@ -832,9 +833,11 @@ "MAX": "همه", "minimumSlippage": "حداقل تغییرات قابل قبول ({slippage})", "pool": "استخر #{id}", - "priceImpact": "تاثیر قیمت", + "priceImpact": "تاثیر بازار", "routerTooltipFee": "کارمزد", "routerTooltipSpreadFactor": "فاکتور گسترش", + "showDetails": "نمایش جزئیات", + "hideDetails": "پنهان شدن", "continueAnyway": "ادامه دادن به هر طریق", "warning": { "exceedsSpendLimit": "این مبادله از حد باقیمانده هزینه شما برای تجارت 1 کلیک بیشتر است.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "مسن تر", "newer": "جدیدتر" + }, + "limitOrders": { + "reviewOrder": "سفارش بررسی", + "tradeDetails": "جزئیات تجارت", + "marketOrder": { + "title": "سفارش بازار", + "description": { + "buy": "بلافاصله با بهترین قیمت موجود خرید کنید", + "sell": "فروش فوری با بهترین قیمت موجود" + } + }, + "limitOrder": { + "title": "سفارش محدود", + "description": { + "buy": "زمانی که قیمت {denom} کاهش یابد، خرید کنید", + "sell": "فروش زمانی که قیمت {denom} افزایش می یابد", + "disabled": "در حال حاضر برای {denom} / {quoteDenom} در دسترس نیست" + } + }, + "recurringOrder": { + "title": "سفارش تکراری", + "description": "{action} با قیمت متوسط در طول زمان" + }, + "aboveMarket": { + "title": "محدودیت قیمت بالاتر از قیمت بازار", + "description": "در صورت ادامه، سفارش شما به عنوان سفارش بازار با قیمت بازار پردازش می شود." + }, + "belowMarket": { + "title": "محدود کردن قیمت زیر قیمت بازار", + "description": "در صورت ادامه، سفارش شما به عنوان سفارش بازار با قیمت بازار پردازش می شود." + }, + "enterAnAmountTo": "مقداری را وارد کنید", + "sell": "فروش", + "insufficientFunds": "بودجه ناکافی", + "addFunds": "اضافه کردن وجوه", + "watchOut": "مواظب باش! نهنگ ورودی", + "selectAnAssetTo": { + "buy": "دارایی را برای خرید انتخاب کنید", + "sell": "دارایی را برای فروش انتخاب کنید" + }, + "payWith": "پرداخت با", + "receive": "دريافت كردن", + "swapFromAnotherAsset": "تعویض از دارایی دیگر", + "connectYourWallet": "کیف پول خود را وصل کنید", + "toSeeYourBalances": "برای دیدن موجودی خود", + "searchAssets": "جستجوی دارایی ها", + "marketPrice": "قیمت بازار", + "below": "زیر", + "above": "در بالا", + "currentPrice": "قیمت فعلی", + "whenDenomPriceIs": "وقتی قیمت {denom} است", + "estimating": "تخمین زدن", + "totalFeesWhenFilled": "مجموع هزینه ها هنگام پر شدن", + "at": "در", + "value": "ارزش", + "estimatedFees": "هزینه های تخمینی", + "totalEstimatedFees": "مجموع هزینه های تخمینی", + "receiveMin": "حداقل دریافت کنید", + "receiveAsset": "دریافت دارایی", + "orderType": "نوع سفارش", + "limitPrice": "قیمت محدود", + "moreDetails": "جزئیات بیشتر", + "confirm": "تایید", + "expectedRate": "نرخ مورد انتظار", + "market": "بازار", + "limit": "حد", + "increases": "افزایش", + "decreases": "کاهش می دهد", + "estimatingFees": "برآورد هزینه ها", + "fees": "هزینه ها", + "receiveEstimated": "حداقل دریافت کنید", + "swapRoute": "مسیر سفارش", + "trade": "تجارت", + "swapToAnotherAsset": "تعویض به دارایی دیگر", + "openOrders": "باز کردن سفارشات", + "invalidPrice": "قیمت نامعتبر", + "unavailable": "برای {denom} در دسترس نیست" + }, + "orderHistory": { + "orders": "سفارشات", + "history": "تاریخ" } } diff --git a/packages/web/localizations/fr.json b/packages/web/localizations/fr.json index 4b5c092c54..52f4ecc4b3 100644 --- a/packages/web/localizations/fr.json +++ b/packages/web/localizations/fr.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Plus vieux", "newer": "Plus récent" + }, + "limitOrders": { + "reviewOrder": "Réviser la commande", + "tradeDetails": "Détails du commerce", + "marketOrder": { + "title": "Ordre du marché", + "description": { + "buy": "Achetez immédiatement au meilleur prix disponible", + "sell": "Vendre immédiatement au meilleur prix disponible" + } + }, + "limitOrder": { + "title": "Ordre Limité", + "description": { + "buy": "Achetez lorsque le prix {denom} diminue", + "sell": "Vendre lorsque le prix {denom} augmente", + "disabled": "Actuellement indisponible pour {denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "Commande récurrente", + "description": "{action} au prix moyen au fil du temps" + }, + "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", + "insufficientFunds": "Fonds insuffisants", + "addFunds": "Ajouter des fonds", + "watchOut": "Attention! Baleine entrante", + "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", + "estimating": "Estimation", + "totalFeesWhenFilled": "Frais totaux une fois remplis", + "at": "à", + "value": "Valeur", + "estimatedFees": "Frais Estimés", + "totalEstimatedFees": "Frais totaux estimés", + "receiveMin": "Recevoir un minimum", + "receiveAsset": "Recevoir l'actif", + "orderType": "Type de commande", + "limitPrice": "Prix limite", + "moreDetails": "Plus de détails", + "confirm": "Confirmer", + "expectedRate": "Taux attendu", + "market": "Marché", + "limit": "Limite", + "increases": "augmente", + "decreases": "diminue", + "estimatingFees": "Estimation des frais", + "fees": "frais", + "receiveEstimated": "Recevez au moins", + "swapRoute": "Itinéraire de commande", + "trade": "Commerce", + "swapToAnotherAsset": "Échanger vers un autre actif", + "openOrders": "Commandes ouvertes", + "invalidPrice": "Prix invalide", + "unavailable": "Indisponible pour {denom}" + }, + "orderHistory": { + "orders": "Ordres", + "history": "Histoire" } } diff --git a/packages/web/localizations/gu.json b/packages/web/localizations/gu.json index 0380ff6dce..b6fe38732a 100644 --- a/packages/web/localizations/gu.json +++ b/packages/web/localizations/gu.json @@ -405,6 +405,7 @@ "txTimedOutError": "વ્યવહારનો સમય સમાપ્ત થયો. કૃપા કરીને ફરી પ્રયાસ કરો.", "insufficientFee": "વ્યવહાર શુલ્ક માટે અપર્યાપ્ત બેલેન્સ. ચાલુ રાખવા માટે કૃપા કરીને ભંડોળ ઉમેરો.", "noData": "કોઈ ડેટા નથી", + "noOrderbook": "જોડી માટે કોઈ ઓર્ડરબુક નથી", "uhOhSomethingWentWrong": "ઓહ, કંઈક ખોટું થયું", "sorryForTheInconvenience": "અસુવીધી બદલ માફી. પછીથી ફરી પ્રયત્ન કરો.", "startAgain": "ફરી શરૂ કરો" @@ -832,9 +833,11 @@ "MAX": "MAX", "minimumSlippage": "સ્લિપેજ પછી પ્રાપ્ત ન્યૂનતમ ( {slippage} )", "pool": "પૂલ # {id}", - "priceImpact": "ભાવની અસર", + "priceImpact": "બજારની અસર", "routerTooltipFee": "ફી", "routerTooltipSpreadFactor": "સ્પ્રેડ ફેક્ટર", + "showDetails": "વિગતો બતાવો", + "hideDetails": "છુપાવો", "continueAnyway": "કોઈપણ રીતે ચાલુ રાખો", "warning": { "exceedsSpendLimit": "આ સ્વેપ 1-ક્લિક ટ્રેડિંગ માટે તમારી બાકીની ખર્ચ મર્યાદાને ઓળંગે છે.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "જૂની", "newer": "નવું" + }, + "limitOrders": { + "reviewOrder": "રિવ્યૂ ઓર્ડર", + "tradeDetails": "વેપારની વિગતો", + "marketOrder": { + "title": "માર્કેટ ઓર્ડર", + "description": { + "buy": "શ્રેષ્ઠ ઉપલબ્ધ ભાવે તરત જ ખરીદો", + "sell": "શ્રેષ્ઠ ઉપલબ્ધ ભાવે તરત જ વેચો" + } + }, + "limitOrder": { + "title": "મર્યાદા ઓર્ડર", + "description": { + "buy": "જ્યારે {denom} કિંમત ઘટે ત્યારે ખરીદો", + "sell": "જ્યારે {denom} કિંમત વધે ત્યારે વેચો", + "disabled": "હાલમાં {denom} / {quoteDenom} માટે ઉપલબ્ધ નથી" + } + }, + "recurringOrder": { + "title": "રિકરિંગ ઓર્ડર", + "description": "{action} સમયાંતરે સરેરાશ કિંમતે" + }, + "aboveMarket": { + "title": "બજાર કિંમત ઉપર મર્યાદા કિંમત", + "description": "જો તમે આગળ વધો છો, તો તમારા ઓર્ડરની બજાર કિંમત પર બજાર ઓર્ડર તરીકે પ્રક્રિયા કરવામાં આવશે." + }, + "belowMarket": { + "title": "બજાર કિંમત નીચે કિંમત મર્યાદા", + "description": "જો તમે આગળ વધો છો, તો તમારા ઓર્ડરની બજાર કિંમત પર બજાર ઓર્ડર તરીકે પ્રક્રિયા કરવામાં આવશે." + }, + "enterAnAmountTo": "માટે રકમ દાખલ કરો", + "sell": "વેચો", + "insufficientFunds": "અપૂરતું ભંડોળ", + "addFunds": "ભંડોળ ઉમેરો", + "watchOut": "ધ્યાન રાખો! વ્હેલ ઇનકમિંગ", + "selectAnAssetTo": { + "buy": "ખરીદવા માટે સંપત્તિ પસંદ કરો", + "sell": "વેચવા માટે સંપત્તિ પસંદ કરો" + }, + "payWith": "સાથે ચૂકવો", + "receive": "પ્રાપ્ત કરો", + "swapFromAnotherAsset": "અન્ય સંપત્તિમાંથી સ્વેપ કરો", + "connectYourWallet": "તમારું વૉલેટ કનેક્ટ કરો", + "toSeeYourBalances": "તમારા બેલેન્સ જોવા માટે", + "searchAssets": "સંપત્તિ શોધો", + "marketPrice": "બજાર કિંમત", + "below": "નીચે", + "above": "ઉપર", + "currentPrice": "વર્તમાન ભાવ", + "whenDenomPriceIs": "જ્યારે {denom} કિંમત હોય છે", + "estimating": "અંદાજ", + "totalFeesWhenFilled": "ભરવામાં આવે ત્યારે કુલ ફી", + "at": "ખાતે", + "value": "મૂલ્ય", + "estimatedFees": "અંદાજિત ફી", + "totalEstimatedFees": "કુલ અંદાજિત ફી", + "receiveMin": "ન્યૂનતમ પ્રાપ્ત કરો", + "receiveAsset": "સંપત્તિ પ્રાપ્ત કરો", + "orderType": "ઓર્ડરનો પ્રકાર", + "limitPrice": "મર્યાદા કિંમત", + "moreDetails": "વધુ વિગતો", + "confirm": "પુષ્ટિ કરો", + "expectedRate": "અપેક્ષિત દર", + "market": "બજાર", + "limit": "મર્યાદા", + "increases": "વધે છે", + "decreases": "ઘટે છે", + "estimatingFees": "અંદાજિત ફી", + "fees": "ફી", + "receiveEstimated": "ઓછામાં ઓછું પ્રાપ્ત કરો", + "swapRoute": "ઓર્ડર માર્ગ", + "trade": "વેપાર", + "swapToAnotherAsset": "અન્ય સંપત્તિમાં સ્વેપ કરો", + "openOrders": "ઓર્ડર ખોલો", + "invalidPrice": "અમાન્ય કિંમત", + "unavailable": "{denom} માટે અનુપલબ્ધ" + }, + "orderHistory": { + "orders": "ઓર્ડર", + "history": "ઇતિહાસ" } } diff --git a/packages/web/localizations/hi.json b/packages/web/localizations/hi.json index 799947bf80..c24157454b 100644 --- a/packages/web/localizations/hi.json +++ b/packages/web/localizations/hi.json @@ -405,6 +405,7 @@ "txTimedOutError": "लेन-देन का समय समाप्त हो गया. कृपया फिर से कोशिश करें।", "insufficientFee": "लेन-देन शुल्क के लिए अपर्याप्त शेष. कृपया जारी रखने के लिए धनराशि जोड़ें।", "noData": "कोई डेटा नहीं", + "noOrderbook": "जोड़ी के लिए कोई ऑर्डरबुक नहीं", "uhOhSomethingWentWrong": "ओह ओह, कुछ ग़लत हो गया", "sorryForTheInconvenience": "असुविधा के लिए खेद है। कृपया बाद में पुनः प्रयास करें।", "startAgain": "फिर से शुरू करें" @@ -832,9 +833,11 @@ "MAX": "अधिकतम", "minimumSlippage": "स्लिपेज के बाद प्राप्त न्यूनतम राशि ( {slippage} )", "pool": "पूल # {id}", - "priceImpact": "मूल्य प्रभाव", + "priceImpact": "बाजार प्रभाव", "routerTooltipFee": "शुल्क", "routerTooltipSpreadFactor": "प्रसार कारक", + "showDetails": "प्रदर्शन का विवरण", + "hideDetails": "छिपाना", "continueAnyway": "फिर भी जारी रखें", "warning": { "exceedsSpendLimit": "यह स्वैप 1-क्लिक ट्रेडिंग के लिए आपकी शेष खर्च सीमा से अधिक है।", @@ -1211,5 +1214,86 @@ "pagination": { "older": "पुराने", "newer": "नई" + }, + "limitOrders": { + "reviewOrder": "अादेश का पुनः निरिक्षण", + "tradeDetails": "व्यापार विवरण", + "marketOrder": { + "title": "बाजार आदेश", + "description": { + "buy": "सर्वोत्तम उपलब्ध मूल्य पर तुरंत खरीदें", + "sell": "सर्वोत्तम उपलब्ध मूल्य पर तुरंत बेचें" + } + }, + "limitOrder": { + "title": "सीमा आदेश", + "description": { + "buy": "जब {denom} कीमत घट जाए तो खरीदें", + "sell": "जब {denom} कीमत बढ़ जाए तो बेच दें", + "disabled": "वर्तमान में {denom} / {quoteDenom} के लिए उपलब्ध नहीं है" + } + }, + "recurringOrder": { + "title": "आवर्ती आदेश", + "description": "{action} समय के साथ औसत कीमत पर" + }, + "aboveMarket": { + "title": "सीमा मूल्य बाजार मूल्य से ऊपर", + "description": "यदि आप आगे बढ़ते हैं, तो आपके ऑर्डर को बाजार मूल्य पर बाजार ऑर्डर के रूप में संसाधित किया जाएगा।" + }, + "belowMarket": { + "title": "सीमा मूल्य बाजार मूल्य से नीचे", + "description": "यदि आप आगे बढ़ते हैं, तो आपके ऑर्डर को बाजार मूल्य पर बाजार ऑर्डर के रूप में संसाधित किया जाएगा।" + }, + "enterAnAmountTo": "राशि दर्ज करें", + "sell": "बेचना", + "insufficientFunds": "अपर्याप्त कोष", + "addFunds": "धन जोड़ें", + "watchOut": "सावधान! व्हेल आ रही है", + "selectAnAssetTo": { + "buy": "खरीदने के लिए एक परिसंपत्ति का चयन करें", + "sell": "बेचने के लिए एक परिसंपत्ति का चयन करें" + }, + "payWith": "के साथ भुगतान करें", + "receive": "प्राप्त करें", + "swapFromAnotherAsset": "किसी अन्य परिसंपत्ति से स्वैप करें", + "connectYourWallet": "अपना वॉलेट कनेक्ट करें", + "toSeeYourBalances": "अपना शेष देखने के लिए", + "searchAssets": "संपत्ति खोजें", + "marketPrice": "बाजार कीमत", + "below": "नीचे", + "above": "ऊपर", + "currentPrice": "मौजूदा कीमत", + "whenDenomPriceIs": "जब {denom} कीमत है", + "estimating": "आकलन", + "totalFeesWhenFilled": "कुल शुल्क जब भरा गया", + "at": "पर", + "value": "कीमत", + "estimatedFees": "अनुमानित शुल्क", + "totalEstimatedFees": "कुल अनुमानित शुल्क", + "receiveMin": "न्यूनतम प्राप्त करें", + "receiveAsset": "संपत्ति प्राप्त करें", + "orderType": "आदेश प्रकार", + "limitPrice": "सीमा मूल्य", + "moreDetails": "अधिक जानकारी", + "confirm": "पुष्टि करना", + "expectedRate": "अपेक्षित दर", + "market": "बाज़ार", + "limit": "आप LIMIT", + "increases": "बढ़ती है", + "decreases": "कम हो जाती है", + "estimatingFees": "शुल्क का अनुमान लगाना", + "fees": "फीस", + "receiveEstimated": "कम से कम प्राप्त करें", + "swapRoute": "आदेश मार्ग", + "trade": "व्यापार", + "swapToAnotherAsset": "किसी अन्य परिसंपत्ति में स्वैप करें", + "openOrders": "खुले आदेश", + "invalidPrice": "अमान्य मूल्य", + "unavailable": "{denom} के लिए अनुपलब्ध" + }, + "orderHistory": { + "orders": "आदेश", + "history": "इतिहास" } } diff --git a/packages/web/localizations/ja.json b/packages/web/localizations/ja.json index 87475531f1..30552d61a1 100644 --- a/packages/web/localizations/ja.json +++ b/packages/web/localizations/ja.json @@ -405,6 +405,7 @@ "txTimedOutError": "トランザクションがタイムアウトしました。再試行してください。", "insufficientFee": "取引手数料の残高が不足しています。続行するには資金を追加してください。", "noData": "データなし", + "noOrderbook": "ペアの注文書はありません", "uhOhSomethingWentWrong": "ああ、何か問題が発生しました", "sorryForTheInconvenience": "ご不便をおかけして申し訳ございません。しばらくしてからもう一度お試しください。", "startAgain": "再開する" @@ -832,9 +833,11 @@ "MAX": "マックス", "minimumSlippage": "スリッページ後に受信した最小値 ( {slippage} )", "pool": "プール番号{id}", - "priceImpact": "価格への影響", + "priceImpact": "市場への影響", "routerTooltipFee": "手数料", "routerTooltipSpreadFactor": "スプレッドファクター", + "showDetails": "詳細を表示", + "hideDetails": "隠れる", "continueAnyway": "とにかく続けます", "warning": { "exceedsSpendLimit": "このスワップは、1-Click 取引の残りの支出制限を超えています。", @@ -1211,5 +1214,86 @@ "pagination": { "older": "古い", "newer": "新しい" + }, + "limitOrders": { + "reviewOrder": "注文の確認", + "tradeDetails": "取引の詳細", + "marketOrder": { + "title": "成行注文", + "description": { + "buy": "最安価格で今すぐご購入ください", + "sell": "最高価格ですぐに販売" + } + }, + "limitOrder": { + "title": "指値注文", + "description": { + "buy": "{denom}価格が下がったら購入する", + "sell": "{denom}価格が上昇したら売る", + "disabled": "現在{denom} / {quoteDenom}ではご利用いただけません" + } + }, + "recurringOrder": { + "title": "定期注文", + "description": "{action}時間の経過に伴う平均価格" + }, + "aboveMarket": { + "title": "市場価格を上回る制限価格", + "description": "続行すると、注文は市場価格での成行注文として処理されます。" + }, + "belowMarket": { + "title": "市場価格以下の制限価格", + "description": "続行すると、注文は市場価格での成行注文として処理されます。" + }, + "enterAnAmountTo": "金額を入力してください", + "sell": "売る", + "insufficientFunds": "残高不足", + "addFunds": "資金を追加", + "watchOut": "気をつけろ!クジラが来る", + "selectAnAssetTo": { + "buy": "購入する資産を選択", + "sell": "売却する資産を選択する" + }, + "payWith": "お支払い方法", + "receive": "受け取る", + "swapFromAnotherAsset": "別の資産からのスワップ", + "connectYourWallet": "ウォレットを接続する", + "toSeeYourBalances": "残高を確認する", + "searchAssets": "アセットを検索", + "marketPrice": "市場価格", + "below": "下に", + "above": "その上", + "currentPrice": "現在の価格", + "whenDenomPriceIs": "{denom}価格が", + "estimating": "見積り", + "totalFeesWhenFilled": "記入時の合計手数料", + "at": "で", + "value": "価値", + "estimatedFees": "推定料金", + "totalEstimatedFees": "合計見積料金", + "receiveMin": "最低限受け取る", + "receiveAsset": "資産を受け取る", + "orderType": "注文タイプ", + "limitPrice": "制限価格", + "moreDetails": "詳細情報", + "confirm": "確認する", + "expectedRate": "予想レート", + "market": "市場", + "limit": "制限", + "increases": "増加する", + "decreases": "減少する", + "estimatingFees": "料金の見積り", + "fees": "手数料", + "receiveEstimated": "少なくとも受け取る", + "swapRoute": "注文ルート", + "trade": "貿易", + "swapToAnotherAsset": "別の資産に交換する", + "openOrders": "オープン注文", + "invalidPrice": "無効な価格", + "unavailable": "{denom}では利用できません" + }, + "orderHistory": { + "orders": "注文", + "history": "歴史" } } diff --git a/packages/web/localizations/ko.json b/packages/web/localizations/ko.json index ca8ccff352..e76742ee29 100644 --- a/packages/web/localizations/ko.json +++ b/packages/web/localizations/ko.json @@ -405,6 +405,7 @@ "txTimedOutError": "거래 시간이 초과되었습니다. 다시 시도해 주세요.", "insufficientFee": "거래 수수료 잔액이 부족합니다. 계속하려면 자금을 추가하세요.", "noData": "데이터 없음", + "noOrderbook": "페어 주문서 없음", "uhOhSomethingWentWrong": "아, 뭔가 잘못됐어", "sorryForTheInconvenience": "불편을 드려 죄송합니다. 나중에 다시 시도 해주십시오.", "startAgain": "다시 시작" @@ -832,9 +833,11 @@ "MAX": "최대", "minimumSlippage": "슬리피지 이후 받을 최소수량 ({slippage})", "pool": "풀 #{id}", - "priceImpact": "가격 변동", + "priceImpact": "시장 영향", "routerTooltipFee": "회비", "routerTooltipSpreadFactor": "스프레드 팩터", + "showDetails": "세부정보 표시", + "hideDetails": "숨다", "continueAnyway": "계속 진행", "warning": { "exceedsSpendLimit": "이 교환은 1-클릭 거래에 대한 남은 지출 한도를 초과합니다.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "이전", "newer": "최신" + }, + "limitOrders": { + "reviewOrder": "주문 검토", + "tradeDetails": "거래내역", + "marketOrder": { + "title": "시장가 주문", + "description": { + "buy": "가장 좋은 가격으로 즉시 구매하세요", + "sell": "가장 좋은 가격으로 즉시 판매" + } + }, + "limitOrder": { + "title": "제한 주문", + "description": { + "buy": "{denom} 가격이 하락하면 구매하세요.", + "sell": "{denom} 가격이 상승하면 매도하세요", + "disabled": "현재는 {denom} / {quoteDenom} 에 사용할 수 없습니다." + } + }, + "recurringOrder": { + "title": "반복 주문", + "description": "시간 경과에 따른 평균 가격의 {action}" + }, + "aboveMarket": { + "title": "시장가보다 높은 가격 제한", + "description": "계속 진행하시면 귀하의 주문은 시장가로 시장가 주문으로 처리됩니다." + }, + "belowMarket": { + "title": "가격을 시장가 이하로 제한", + "description": "계속 진행하시면 귀하의 주문은 시장가로 시장가 주문으로 처리됩니다." + }, + "enterAnAmountTo": "금액을 입력하세요.", + "sell": "팔다", + "insufficientFunds": "자금 부족", + "addFunds": "자금 추가", + "watchOut": "조심해! 고래가 들어오다", + "selectAnAssetTo": { + "buy": "구매할 자산을 선택하세요", + "sell": "판매할 자산을 선택하세요" + }, + "payWith": "지불", + "receive": "받다", + "swapFromAnotherAsset": "다른 자산에서 교체", + "connectYourWallet": "지갑을 연결하세요", + "toSeeYourBalances": "잔액을 보려면", + "searchAssets": "자산 검색", + "marketPrice": "시장 가격", + "below": "아래에", + "above": "~ 위에", + "currentPrice": "현재 가격", + "whenDenomPriceIs": "{denom} 가격이 다음과 같은 경우", + "estimating": "견적", + "totalFeesWhenFilled": "충전 시 총 수수료", + "at": "~에", + "value": "값", + "estimatedFees": "예상 수수료", + "totalEstimatedFees": "총 예상 수수료", + "receiveMin": "최소 수신", + "receiveAsset": "자산 수령", + "orderType": "주문 유형", + "limitPrice": "가격 제한", + "moreDetails": "자세한 내용은", + "confirm": "확인하다", + "expectedRate": "예상비율", + "market": "시장", + "limit": "한계", + "increases": "증가하다", + "decreases": "감소하다", + "estimatingFees": "수수료 추정", + "fees": "수수료", + "receiveEstimated": "최소한 받아라", + "swapRoute": "주문 경로", + "trade": "거래", + "swapToAnotherAsset": "다른 자산으로 교체", + "openOrders": "오픈 주문", + "invalidPrice": "잘못된 가격", + "unavailable": "{denom} 에는 사용할 수 없습니다." + }, + "orderHistory": { + "orders": "명령", + "history": "역사" } } diff --git a/packages/web/localizations/pl.json b/packages/web/localizations/pl.json index df020f2f33..48d8119aed 100644 --- a/packages/web/localizations/pl.json +++ b/packages/web/localizations/pl.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Starszy", "newer": "Nowsza" + }, + "limitOrders": { + "reviewOrder": "Przegląd zamówienia", + "tradeDetails": "Szczegóły handlu", + "marketOrder": { + "title": "Porządek rynkowy", + "description": { + "buy": "Kup natychmiast po najlepszej dostępnej cenie", + "sell": "Sprzedaj natychmiast po najlepszej dostępnej cenie" + } + }, + "limitOrder": { + "title": "Zamówienie z limitem", + "description": { + "buy": "Kupuj, gdy cena {denom} spadnie", + "sell": "Sprzedawaj, gdy cena {denom} wzrośnie", + "disabled": "Obecnie niedostępne dla {denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "Zamówienie powtarzające się", + "description": "{action} po średniej cenie w czasie" + }, + "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ć", + "insufficientFunds": "Niewystarczające środki", + "addFunds": "Dodać fundusze", + "watchOut": "Uważaj! Nadchodzi wieloryb", + "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", + "estimating": "Doceniający", + "totalFeesWhenFilled": "Suma opłat po wypełnieniu", + "at": "Na", + "value": "Wartość", + "estimatedFees": "Szacunkowe opłaty", + "totalEstimatedFees": "Całkowite szacunkowe opłaty", + "receiveMin": "Otrzymaj minimum", + "receiveAsset": "Odbierz zasób", + "orderType": "Typ zamówienia", + "limitPrice": "Cena graniczna", + "moreDetails": "Więcej szczegółów", + "confirm": "Potwierdzać", + "expectedRate": "Oczekiwana stawka", + "market": "Rynek", + "limit": "Limit", + "increases": "wzrasta", + "decreases": "maleje", + "estimatingFees": "Szacowanie opłat", + "fees": "opłaty", + "receiveEstimated": "Odbierz przynajmniej", + "swapRoute": "Zamów trasę", + "trade": "Handel", + "swapToAnotherAsset": "Zamień na inny zasób", + "openOrders": "Otwarte zlecenia", + "invalidPrice": "Nieprawidłowa cena", + "unavailable": "Niedostępne dla {denom}" + }, + "orderHistory": { + "orders": "Zamówienia", + "history": "Historia" } } diff --git a/packages/web/localizations/pt-br.json b/packages/web/localizations/pt-br.json index 8ca8374897..0feffe7d5a 100644 --- a/packages/web/localizations/pt-br.json +++ b/packages/web/localizations/pt-br.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Mais velho", "newer": "Mais recente" + }, + "limitOrders": { + "reviewOrder": "Revisar pedido", + "tradeDetails": "Detalhes comerciais", + "marketOrder": { + "title": "Ordem de Mercado", + "description": { + "buy": "Compre imediatamente ao melhor preço disponível", + "sell": "Venda imediatamente ao melhor preço disponível" + } + }, + "limitOrder": { + "title": "Ordem Limitada", + "description": { + "buy": "Compre quando o preço {denom} diminuir", + "sell": "Venda quando o preço {denom} aumentar", + "disabled": "Atualmente indisponível para {denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "Pedido recorrente", + "description": "{action} pelo preço médio ao longo do tempo" + }, + "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", + "insufficientFunds": "Fundos insuficientes", + "addFunds": "Adicionar fundos", + "watchOut": "Atenção! Baleia chegando", + "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 é", + "estimating": "Estimando", + "totalFeesWhenFilled": "Taxas totais quando preenchidas", + "at": "no", + "value": "Valor", + "estimatedFees": "Taxas estimadas", + "totalEstimatedFees": "Taxas totais estimadas", + "receiveMin": "Receba o mínimo", + "receiveAsset": "Receber ativo", + "orderType": "Tipo de pedido", + "limitPrice": "Preço Limite", + "moreDetails": "Mais detalhes", + "confirm": "confirme", + "expectedRate": "Taxa esperada", + "market": "Mercado", + "limit": "Limite", + "increases": "aumenta", + "decreases": "diminui", + "estimatingFees": "Estimando taxas", + "fees": "tarifas", + "receiveEstimated": "Receba pelo menos", + "swapRoute": "Rota do pedido", + "trade": "Troca", + "swapToAnotherAsset": "Trocar por outro ativo", + "openOrders": "Pedidos em aberto", + "invalidPrice": "Preço inválido", + "unavailable": "Indisponível para {denom}" + }, + "orderHistory": { + "orders": "Pedidos", + "history": "História" } } diff --git a/packages/web/localizations/ro.json b/packages/web/localizations/ro.json index d230077f1b..85f9d4dc3b 100644 --- a/packages/web/localizations/ro.json +++ b/packages/web/localizations/ro.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Mai batran", "newer": "Mai nou" + }, + "limitOrders": { + "reviewOrder": "Verificați comanda", + "tradeDetails": "Detalii comerciale", + "marketOrder": { + "title": "Ordinul pieței", + "description": { + "buy": "Cumpărați imediat la cel mai bun preț disponibil", + "sell": "Vinde imediat la cel mai bun pret disponibil" + } + }, + "limitOrder": { + "title": "Ordin limită", + "description": { + "buy": "Cumpărați când prețul {denom} scade", + "sell": "Vindeți când prețul {denom} crește", + "disabled": "Momentan indisponibil pentru {denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "Comandă recurentă", + "description": "{action} la preț mediu în timp" + }, + "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", + "insufficientFunds": "Fonduri insuficiente", + "addFunds": "Adăuga fonduri", + "watchOut": "Ai grijă! Sosește balena", + "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", + "estimating": "Estimarea", + "totalFeesWhenFilled": "Taxele totale când sunt completate", + "at": "la", + "value": "Valoare", + "estimatedFees": "Taxe estimative", + "totalEstimatedFees": "Taxele totale estimate", + "receiveMin": "Primiți minim", + "receiveAsset": "Primiți activ", + "orderType": "Tip de comandă", + "limitPrice": "Preț limită", + "moreDetails": "Mai multe detalii", + "confirm": "A confirma", + "expectedRate": "Rata așteptată", + "market": "Piaţă", + "limit": "Limită", + "increases": "crește", + "decreases": "scade", + "estimatingFees": "Estimarea taxelor", + "fees": "taxe", + "receiveEstimated": "Primește cel puțin", + "swapRoute": "Traseul comenzii", + "trade": "Comerț", + "swapToAnotherAsset": "Schimbați cu un alt activ", + "openOrders": "Comenzi deschise", + "invalidPrice": "Preț nevalid", + "unavailable": "Indisponibil pentru {denom}" + }, + "orderHistory": { + "orders": "Comenzi", + "history": "Istorie" } } diff --git a/packages/web/localizations/ru.json b/packages/web/localizations/ru.json index d0aa9f8565..1a62aaf6ec 100644 --- a/packages/web/localizations/ru.json +++ b/packages/web/localizations/ru.json @@ -405,6 +405,7 @@ "txTimedOutError": "Время транзакции истекло. Пожалуйста, повторите попытку.", "insufficientFee": "Недостаточно средств для оплаты комиссий за транзакции. Пожалуйста, добавьте средства, чтобы продолжить.", "noData": "Нет данных", + "noOrderbook": "Нет книги заказов для пары", "uhOhSomethingWentWrong": "Ой-ой, что-то пошло не так", "sorryForTheInconvenience": "Приносим извинения за неудобства. Пожалуйста, повторите попытку позже.", "startAgain": "Начать заново" @@ -832,9 +833,11 @@ "MAX": "МАКС", "minimumSlippage": "Минимум, полученный после проскальзывания ( {slippage} )", "pool": "Пул № {id}", - "priceImpact": "Влияние на цену", + "priceImpact": "Влияние на рынок", "routerTooltipFee": "Платеж", "routerTooltipSpreadFactor": "Фактор распространения", + "showDetails": "Показать детали", + "hideDetails": "Скрывать", "continueAnyway": "Продолжай в любом случае", "warning": { "exceedsSpendLimit": "Этот обмен превышает оставшийся лимит расходов для торговли в один клик.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Старшая", "newer": "Новее" + }, + "limitOrders": { + "reviewOrder": "Просмотреть заказ", + "tradeDetails": "Детали торговли", + "marketOrder": { + "title": "Рыночный ордер", + "description": { + "buy": "Купите немедленно по лучшей доступной цене", + "sell": "Продать немедленно по лучшей доступной цене" + } + }, + "limitOrder": { + "title": "Лимитный ордер", + "description": { + "buy": "Покупайте, когда цена {denom} снижается", + "sell": "Продавайте, когда цена {denom} возрастает", + "disabled": "В настоящее время недоступно для {denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "Повторяющийся заказ", + "description": "{action} по средней цене с течением времени" + }, + "aboveMarket": { + "title": "Лимитная цена выше рыночной цены", + "description": "Если вы продолжите, ваш заказ будет обработан как рыночный ордер по рыночной цене." + }, + "belowMarket": { + "title": "Лимитная цена ниже рыночной цены", + "description": "Если вы продолжите, ваш заказ будет обработан как рыночный ордер по рыночной цене." + }, + "enterAnAmountTo": "Введите сумму для", + "sell": "Продавать", + "insufficientFunds": "Недостаточно средств", + "addFunds": "Добавить средства", + "watchOut": "Осторожно! Кит приближается", + "selectAnAssetTo": { + "buy": "Выберите актив для покупки", + "sell": "Выберите актив для продажи" + }, + "payWith": "Оплатить с", + "receive": "Получать", + "swapFromAnotherAsset": "Обмен с другого актива", + "connectYourWallet": "Подключите свой кошелек", + "toSeeYourBalances": "чтобы увидеть свой баланс", + "searchAssets": "Поиск активов", + "marketPrice": "рыночная цена", + "below": "ниже", + "above": "выше", + "currentPrice": "текущая цена", + "whenDenomPriceIs": "Когда цена {denom} равна", + "estimating": "Оценка", + "totalFeesWhenFilled": "Общая сумма сборов при заполнении", + "at": "в", + "value": "Ценить", + "estimatedFees": "Ориентировочные сборы", + "totalEstimatedFees": "Общая ориентировочная стоимость", + "receiveMin": "Получите минимум", + "receiveAsset": "Получить актив", + "orderType": "Тип заказа", + "limitPrice": "Лимитная цена", + "moreDetails": "Подробнее", + "confirm": "Подтверждать", + "expectedRate": "Ожидаемая ставка", + "market": "Рынок", + "limit": "Лимит", + "increases": "увеличивается", + "decreases": "уменьшается", + "estimatingFees": "Оценка сборов", + "fees": "сборы", + "receiveEstimated": "Получите хотя бы", + "swapRoute": "Заказать маршрут", + "trade": "Торговля", + "swapToAnotherAsset": "Обмен на другой актив", + "openOrders": "Открытые ордера", + "invalidPrice": "Неверная цена", + "unavailable": "Недоступно для {denom}" + }, + "orderHistory": { + "orders": "Заказы", + "history": "История" } } diff --git a/packages/web/localizations/tr.json b/packages/web/localizations/tr.json index 75d88ee8c7..2bf150cdae 100644 --- a/packages/web/localizations/tr.json +++ b/packages/web/localizations/tr.json @@ -405,6 +405,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" @@ -832,9 +833,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.", @@ -1211,5 +1214,86 @@ "pagination": { "older": "Daha eski", "newer": "Daha yeni" + }, + "limitOrders": { + "reviewOrder": "Siparişi İncele", + "tradeDetails": "Ticaret Detayları", + "marketOrder": { + "title": "Market siparişi", + "description": { + "buy": "Mevcut en iyi fiyatla hemen satın alın", + "sell": "Mümkün olan en iyi fiyatla hemen satış yapın" + } + }, + "limitOrder": { + "title": "Limit Emri", + "description": { + "buy": "{denom} fiyatı düştüğünde satın alın", + "sell": "{denom} fiyatı arttığında sat", + "disabled": "Şu anda {denom} / {quoteDenom} için kullanılamıyor" + } + }, + "recurringOrder": { + "title": "Yinelenen Sipariş", + "description": "Zaman içindeki ortalama fiyata {action}" + }, + "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", + "insufficientFunds": "Yetersiz bakiye", + "addFunds": "Fon Ekle", + "watchOut": "Dikkat! Balina geliyor", + "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", + "estimating": "Tahmin etme", + "totalFeesWhenFilled": "Doldurulduğunda toplam ücretler", + "at": "en", + "value": "Değer", + "estimatedFees": "Tahmini ücretler", + "totalEstimatedFees": "Toplam Tahmini Ücretler", + "receiveMin": "Minimum alma", + "receiveAsset": "Varlık al", + "orderType": "Sipariş türü", + "limitPrice": "Limit Fiyatı", + "moreDetails": "Daha fazla detay", + "confirm": "Onaylamak", + "expectedRate": "Beklenen oran", + "market": "Pazar", + "limit": "Sınır", + "increases": "artışlar", + "decreases": "azalır", + "estimatingFees": "Ücretlerin tahmin edilmesi", + "fees": "ücretler", + "receiveEstimated": "En azından al", + "swapRoute": "Sipariş rotası", + "trade": "Ticaret", + "swapToAnotherAsset": "Başka bir varlığa geç", + "openOrders": "Açık siparişler", + "invalidPrice": "Geçersiz fiyat", + "unavailable": "{denom} için kullanılamıyor" + }, + "orderHistory": { + "orders": "Emirler", + "history": "Tarih" } } diff --git a/packages/web/localizations/zh-cn.json b/packages/web/localizations/zh-cn.json index 05ba3d5a8c..e54562348c 100644 --- a/packages/web/localizations/zh-cn.json +++ b/packages/web/localizations/zh-cn.json @@ -405,6 +405,7 @@ "txTimedOutError": "交易超时。请重试。", "insufficientFee": "余额不足,无法支付交易费用。请添加资金以继续。", "noData": "没有数据", + "noOrderbook": "暂无订单簿", "uhOhSomethingWentWrong": "哦,出问题了", "sorryForTheInconvenience": "抱歉造成不便。请稍后重试。", "startAgain": "重新开始" @@ -832,9 +833,11 @@ "MAX": "最大", "minimumSlippage": "扣除滑点后最少获得 ({slippage})", "pool": "资金池 #{id}", - "priceImpact": "价格影响", + "priceImpact": "市场影响", "routerTooltipFee": "费用", "routerTooltipSpreadFactor": "扩频因子", + "showDetails": "显示详细资料", + "hideDetails": "隐藏", "continueAnyway": "无论如何继续", "warning": { "exceedsSpendLimit": "此交换超出了您一键交易的剩余支出限额。", @@ -1211,5 +1214,86 @@ "pagination": { "older": "较旧", "newer": "较新" + }, + "limitOrders": { + "reviewOrder": "查看订单", + "tradeDetails": "交易详情", + "marketOrder": { + "title": "市价订单", + "description": { + "buy": "立即以最佳价格购买", + "sell": "立即以最佳价格出售" + } + }, + "limitOrder": { + "title": "限价订单", + "description": { + "buy": "当{denom}价格下降时买入", + "sell": "当{denom}价格上涨时卖出", + "disabled": "目前不适用于{denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "重复订单", + "description": "{action}随时间的平均价格" + }, + "aboveMarket": { + "title": "限制价格高于市场价格", + "description": "如果您继续,您的订单将按市场价格作为市价订单处理。" + }, + "belowMarket": { + "title": "限制价格低于市场价格", + "description": "如果您继续,您的订单将按市场价格作为市价订单处理。" + }, + "enterAnAmountTo": "输入金额", + "sell": "卖", + "insufficientFunds": "不充足的资金", + "addFunds": "增加资金", + "watchOut": "小心!鲸鱼来了", + "selectAnAssetTo": { + "buy": "选择要购买的资产", + "sell": "选择要出售的资产" + }, + "payWith": "使用。。。支付", + "receive": "收到", + "swapFromAnotherAsset": "从另一项资产交换", + "connectYourWallet": "连接你的钱包", + "toSeeYourBalances": "查看你的余额", + "searchAssets": "搜索资产", + "marketPrice": "市场价", + "below": "以下", + "above": "多于", + "currentPrice": "时价", + "whenDenomPriceIs": "当{denom}价格为", + "estimating": "估算", + "totalFeesWhenFilled": "填满时的总费用", + "at": "在", + "value": "价值", + "estimatedFees": "预估费用", + "totalEstimatedFees": "预计费用总额", + "receiveMin": "接收最低", + "receiveAsset": "接收资产", + "orderType": "订单类型", + "limitPrice": "限价", + "moreDetails": "更多细节", + "confirm": "确认", + "expectedRate": "预期利率", + "market": "市场", + "limit": "限制", + "increases": "增加", + "decreases": "减少", + "estimatingFees": "估算费用", + "fees": "费用", + "receiveEstimated": "至少收到", + "swapRoute": "订单路线", + "trade": "贸易", + "swapToAnotherAsset": "转换为其他资产", + "openOrders": "未结订单", + "invalidPrice": "价格无效", + "unavailable": "不适用于{denom}" + }, + "orderHistory": { + "orders": "命令", + "history": "历史" } } diff --git a/packages/web/localizations/zh-hk.json b/packages/web/localizations/zh-hk.json index 706b996e86..ada8d1c150 100644 --- a/packages/web/localizations/zh-hk.json +++ b/packages/web/localizations/zh-hk.json @@ -405,6 +405,7 @@ "txTimedOutError": "交易超時。請重試。", "insufficientFee": "餘額不足,無法支付交易費用。請添加資金以繼續。", "noData": "沒有數據", + "noOrderbook": "沒有配對訂單簿", "uhOhSomethingWentWrong": "呃哦,出了點問題", "sorryForTheInconvenience": "帶來不便敬請諒解。請稍後再試。", "startAgain": "重新開始" @@ -832,9 +833,11 @@ "MAX": "全部", "minimumSlippage": "({slippage}) 包括滑價({slippage})後最少能獲得", "pool": "流動性池 #{id}", - "priceImpact": "價格影響", + "priceImpact": "市場影響", "routerTooltipFee": "費", "routerTooltipSpreadFactor": "擴頻因子", + "showDetails": "顯示詳細資料", + "hideDetails": "隱藏", "continueAnyway": "無論如何繼續", "warning": { "exceedsSpendLimit": "此交換超出了您一鍵交易的剩餘支出限額。", @@ -1211,5 +1214,86 @@ "pagination": { "older": "年長的", "newer": "較新" + }, + "limitOrders": { + "reviewOrder": "查看訂單", + "tradeDetails": "交易詳情", + "marketOrder": { + "title": "市價訂單", + "description": { + "buy": "立即以最優惠的價格購買", + "sell": "立即以最優惠的價格出售" + } + }, + "limitOrder": { + "title": "限價訂單", + "description": { + "buy": "當{denom}價格下跌時購買", + "sell": "當{denom}價格上漲時出售", + "disabled": "目前不適用於{denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "重複訂單", + "description": "{action}以一段時間內的平均價格" + }, + "aboveMarket": { + "title": "限價高於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "belowMarket": { + "title": "限制價格低於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "enterAnAmountTo": "輸入金額至", + "sell": "賣", + "insufficientFunds": "不充足的資金", + "addFunds": "增加資金", + "watchOut": "小心!鯨魚來襲", + "selectAnAssetTo": { + "buy": "選擇要購買的資產", + "sell": "選擇要出售的資產" + }, + "payWith": "使用。", + "receive": "收到", + "swapFromAnotherAsset": "從另一種資產交換", + "connectYourWallet": "連接你的錢包", + "toSeeYourBalances": "查看您的餘額", + "searchAssets": "搜尋資產", + "marketPrice": "市價", + "below": "以下", + "above": "多於", + "currentPrice": "時價", + "whenDenomPriceIs": "當{denom}價格為", + "estimating": "估計", + "totalFeesWhenFilled": "填寫後的總費用", + "at": "在", + "value": "價值", + "estimatedFees": "預計費用", + "totalEstimatedFees": "預計費用總計", + "receiveMin": "接收最低", + "receiveAsset": "接收資產", + "orderType": "訂單類型", + "limitPrice": "限價", + "moreDetails": "更多細節", + "confirm": "確認", + "expectedRate": "預期利率", + "market": "市場", + "limit": "限制", + "increases": "增加", + "decreases": "減少", + "estimatingFees": "估算費用", + "fees": "費用", + "receiveEstimated": "至少收到", + "swapRoute": "訂購路線", + "trade": "貿易", + "swapToAnotherAsset": "交換到另一種資產", + "openOrders": "未結訂單", + "invalidPrice": "價格無效", + "unavailable": "不適用於{denom}" + }, + "orderHistory": { + "orders": "命令", + "history": "歷史" } } diff --git a/packages/web/localizations/zh-tw.json b/packages/web/localizations/zh-tw.json index e4bb01b833..b664282099 100644 --- a/packages/web/localizations/zh-tw.json +++ b/packages/web/localizations/zh-tw.json @@ -405,6 +405,7 @@ "txTimedOutError": "交易超時。請重試。", "insufficientFee": "餘額不足,無法支付交易費用。請添加資金以繼續。", "noData": "沒有數據", + "noOrderbook": "沒有配對訂單簿", "uhOhSomethingWentWrong": "呃哦,出了點問題", "sorryForTheInconvenience": "帶來不便敬請諒解。請稍後再試。", "startAgain": "重新開始" @@ -832,9 +833,11 @@ "MAX": "全部", "minimumSlippage": "({slippage}) 包括滑價({slippage})後最少能獲得", "pool": "流動性池 #{id}", - "priceImpact": "價格影響", + "priceImpact": "市場影響", "routerTooltipFee": "費", "routerTooltipSpreadFactor": "擴頻因子", + "showDetails": "顯示詳細資料", + "hideDetails": "隱藏", "continueAnyway": "無論如何繼續", "warning": { "exceedsSpendLimit": "此交換超出了您一鍵交易的剩餘支出限額。", @@ -1211,5 +1214,86 @@ "pagination": { "older": "年長的", "newer": "較新" + }, + "limitOrders": { + "reviewOrder": "查看訂單", + "tradeDetails": "交易詳情", + "marketOrder": { + "title": "市價訂單", + "description": { + "buy": "立即以最優惠的價格購買", + "sell": "立即以最優惠的價格出售" + } + }, + "limitOrder": { + "title": "限價訂單", + "description": { + "buy": "當{denom}價格下跌時購買", + "sell": "當{denom}價格上漲時出售", + "disabled": "目前不適用於{denom} / {quoteDenom}" + } + }, + "recurringOrder": { + "title": "重複訂單", + "description": "{action}以一段時間內的平均價格" + }, + "aboveMarket": { + "title": "限價高於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "belowMarket": { + "title": "限制價格低於市場價格", + "description": "如果您繼續,您的訂單將作為市價訂單以市場價格處理。" + }, + "enterAnAmountTo": "輸入金額至", + "sell": "賣", + "insufficientFunds": "不充足的資金", + "addFunds": "增加資金", + "watchOut": "小心!鯨魚來襲", + "selectAnAssetTo": { + "buy": "選擇要購買的資產", + "sell": "選擇要出售的資產" + }, + "payWith": "使用。", + "receive": "收到", + "swapFromAnotherAsset": "從另一種資產交換", + "connectYourWallet": "連接你的錢包", + "toSeeYourBalances": "查看您的餘額", + "searchAssets": "搜尋資產", + "marketPrice": "市價", + "below": "以下", + "above": "多於", + "currentPrice": "時價", + "whenDenomPriceIs": "當{denom}價格為", + "estimating": "估計", + "totalFeesWhenFilled": "填寫後的總費用", + "at": "在", + "value": "價值", + "estimatedFees": "預計費用", + "totalEstimatedFees": "預計費用總計", + "receiveMin": "接收最低", + "receiveAsset": "接收資產", + "orderType": "訂單類型", + "limitPrice": "限價", + "moreDetails": "更多細節", + "confirm": "確認", + "expectedRate": "預期利率", + "market": "市場", + "limit": "限制", + "increases": "增加", + "decreases": "減少", + "estimatingFees": "估算費用", + "fees": "費用", + "receiveEstimated": "至少收到", + "swapRoute": "訂購路線", + "trade": "貿易", + "swapToAnotherAsset": "交換到另一種資產", + "openOrders": "未結訂單", + "invalidPrice": "價格無效", + "unavailable": "不適用於{denom}" + }, + "orderHistory": { + "orders": "命令", + "history": "歷史" } } diff --git a/packages/web/modals/add-funds.tsx b/packages/web/modals/add-funds.tsx new file mode 100644 index 0000000000..9d481a5df5 --- /dev/null +++ b/packages/web/modals/add-funds.tsx @@ -0,0 +1,358 @@ +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" ? ( + + You need {" "} + funds on Osmosis to buy assets. + Choose an option to continue. + + ) : ( + + You don’t have any {fromAsset?.coinName} funds on Osmosis to trade + with. Choose an option to continue. + + )} +
+
+ {from === "buy" ? ( + + ) : ( + + )} + {from === "buy" ? ( + + ) : ( + + )} + {from === "buy" ? ( + + ) : ( + + )} +
+
+ +
+
+
+ ); +} + +function StableCoinsInfoTooltip() { + return ( + + +
+ What is a stablecoin? + + 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. + +
+ + + + + + + + + + + +
+ } + className="text-wosmongton-300" + > + <>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..71c7ea35a2 --- /dev/null +++ b/packages/web/modals/review-order.tsx @@ -0,0 +1,465 @@ +import { 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 { useCallback, useMemo, useState } from "react"; +import AutosizeInput from "react-input-autosize"; + +import { Icon } from "~/components/assets"; +import { Button } from "~/components/buttons"; +import { RecapRow } from "~/components/ui/recap-row"; +import { Skeleton } from "~/components/ui/skeleton"; +import { EventName } from "~/config/analytics-events"; +import { useAmplitudeAnalytics, 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; + swapState: ReturnType; + confirmAction: () => void; + isConfirmationDisabled: boolean; + slippageConfig?: ObservableSlippageConfig; + outAmountLessSlippage?: IntPretty; + outFiatAmountLessSlippage?: PricePretty; + outputDifference?: RatePretty; + showOutputDifferenceWarning?: boolean; + orderType?: "market" | "limit"; + percentAdjusted?: Dec; + limitOrderDirection?: "bid" | "ask"; + limitPriceFiat?: PricePretty; + baseDenom?: string; + title: string; +} + +export function ReviewOrder({ + isOpen, + onClose, + swapState, + confirmAction, + isConfirmationDisabled, + slippageConfig, + outAmountLessSlippage, + outFiatAmountLessSlippage, + outputDifference, + showOutputDifferenceWarning, + orderType = "market", + percentAdjusted, + limitOrderDirection, + limitPriceFiat, + baseDenom, + title, +}: ReviewOrderProps) { + const { t } = useTranslation(); + // const { isMobile } = useWindowSize(); + const { logEvent } = useAmplitudeAnalytics(); + const [manualSlippage, setManualSlippage] = useState(""); + const [isEditingSlippage, setIsEditingSlippage] = useState(false); + let isManualSlippageTooHigh = useMemo( + () => +manualSlippage >= 1, + [manualSlippage] + ); + + 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] + ); + + return ( + +
+
+
{title}
+ +
+
+ {orderType === "limit" && ( +
+
+
+ {limitOrderDirection === "bid" ? ( + + + + ) : ( + + )} +
+ + If {baseDenom} price reaches{" "} + {limitPriceFiat && + formatPretty( + limitPriceFiat, + getPriceExtendedFormatOptions(limitPriceFiat.toDec()) + )} + + {percentAdjusted && ( +
+
+ +
+ + {formatPretty(percentAdjusted.mul(new Dec(100)).abs())}% + +
+ )} +
+
+ )} +
+
+
+ {swapState.fromAsset && ( + {`${swapState.fromAsset.coinDenom} + )} +
+

{t("limitOrders.sell")}

+ {swapState.inAmountInput.amount && ( + + {formatPretty(swapState.inAmountInput.amount)} + + )} +
+
+
+

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

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

{t("portfolio.buy")}

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

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

+
+
+
+
+
+ + {orderType === "limit" + ? t("limitOrders.limit") + : t("limitOrders.market")} + + } + /> + {slippageConfig && orderType === "market" && ( +
+ +
+ { + slippageConfig?.setIsManualSlippage(true); + setIsEditingSlippage(true); + }} + onBlur={() => { + if (isManualSlippageTooHigh) { + handleManualSlippageChange( + (+manualSlippage).toString().split("")[0] + ); + } + setIsEditingSlippage(false); + }} + // autoFocus={slippageConfig?.isManualSlippage} + onChange={(e) => { + handleManualSlippageChange(e.target.value); + + logEvent([ + EventName.Swap.slippageToleranceSet, + { + fromToken: swapState?.fromAsset?.coinDenom, + toToken: swapState?.toAsset?.coinDenom, + // isOnHome: page === "Swap Page", + isOnHome: true, + percentage: + slippageConfig?.slippage.toString(), + page: "Swap Page", + }, + ]); + }} + /> + {manualSlippage !== "" && ( + + % + + )} +
+
+ } + /> + {isManualSlippageTooHigh && ( +
+ +
+ + Your trade may result in significant loss of value + + + A lower slippage tolerance is recommended. + +
+
+ )} +
+ )} + {orderType === "market" && ( +
+ )} + {orderType === "market" ? ( + + {outAmountLessSlippage && + outFiatAmountLessSlippage && + swapState.toAsset && ( + + {formatPretty(outAmountLessSlippage, { + maxDecimals: 6, + })}{" "} + {swapState.toAsset.coinDenom} + + )}{" "} + {outFiatAmountLessSlippage && ( + + (~ + {formatPretty(outFiatAmountLessSlippage, { + ...getPriceExtendedFormatOptions( + outFiatAmountLessSlippage.toDec() + ), + })} + ) + + )} + + } + /> + ) : ( + + {t("transfer.free")} + + } + /> + )} + + {!swapState.isLoadingNetworkFee ? ( + + ~ + {swapState.networkFee?.gasUsdValueToPay && + formatPretty(swapState.networkFee?.gasUsdValueToPay, { + maxDecimals: 2, + })} + + ) : ( + + )} + + } + /> + {/*
+ + {t("limitOrders.moreDetails")} + + + {t("swap.autoRouterToggle.show")} + +
*/} +
+ {/*
+ + Disclaimer lorem ipsum.{" "} + Learn more + +
*/} +
+ +
+
+
+
+ + ); +} 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..7dbae06eb7 --- /dev/null +++ b/packages/web/modals/token-select-modal-limit.tsx @@ -0,0 +1,513 @@ +import { 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 { + FunctionComponent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; +import { useLatest } from "react-use"; + +import { Icon } from "~/components/assets"; +import { Intersection } from "~/components/intersection"; +import { Spinner } from "~/components/loaders"; +import { + useFilteredData, + useTranslation, + useWalletSelect, + useWindowKeyActions, +} from "~/hooks"; +import { useConst } from "~/hooks/use-const"; +import { useDraggableScroll } from "~/hooks/use-draggable-scroll"; +import { useKeyActions } from "~/hooks/use-key-actions"; +import { useStateRef } from "~/hooks/use-state-ref"; +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"; + +const dataAttributeName = "data-token-id"; + +function getTokenItemId(uniqueId: string, index: number) { + return `token-selector-item-${uniqueId}-${index}`; +} + +function getTokenElement(uniqueId: string, index: number) { + return document.querySelector( + `[${dataAttributeName}=${getTokenItemId(uniqueId, index)}]` + ); +} + +function getAllTokenElements() { + return document.querySelectorAll(`[${dataAttributeName}]`); +} + +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 uniqueId = useConst(() => + Math.random().toString(36).substring(2, 9) + ); + const recommendedAssets = useRecommendedAssets(); + + const isWalletConnected = accountStore.getWallet( + accountStore.osmosisChainId + )?.isWalletConnected; + + const [ + keyboardSelectedIndex, + setKeyboardSelectedIndex, + keyboardSelectedIndexRef, + ] = useStateRef(0); + + const [_isRequestingClose, setIsRequestingClose] = useState(false); + const [confirmUnverifiedAssetDenom, setConfirmUnverifiedAssetDenom] = + useState(null); + + const showUnverifiedAssetsSetting = + userSettings.getUserSettingById( + "unverified-assets" + ); + const shouldShowUnverifiedAssets = + showUnverifiedAssetsSetting?.state.showUnverifiedAssets; + + const assetsRef = useLatest(selectableAssets); + + const searchBoxRef = useRef(null); + const quickSelectRef = useRef(null); + + const { onMouseDown: onMouseDownQuickSelect } = + useDraggableScroll(quickSelectRef); + + const onClose = () => { + setIsRequestingClose(true); + setKeyboardSelectedIndex(0); + onCloseProp?.(); + }; + + 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); + }; + + useWindowKeyActions({ + Escape: onClose, + }); + + const { handleKeyDown: containerKeyDown } = useKeyActions({ + ArrowDown: () => { + setKeyboardSelectedIndex((selectedIndex) => + selectedIndex === getAllTokenElements().length - 1 + ? 0 + : selectedIndex + 1 + ); + + getTokenElement( + uniqueId, + keyboardSelectedIndexRef.current + )?.scrollIntoView({ + block: "nearest", + }); + + // Focus on search bar if user starts keyboard navigation + searchBoxRef.current?.focus(); + }, + ArrowUp: () => { + setKeyboardSelectedIndex((selectedIndex) => + selectedIndex === 0 + ? getAllTokenElements().length - 1 + : selectedIndex - 1 + ); + + getTokenElement( + uniqueId, + keyboardSelectedIndexRef.current + )?.scrollIntoView({ + block: "nearest", + }); + + // Focus on search bar if user starts keyboard navigation + searchBoxRef.current?.focus(); + }, + Enter: () => { + const asset = assetsRef.current[keyboardSelectedIndexRef.current]; + if (!asset) return; + const { coinDenom } = asset; + + onClickAsset(coinDenom); + }, + }); + + // const { handleKeyDown: searchBarKeyDown } = useKeyActions({ + // ArrowDown: (event) => { + // event.preventDefault(); + // }, + // ArrowUp: (event) => { + // event.preventDefault(); + // }, + // }); + + const [filterValue, setQuery, results] = useFilteredData( + selectableAssets, + ["coinDenom", "coinName"] + ); + + 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)} + 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 as any[]).map( + ( + { + coinDenom, + coinMinimalDenom, + coinImageUrl, + coinName, + amount, + usdValue, + isVerified, + }, + index + ) => { + return ( + + ); + } + )} + { + // 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..605f8c4d4a 100644 --- a/packages/web/modals/trade-tokens.tsx +++ b/packages/web/modals/trade-tokens.tsx @@ -1,6 +1,6 @@ import { FunctionComponent } from "react"; -import { SwapTool } from "~/components/swap-tool"; +import { AltSwapTool } from "~/components/swap-tool/alt"; import { EventPage } from "~/config"; import { useConnectWalletModalRedirect } from "~/hooks"; import { ModalBase, ModalBaseProps } from "~/modals/base"; @@ -29,9 +29,9 @@ export const TradeTokens: FunctionComponent< {...modalProps} isOpen={showModalBase && modalProps.isOpen} hideCloseButton - className="!w-fit !p-0" + className="!w-fit !bg-osmoverse-900 !px-4 !py-6" > - , selectionTest: /\/$/, diff --git a/packages/web/pages/assets/[denom].tsx b/packages/web/pages/assets/[denom].tsx index dfbd87ff1e..e1b176d22d 100644 --- a/packages/web/pages/assets/[denom].tsx +++ b/packages/web/pages/assets/[denom].tsx @@ -30,11 +30,17 @@ import { import { AssetNavigation } from "~/components/pages/asset-info-page/navigation"; import { AssetPools } from "~/components/pages/asset-info-page/pools"; import { TwitterSection } from "~/components/pages/asset-info-page/twitter"; -import { SwapTool } from "~/components/swap-tool"; +import { SwapToolProps } from "~/components/swap-tool/alt"; +import { TradeTool } from "~/components/trade-tool"; import { EventName } from "~/config"; import { AssetLists } from "~/config/generated/asset-lists"; -import { useAmplitudeAnalytics, useTranslation } from "~/hooks"; -import { useAssetInfoConfig, useFeatureFlags, useNavBar } from "~/hooks"; +import { + useAmplitudeAnalytics, + useAssetInfoConfig, + useFeatureFlags, + useNavBar, + useTranslation, +} from "~/hooks"; import { useAssetInfo } from "~/hooks/use-asset-info"; import { AssetInfoViewProvider } from "~/hooks/use-asset-info-view"; import { SUPPORTED_LANGUAGES } from "~/stores/user-settings"; @@ -80,6 +86,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, @@ -126,14 +143,7 @@ const AssetInfoView: FunctionComponent = observer( ); const SwapTool_ = ( - + ); return ( diff --git a/packages/web/pages/index.tsx b/packages/web/pages/index.tsx index 5674315226..7c3c9f3eb8 100644 --- a/packages/web/pages/index.tsx +++ b/packages/web/pages/index.tsx @@ -6,8 +6,14 @@ 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, + useTranslation, +} from "~/hooks"; import { api } from "~/utils/trpc"; export const SwapPreviousTradeKey = "swap-previous-trade"; @@ -17,6 +23,65 @@ export type PreviousTrade = { }; const Home = () => { + const featureFlags = useFeatureFlags(); + if (!featureFlags._isInitialized) return null; + return featureFlags.limitOrders ? : ; +}; + +const HomeNew = () => { + const featureFlags = useFeatureFlags(); + // const [previousTrade, setPreviousTrade] = + // useLocalStorage(SwapPreviousTradeKey); + + const { t } = useTranslation(); + + useAmplitudeAnalytics({ + onLoadEvent: [EventName.Swap.pageViewed, { isOnHome: true }], + }); + + useNavBar({ title: t("limitOrders.trade") }); + + return ( +
+ {/*
+ + + + + + +
*/} +
+
+ {featureFlags.swapsAdBanner && } + +
+
+
+ ); +}; + +const HomeV1 = () => { const featureFlags = useFeatureFlags(); const [previousTrade, setPreviousTrade] = useLocalStorage(SwapPreviousTradeKey); 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 32739c75ce..691ced9f2b 100644 --- a/packages/web/public/icons/sprite.svg +++ b/packages/web/public/icons/sprite.svg @@ -1041,7 +1041,7 @@ /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1324,5 +1357,14 @@ fill="currentColor" /> + + + + + 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 0000000000..8c6c23a13c Binary files /dev/null and b/packages/web/public/images/quote-swap-from-another-asset.png differ diff --git a/packages/web/server/api/edge-router.ts b/packages/web/server/api/edge-router.ts index f7b5e6c527..d064c68daa 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, poolsRouter, stakingRouter, transactionsRouter, @@ -15,5 +16,6 @@ export const edgeRouter = createTRPCRouter({ staking: stakingRouter, earn: earnRouter, transactions: transactionsRouter, + orderbooks: orderbookRouter, chains: chainsRouter, }); 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; +}