diff --git a/.gitignore b/.gitignore index 0186ada852..e6df1d1288 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,4 @@ yarn-error.log* *.gen.* *storybook.log .cursor -.claude \ No newline at end of file +.claude.mcp.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..498154f01c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# Project conventions for Claude + +## After every task + +**Run `yarn lint` after every task.** Fix any errors introduced by the change before considering the task complete. Pre-existing errors in unrelated files can be ignored but should be called out. + +## Tech stack + +- Monorepo with Turbo (`apps/*`, `packages/*`) +- React + TypeScript +- Emotion styled-components (NOT Tailwind) — use theme tokens from `@galacticcouncil/ui` +- React Hook Form with Zod resolvers +- TanStack Query + Router +- `Big.js` for decimal math +- `@galacticcouncil/ui` component library (Tooltip, Modal, DataTable, etc.) + +## Styling conventions + +- Use `theme.space.*`, `theme.radii.*`, `theme.text.*`, `theme.buttons.*` tokens — don't hardcode colors/spacing +- Styled components prefixed with `S` (e.g. `SLockPill`, `SEmphasis`) +- Prefer `Flex`, `Text`, `Icon` primitives from `@galacticcouncil/ui/components` + +## Patterns to follow + +- Queries with `QUERY_KEY_BLOCK_PREFIX` auto-refetch on new block — use for on-chain state +- `spotPriceQuery` for clean theoretical rates, `bestSellQuery` for trade quotes (with fees/impact) +- For new transactions, use `useTransactionsStore().createTransaction({ tx, toasts })` with i18n keys for submitted/success/error +- Buttons inside forms must have `type="button"` explicitly (otherwise they default to submit) diff --git a/apps/main/.env.production b/apps/main/.env.production index 30df85e236..e9f713d003 100644 --- a/apps/main/.env.production +++ b/apps/main/.env.production @@ -1,4 +1,4 @@ -VITE_PROVIDER_URL=wss://hydration-rpc.n.dwellir.com +VITE_PROVIDER_URL=wss://node3.lark.hydration.cloud VITE_INDEXER_URL=https://explorer.hydradx.cloud/graphql VITE_SQUID_URL=https://orca-main-aggr-indx.indexer.hydration.cloud/graphql VITE_SNOWBRIDGE_URL="https://snowbridge.squids.live/snowbridge-subsquid-polkadot@v1/api/graphql" diff --git a/apps/main/.gitignore b/apps/main/.gitignore new file mode 100644 index 0000000000..70874ba50a --- /dev/null +++ b/apps/main/.gitignore @@ -0,0 +1,3 @@ + +# TanStack Router generated cache +.tanstack/ diff --git a/apps/main/src/api/intents.ts b/apps/main/src/api/intents.ts new file mode 100644 index 0000000000..de67cdaa51 --- /dev/null +++ b/apps/main/src/api/intents.ts @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { queryOptions } from "@tanstack/react-query" + +import { TProviderContext } from "@/providers/rpcProvider" + +export const iceFeeQuery = (context: TProviderContext) => { + const { papiClient, isApiLoaded } = context + + return queryOptions({ + enabled: isApiLoaded, + staleTime: Infinity, + queryKey: ["iceFee"], + queryFn: async () => { + const unsafeApi = papiClient.getUnsafeApi() as any + try { + // Permill: parts per million (e.g. 200 = 0.02%) + const raw = unsafeApi.constants.ICE.Fee + const fee = + typeof raw === "object" ? Number(raw.value ?? raw) : Number(raw) + if (!isNaN(fee) && fee > 0) return fee + } catch (e) { + console.warn("[iceFee] ICE.Fee constant not available:", e) + } + // Fallback: 200 Permill = 0.02% (matches runtime config) + return 200 + }, + }) +} + +/** + * Runtime constant `Intent.MaxAllowedIntentDuration` — maximum + * `deadline - now` window in milliseconds that a Swap intent may have. + * Exceeding it (or sitting exactly at the boundary when the runtime's + * comparison is strict) causes the extrinsic to fail with + * `Intent: InvalidDeadline`. Cached indefinitely: changes only on + * runtime upgrade. + */ +export const maxIntentDurationQuery = (context: TProviderContext) => { + const { papiClient, isApiLoaded } = context + + return queryOptions({ + enabled: isApiLoaded, + staleTime: Infinity, + queryKey: ["maxIntentDuration"], + queryFn: async () => { + const unsafeApi = papiClient.getUnsafeApi() as any + try { + const raw = unsafeApi.constants.Intent.MaxAllowedIntentDuration + // The PAPI descriptor types it as `bigint`. Some unsafeApi + // wrappers return `{ value: bigint }` — handle both. + const ms = + typeof raw === "bigint" + ? raw + : typeof raw?.value === "bigint" + ? raw.value + : typeof raw === "number" + ? BigInt(raw) + : null + if (ms !== null && ms > 0n) return ms + } catch (e) { + console.warn( + "[maxIntentDuration] Intent.MaxAllowedIntentDuration not available:", + e, + ) + } + // Fallback: 24h (matches feat/ice-pallet runtime default). + return BigInt(24 * 60 * 60 * 1000) + }, + }) +} + +export type IntentSwapData = { + asset_in: number + asset_out: number + amount_in: bigint + amount_out: bigint + partial: boolean +} + +export type IntentDcaData = { + asset_in: number + asset_out: number + /** Per-trade input (native units). */ + amount_in: bigint + /** Per-trade expected output (native units). */ + amount_out: bigint + /** Permill (parts per million). 10_000 = 1%. */ + slippage: number + /** + * Total budget cap in native units. `undefined` means "rolling / no + * cap" — the intent runs until the account balance is depleted or + * until the intent is cancelled. + */ + budget?: bigint + /** Blocks between executions. */ + period: number +} + +export type IntentData = { + data: + | { type: "Swap"; value: IntentSwapData } + | { type: "Dca"; value: IntentDcaData } + deadline?: bigint + on_resolved?: unknown +} + +export type AccountIntent = { + id: bigint + intent: IntentData +} + +export const intentsByAccountQuery = ( + context: TProviderContext, + address: string, +) => { + const { papiClient, isApiLoaded } = context + + return queryOptions({ + enabled: isApiLoaded && !!address, + refetchInterval: 30_000, + queryKey: ["intents", "byAccount", address], + queryFn: async (): Promise => { + const unsafeApi = papiClient.getUnsafeApi() as any + + // Use the AccountIntents reverse index — keyed by (account, intentId) + let accountEntries: any[] + try { + accountEntries = await unsafeApi.query.Intent.AccountIntents.getEntries( + address, + { + at: "best", + }, + ) + } catch { + // Intent pallet not available on this chain + return [] + } + + const intentIds = accountEntries.map( + (entry: any) => entry.keyArgs[1] as bigint, + ) + + if (intentIds.length === 0) return [] + + const results = await Promise.all( + intentIds.map(async (id: bigint) => { + const intent = await unsafeApi.query.Intent.Intents.getValue(id, { + at: "best", + }) + return intent ? { id, intent: intent as IntentData } : null + }), + ) + + return results.filter((r): r is AccountIntent => r !== null) + }, + }) +} diff --git a/apps/main/src/components/DataProviderSelect/DataProviderSelect.styled.ts b/apps/main/src/components/DataProviderSelect/DataProviderSelect.styled.ts index ba3e4a6444..c6e7990a3c 100644 --- a/apps/main/src/components/DataProviderSelect/DataProviderSelect.styled.ts +++ b/apps/main/src/components/DataProviderSelect/DataProviderSelect.styled.ts @@ -15,9 +15,11 @@ export const SContainer = styled.div<{ readonly bottomPinned?: boolean }>( ({ theme, bottomPinned }) => css` display: flex; - gap: ${theme.space.base}; - padding: ${theme.space.base}; + /* 8px gap + 20px vertical padding (design tokens); horizontal stays base. */ + gap: ${theme.sizes["2xs"]}; + padding-top: ${theme.sizes.l}; padding-bottom: ${pxToRem(80)}; + padding-inline: ${theme.space.base}; ${bottomPinned ? bottomPinnedStyle(theme) diff --git a/apps/main/src/config/navigation.ts b/apps/main/src/config/navigation.ts index 68f3cc2bdd..ff69e8e177 100644 --- a/apps/main/src/config/navigation.ts +++ b/apps/main/src/config/navigation.ts @@ -27,6 +27,7 @@ export const LINKS = { swap: "/trade/swap", swapMarket: "/trade/swap/market", swapDca: "/trade/swap/dca", + swapLimit: "/trade/swap/limit", wallet: "/wallet", walletAssets: "/wallet/assets", walletTransactions: "/wallet/transactions", @@ -79,6 +80,7 @@ export const NAVIGATION: NavigationItem[] = [ icon: Repeat2Icon, children: [ { key: "swapMarket", to: LINKS.swapMarket }, + { key: "swapLimit", to: LINKS.swapLimit }, { key: "swapDca", to: LINKS.swapDca }, ], }, @@ -224,6 +226,10 @@ export const getMenuTranslations = (t: TFunction) => title: t("navigation.swapDca.title"), description: "", }, + swapLimit: { + title: t("navigation.swapLimit.title"), + description: "", + }, otc: { title: t("navigation.otc.title"), description: t("navigation.otc.description"), diff --git a/apps/main/src/config/rpc.ts b/apps/main/src/config/rpc.ts index 4e6648174c..21665b3542 100644 --- a/apps/main/src/config/rpc.ts +++ b/apps/main/src/config/rpc.ts @@ -73,6 +73,20 @@ export const SQUID_URLS: IndexerProps[] = SQUID_URLS_CONFIG.map((config) => ({ })) export const PROVIDERS: ProviderProps[] = [ + // Lark: mainnet fork with the Intent/ICE pallet enabled. Required + // for testing limit orders and Intent-based DCA. Listed first so + // it's visible at the top of the RPC picker. + // dataEnv MUST be "mainnet" — Lark is forked from mainnet and uses + // the mainnet Aave / money market contracts; setting "testnet" here + // causes the supply/borrow screens to fail to load. + createProvider( + "Lark (Intents)", + "wss://node3.lark.hydration.cloud", + MAINNET_INDEXER_URL, + MAINNET_SQUID_URL, + ["development", "production"], + "mainnet", + ), createProvider("Dwellir", "wss://hydration-rpc.n.dwellir.com"), createProvider("Dotters", "wss://hydration.dotters.network"), createProvider("IBP", "wss://hydration.ibp.network"), diff --git a/apps/main/src/i18n/locales/en/common.json b/apps/main/src/i18n/locales/en/common.json index ea3ed5c94c..831322533a 100644 --- a/apps/main/src/i18n/locales/en/common.json +++ b/apps/main/src/i18n/locales/en/common.json @@ -202,6 +202,7 @@ "navigation.trade.title": "Trade", "navigation.swapMarket.title": "Market", "navigation.swapDca.title": "DCA", + "navigation.swapLimit.title": "Limit order", "navigation.wallet.title": "Wallet", "navigation.walletAssets.title": "Overview", "navigation.walletTransactions.title": "Transactions", diff --git a/apps/main/src/i18n/locales/en/trade.json b/apps/main/src/i18n/locales/en/trade.json index c8b76f0560..4cfa5a0f94 100644 --- a/apps/main/src/i18n/locales/en/trade.json +++ b/apps/main/src/i18n/locales/en/trade.json @@ -24,6 +24,7 @@ "trade.orders.allPairs.off": "Recent pair", "trade.orders.type.market": "Market", "trade.orders.type.dca": "DCA", + "trade.orders.type.limit": "Limit", "trade.orders.status.filled": "Filled", "trade.orders.status.active": "Active", "trade.orders.status.failed": "Failed", @@ -54,6 +55,9 @@ "trade.cancelDcaOrder.success": "DCA schedule terminated", "trade.cancelDcaOrder.error": "Termination of DCA schedule failed", "trade.cancelDcaOrder.loading": "Terminating DCA schedule", + "trade.cancelIntent.success": "Limit order cancelled", + "trade.cancelIntent.error": "Failed to cancel limit order", + "trade.cancelIntent.loading": "Cancelling limit order", "trade.orders.pastExecutions.title": "Past executions", "swap.settings.modal.title": "Swap settings", "swap.settings.modal.description": "Reduce cost for bigger orders with high price", @@ -172,5 +176,28 @@ "otc.fillOrder.partiallyFillable.description": "Allow users to fill smaller parts of this offer", "otc.cancelOrder.loading": "Cancelling an OTC order for {{ amount }}.", "otc.cancelOrder.success": "OTC order cancelled for {{ amount }}.", - "otc.cancelOrder.error": "Failed to cancel OTC order for {{ amount }}." + "otc.cancelOrder.error": "Failed to cancel OTC order for {{ amount }}.", + "limit.receiveAtLeast": "Receive at least", + "limit.submit": "Place limit order", + "limit.deviation.editAria": "Edit deviation", + "limit.deviation.resetAria": "Reset deviation to market price", + "limit.priceLabel": "When 1 {{ symbol }} price is", + "limit.priceUnit": "1 {{ symbol }} =", + "limit.spot": "Spot: {{ value }}", + "limit.expiry": "Expires in", + "limit.expiry.15min": "15 MIN", + "limit.expiry.30min": "30 MIN", + "limit.expiry.1h": "1 HOUR", + "limit.expiry.1d": "1 DAY", + "limit.expiry.open": "OPEN", + "limit.partiallyFillable": "Partially fillable", + "limit.partiallyFillable.enabled": "Enabled", + "limit.partiallyFillable.tooltip.intro": "Allows you to choose whether your limit orders will be <0>Partially fillable or <0>Fill or kill.", + "limit.partiallyFillable.tooltip.partial": "<0>Partially fillable orders may be filled partially if there isn't enough liquidity to fill the full amount.", + "limit.partiallyFillable.tooltip.fillOrKill": "<0>Fill or kill orders will either be filled fully or not at all.", + "limit.warning.message": "Your order might not execute precisely at your specified limit price when the market reaches it.", + "limit.warning.moreInfo": "More Info >", + "limit.tx.submitted": "Limit order submitted: Sell {{ amountIn }} for {{ amountOut }}", + "limit.tx.success": "Limit order placed: Sell {{ amountIn }} for {{ amountOut }}", + "limit.tx.error": "Limit order failed: Sell {{ amountIn }} for {{ amountOut }}" } diff --git a/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.columns.tsx b/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.columns.tsx index 5512e71d59..39172be857 100644 --- a/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.columns.tsx +++ b/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.columns.tsx @@ -19,7 +19,8 @@ import { SwapAmount } from "@/modules/trade/orders/columns/SwapAmount" import { SwapMobile } from "@/modules/trade/orders/columns/SwapMobile" import { SwapPrice } from "@/modules/trade/orders/columns/SwapPrice" import { SwapType } from "@/modules/trade/orders/columns/SwapType" -import { OrderData } from "@/modules/trade/orders/lib/useOrdersData" +import { OrderData, OrderKind } from "@/modules/trade/orders/lib/useOrdersData" +import { useRemoveIntent } from "@/modules/trade/orders/lib/useRemoveIntent" import { TerminateDcaScheduleModalContent } from "@/modules/trade/orders/TerminateDcaScheduleModalContent" const columnHelper = createColumnHelper() @@ -32,17 +33,23 @@ export const useOpenOrdersColumns = () => { const fromToColumn = columnHelper.display({ header: t("trade:trade.orders.openOrders.inOut"), cell: ({ row }) => { + const isLimit = row.original.kind === OrderKind.Limit + return ( { ), cell: ({ row }) => { - const { from, to, fromAmountExecuted, toAmountExecuted } = row.original + const { + kind, + from, + to, + fromAmountBudget, + fromAmountExecuted, + toAmountExecuted, + } = row.original - const price = - toAmountExecuted && fromAmountExecuted && Big(toAmountExecuted).gt(0) - ? Big(fromAmountExecuted).div(toAmountExecuted).toString() - : null + let price: string | null = null + if (kind === OrderKind.Limit) { + // Limit: price = amount_out / amount_in + if ( + toAmountExecuted && + fromAmountBudget && + Big(fromAmountBudget).gt(0) + ) { + price = Big(toAmountExecuted).div(fromAmountBudget).toString() + } + } else { + if ( + toAmountExecuted && + fromAmountExecuted && + Big(toAmountExecuted).gt(0) + ) { + price = Big(fromAmountExecuted).div(toAmountExecuted).toString() + } + } return }, @@ -109,6 +138,14 @@ export const useOpenOrdersColumns = () => { id: "actions", cell: function Cell({ row }) { const [modal, setModal] = useState<"confirmation" | "none">("none") + const removeIntent = useRemoveIntent() + + // Any row produced by an intent — Swap (limit) or Dca — carries + // an `intentId` and is cancelled through `Intent.remove_intent`. + // Legacy DCA schedules (from the old pallet, surfaced via the + // indexer) have no `intentId` and still use the DCA terminate + // modal + extrinsic. + const isIntent = row.original.intentId !== undefined return ( @@ -119,25 +156,31 @@ export const useOpenOrdersColumns = () => { width={34} onClick={(e) => { e.stopPropagation() - setModal("confirmation") + if (isIntent && row.original.intentId !== undefined) { + removeIntent.mutate(row.original.intentId) + } else { + setModal("confirmation") + } }} > - - setModal("none")} - > - setModal("none")} - /> - + {!isIntent && } + {!isIntent && ( + setModal("none")} + > + setModal("none")} + /> + + )} ) }, diff --git a/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.tsx b/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.tsx index 7ce3e3e149..97d7e8237a 100644 --- a/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.tsx +++ b/apps/main/src/modules/trade/orders/OpenOrders/OpenOrders.tsx @@ -1,12 +1,14 @@ import { DcaScheduleStatus } from "@galacticcouncil/indexer/squid" import { DataTable, Modal } from "@galacticcouncil/ui/components" import { useSearch } from "@tanstack/react-router" -import { FC, useState } from "react" +import { FC, useMemo, useState } from "react" import { PaginationProps } from "@/hooks/useDataTableUrlPagination" import { DcaOrderDetailsModal } from "@/modules/trade/orders/DcaOrderDetailsModal" +import { useIntentOrdersData } from "@/modules/trade/orders/lib/useIntentOrdersData" import { OrderData, + OrderKind, useOrdersData, } from "@/modules/trade/orders/lib/useOrdersData" import { useOpenOrdersColumns } from "@/modules/trade/orders/OpenOrders/OpenOrders.columns" @@ -28,27 +30,43 @@ export const OpenOrders: FC = ({ allPairs, paginationProps }) => { readonly isTermination: boolean } | null>(null) - const { orders, totalCount, isLoading } = useOrdersData( + const assetFilter = allPairs ? [] : [assetIn, assetOut] + + const { + orders: dcaOrders, + totalCount, + isLoading: isDcaLoading, + } = useOrdersData( [DcaScheduleStatus.Created], - allPairs ? [] : [assetIn, assetOut], + assetFilter, paginationProps.pagination.pageIndex, paginationProps.pagination.pageSize, ) + const { orders: intentOrders, isLoading: isIntentsLoading } = + useIntentOrdersData(assetFilter) + + const allOrders = useMemo( + () => [...intentOrders, ...dcaOrders], + [intentOrders, dcaOrders], + ) + const columns = useOpenOrdersColumns() return ( <> + rowCount={totalCount + intentOrders.length} + onRowClick={(detail) => { + // Skip detail modal for limit orders for now + if (detail.kind === OrderKind.Limit) return setIsDetailOpen({ detail, isTermination: false }) - } + }} emptyState={} /> setIsDetailOpen(null)}> diff --git a/apps/main/src/modules/trade/orders/TradeOrdersHeader.tsx b/apps/main/src/modules/trade/orders/TradeOrdersHeader.tsx index 90b41ed1c7..754b5227ab 100644 --- a/apps/main/src/modules/trade/orders/TradeOrdersHeader.tsx +++ b/apps/main/src/modules/trade/orders/TradeOrdersHeader.tsx @@ -12,10 +12,12 @@ import { useLocation, useNavigate, useSearch } from "@tanstack/react-router" import { FC } from "react" import { useTranslation } from "react-i18next" +import { intentsByAccountQuery } from "@/api/intents" import { useSquidClient } from "@/api/provider" import { TabItem, TabMenu } from "@/components/TabMenu" import { TabMenuItem } from "@/components/TabMenu/TabMenuItem" import { PaginationProps } from "@/hooks/useDataTableUrlPagination" +import { useRpcProvider } from "@/providers/rpcProvider" import { TradeHistorySearchParams } from "@/routes/trade/_history/route" export const tradeOrderTabs = [ @@ -39,6 +41,7 @@ export const TradeOrdersHeader: FC = ({ paginationProps }) => { }) const squidClient = useSquidClient() + const rpc = useRpcProvider() const { account } = useAccount() const accountAddress = account?.address ?? "" const address = safeConvertSS58toPublicKey(accountAddress) @@ -51,7 +54,11 @@ export const TradeOrdersHeader: FC = ({ paginationProps }) => { ), ) - const openOrdersCount = openOrdersCountData?.dcaSchedules?.totalCount ?? 0 + const { data: intents } = useQuery(intentsByAccountQuery(rpc, accountAddress)) + + const dcaCount = openOrdersCountData?.dcaSchedules?.totalCount ?? 0 + const intentCount = intents?.length ?? 0 + const openOrdersCount = dcaCount + intentCount const navigate = useNavigate() diff --git a/apps/main/src/modules/trade/orders/columns/SwapType.tsx b/apps/main/src/modules/trade/orders/columns/SwapType.tsx index 9966278bc0..b0570ccaf2 100644 --- a/apps/main/src/modules/trade/orders/columns/SwapType.tsx +++ b/apps/main/src/modules/trade/orders/columns/SwapType.tsx @@ -25,7 +25,9 @@ export const SwapType: FC = ({ type }) => { {type === OrderKind.DcaRolling ? t("trade.orders.type.dca") - : t(`trade.orders.type.${type}`)} + : type === OrderKind.Limit + ? t("trade.orders.type.limit") + : t(`trade.orders.type.${type}`)} ) diff --git a/apps/main/src/modules/trade/orders/lib/useIntentOrdersData.ts b/apps/main/src/modules/trade/orders/lib/useIntentOrdersData.ts new file mode 100644 index 0000000000..d8c3e0ccd1 --- /dev/null +++ b/apps/main/src/modules/trade/orders/lib/useIntentOrdersData.ts @@ -0,0 +1,120 @@ +import { DcaScheduleStatus } from "@galacticcouncil/indexer/squid" +import { useAccount } from "@galacticcouncil/web3-connect" +import { useQuery } from "@tanstack/react-query" +import { useMemo } from "react" + +import { + AccountIntent, + IntentDcaData, + intentsByAccountQuery, + IntentSwapData, +} from "@/api/intents" +import { OrderData, OrderKind } from "@/modules/trade/orders/lib/useOrdersData" +import { useAssets } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" +import { scaleHuman } from "@/utils/formatting" + +/** + * Maps an on-chain Intent (either Swap or Dca variant) to the shared + * OrderData shape consumed by the Open Orders table. This lets DCA + * intents render alongside legacy (old-pallet) DCA schedules and Swap + * (limit) intents without the display components needing to know which + * backend produced the row. + */ +const entryToOrder = ( + entry: AccountIntent, + getAssetWithFallback: ReturnType["getAssetWithFallback"], +): OrderData | null => { + const { data } = entry.intent + + if (data.type === "Swap") { + const swap: IntentSwapData = data.value + const from = getAssetWithFallback(String(swap.asset_in)) + const to = getAssetWithFallback(String(swap.asset_out)) + return { + kind: OrderKind.Limit, + scheduleId: Number(entry.id), + from, + fromAmountBudget: scaleHuman(swap.amount_in, from.decimals), + fromAmountExecuted: null, + fromAmountRemaining: scaleHuman(swap.amount_in, from.decimals), + singleTradeSize: null, + to, + toAmountExecuted: scaleHuman(swap.amount_out, to.decimals), + status: DcaScheduleStatus.Created, + blocksPeriod: null, + isOpenBudget: false, + intentId: entry.id, + deadline: entry.intent.deadline, + } + } + + // Dca + const dca: IntentDcaData = data.value + const from = getAssetWithFallback(String(dca.asset_in)) + const to = getAssetWithFallback(String(dca.asset_out)) + const isOpenBudget = dca.budget === undefined + + // Per-trade: `amount_in`/`amount_out` are per-execution on the intent. + const singleTradeSize = scaleHuman(dca.amount_in, from.decimals) + // Total budget in human units (LimitedBudget only). We don't have + // aggregated execution data without an indexer, so `fromAmountExecuted` + // stays null and `fromAmountRemaining` falls back to the full budget. + const fromAmountBudget = !isOpenBudget + ? scaleHuman(dca.budget as bigint, from.decimals) + : null + + return { + kind: isOpenBudget ? OrderKind.DcaRolling : OrderKind.Dca, + scheduleId: Number(entry.id), + from, + fromAmountBudget, + fromAmountExecuted: null, + fromAmountRemaining: fromAmountBudget, + singleTradeSize, + to, + // Per-trade target output — the column currently labels this + // "amount_out" / "to received". Without indexer aggregates the best + // we can show is the per-execution target. + toAmountExecuted: scaleHuman(dca.amount_out, to.decimals), + status: DcaScheduleStatus.Created, + blocksPeriod: String(dca.period), + isOpenBudget, + intentId: entry.id, + deadline: entry.intent.deadline, + } +} + +export const useIntentOrdersData = (assetIds: string[]) => { + const { account } = useAccount() + const rpc = useRpcProvider() + const { getAssetWithFallback } = useAssets() + + const { data: intents, isLoading } = useQuery( + intentsByAccountQuery(rpc, account?.address ?? ""), + ) + + const orders = useMemo(() => { + if (!intents) return [] + + const filterByAsset = (entry: AccountIntent) => { + if (assetIds.length === 0) return true + const { data } = entry.intent + const assetIn = + data.type === "Swap" ? data.value.asset_in : data.value.asset_in + const assetOut = + data.type === "Swap" ? data.value.asset_out : data.value.asset_out + return ( + assetIds.includes(String(assetIn)) || + assetIds.includes(String(assetOut)) + ) + } + + return intents + .filter(filterByAsset) + .map((entry) => entryToOrder(entry, getAssetWithFallback)) + .filter((o): o is OrderData => o !== null) + }, [intents, assetIds, getAssetWithFallback]) + + return { orders, isLoading } +} diff --git a/apps/main/src/modules/trade/orders/lib/useOrdersData.ts b/apps/main/src/modules/trade/orders/lib/useOrdersData.ts index 81bcb71114..151c0c4e3b 100644 --- a/apps/main/src/modules/trade/orders/lib/useOrdersData.ts +++ b/apps/main/src/modules/trade/orders/lib/useOrdersData.ts @@ -16,6 +16,7 @@ import { scaleHuman } from "@/utils/formatting" export enum OrderKind { Dca = "dca", DcaRolling = "dcaRolling", + Limit = "limit", } export type OrderData = { @@ -31,6 +32,9 @@ export type OrderData = { readonly status: DcaScheduleStatus | null readonly blocksPeriod: string | null readonly isOpenBudget: boolean + // Intent/Limit order fields + readonly intentId?: bigint + readonly deadline?: bigint } export const useOrdersData = ( diff --git a/apps/main/src/modules/trade/orders/lib/useRemoveIntent.ts b/apps/main/src/modules/trade/orders/lib/useRemoveIntent.ts new file mode 100644 index 0000000000..d35407a550 --- /dev/null +++ b/apps/main/src/modules/trade/orders/lib/useRemoveIntent.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useAccount } from "@galacticcouncil/web3-connect" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useTranslation } from "react-i18next" + +import { useRpcProvider } from "@/providers/rpcProvider" +import { useTransactionsStore } from "@/states/transactions" + +export const useRemoveIntent = () => { + const { t } = useTranslation("trade") + const { papiClient } = useRpcProvider() + const { createTransaction } = useTransactionsStore() + const queryClient = useQueryClient() + const { account } = useAccount() + + return useMutation({ + mutationFn: (intentId: bigint) => { + const unsafeApi = papiClient.getUnsafeApi() as any + const tx = unsafeApi.tx.Intent.remove_intent({ id: intentId }) + + return createTransaction( + { + tx, + toasts: { + success: t("trade.cancelIntent.success"), + submitted: t("trade.cancelIntent.loading"), + error: t("trade.cancelIntent.error"), + }, + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["intents", "byAccount", account?.address ?? ""], + }) + }, + }, + ) + }, + }) +} diff --git a/apps/main/src/modules/trade/swap/SwapPageDesktop.tsx b/apps/main/src/modules/trade/swap/SwapPageDesktop.tsx index be3577511f..223991dd9c 100644 --- a/apps/main/src/modules/trade/swap/SwapPageDesktop.tsx +++ b/apps/main/src/modules/trade/swap/SwapPageDesktop.tsx @@ -1,9 +1,10 @@ -import { Separator, Stack } from "@galacticcouncil/ui/components" +import { Flex, Separator, Stack } from "@galacticcouncil/ui/components" import { Outlet } from "@tanstack/react-router" import { TwoColumnGrid } from "@/modules/layout/components/TwoColumnGrid/TwoColumnGrid" import { TradeOrders } from "@/modules/trade/orders/TradeOrders" import { FormHeader } from "@/modules/trade/swap/components/FormHeader/FormHeader" +import { LimitPostFormDisclaimer } from "@/modules/trade/swap/components/LimitPostFormDisclaimer" import { PageHeader } from "@/modules/trade/swap/components/PageHeader/PageHeader" import { TradeChart } from "@/modules/trade/swap/components/TradeChart/TradeChart" @@ -17,11 +18,22 @@ export const SwapPageDesktop = () => { - - - - - + + + + + + + + diff --git a/apps/main/src/modules/trade/swap/SwapPageMobile.tsx b/apps/main/src/modules/trade/swap/SwapPageMobile.tsx index 084262a6ee..1683e53785 100644 --- a/apps/main/src/modules/trade/swap/SwapPageMobile.tsx +++ b/apps/main/src/modules/trade/swap/SwapPageMobile.tsx @@ -4,6 +4,7 @@ import { FC } from "react" import { TradeOrders } from "@/modules/trade/orders/TradeOrders" import { FormHeader } from "@/modules/trade/swap/components/FormHeader/FormHeader" +import { LimitPostFormDisclaimer } from "@/modules/trade/swap/components/LimitPostFormDisclaimer" import { TradeChart } from "@/modules/trade/swap/components/TradeChart/TradeChart" import { SSwapFormContainer } from "./SwapPage.styled" @@ -13,11 +14,14 @@ export const TRADE_CHART_MOBILE_HEIGHT = 300 export const SwapPageMobile: FC = () => { return ( - - - - - + + + + + + + + diff --git a/apps/main/src/modules/trade/swap/components/LimitPostFormDisclaimer.tsx b/apps/main/src/modules/trade/swap/components/LimitPostFormDisclaimer.tsx new file mode 100644 index 0000000000..67441b2696 --- /dev/null +++ b/apps/main/src/modules/trade/swap/components/LimitPostFormDisclaimer.tsx @@ -0,0 +1,16 @@ +import { useMatchRoute } from "@tanstack/react-router" +import { FC } from "react" + +import { LINKS } from "@/config/navigation" +import { LimitWarning } from "@/modules/trade/swap/sections/Limit/LimitWarning" + +/** + * Limit-only copy shown under the swap card (not inside the Paper) so it + * spans the full width of the right column. + */ +export const LimitPostFormDisclaimer: FC = () => { + const matchRoute = useMatchRoute() + if (!matchRoute({ to: LINKS.swapLimit })) return null + + return +} diff --git a/apps/main/src/modules/trade/swap/sections/DCA/useSubmitDcaOrder.ts b/apps/main/src/modules/trade/swap/sections/DCA/useSubmitDcaOrder.ts index c873b89589..3e2faf63e6 100644 --- a/apps/main/src/modules/trade/swap/sections/DCA/useSubmitDcaOrder.ts +++ b/apps/main/src/modules/trade/swap/sections/DCA/useSubmitDcaOrder.ts @@ -1,7 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { getTimeFrameMillis } from "@galacticcouncil/main/src/components/TimeFrame/TimeFrame.utils" import { TradeDcaOrder } from "@galacticcouncil/sdk-next/sor" import { useAccount } from "@galacticcouncil/web3-connect" -import { useMutation } from "@tanstack/react-query" +import { useMutation, useQueryClient } from "@tanstack/react-query" import { useTranslation } from "react-i18next" import { @@ -9,19 +10,63 @@ import { DcaOrdersMode, } from "@/modules/trade/swap/sections/DCA/useDcaForm" import { AnyTransaction } from "@/modules/transactions/types" +import { isErc20AToken, TAsset } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" +import { useTradeSettings } from "@/states/tradeSettings" import { useTransactionsStore } from "@/states/transactions" import { scaleHuman } from "@/utils/formatting" +// The Intent pallet works with native asset IDs. ERC20 wrapper tokens +// (e.g. HUSDT 1111) must be mapped to their underlying asset (USDT 111). +// Mirrored from useSubmitLimitOrder — hoist once we factor a shared util. +const getIntentAssetId = (asset: TAsset): number => { + if (isErc20AToken(asset)) { + return Number(asset.underlyingAssetId) + } + return Number(asset.id) +} + +/** + * Submits a DCA order as an ICE Intent (`Intent.submit_intent` with the + * `Dca` data variant). Replaces the previous `DCA.schedule` extrinsic + * path built by sdk-next's `OrderTxBuilder`. + * + * The preview (`TradeDcaOrder` from `dcaTradeOrderQuery`) is kept + * unchanged — it drives the form UI, warnings, health-factor check. + * Only the final transaction construction moves to the Intent pallet. + * + * Mapping of `TradeDcaOrder` + form → Intent.Dca payload: + * asset_in = `getIntentAssetId(sellAsset)` (unwraps ERC20 A-tokens) + * asset_out = `getIntentAssetId(buyAsset)` + * amount_in = `order.tradeAmountIn` (per-trade, native bigint) + * amount_out = `order.tradeAmountOut` (per-trade, native bigint) + * slippage = `slippagePct * 10_000` (Permill) + * budget = LimitedBudget → `order.amountIn`; OpenBudget → undefined + * period = `order.tradePeriod` (blocks) + * deadline = undefined (matches old DCA: runs until budget exhausts + * or user cancels; OpenBudget runs until balance runs out) + * + * Dropped fields (no Intent equivalent): `max_retries`, `beneficiary`, + * `route`, `stability_threshold`, `start_execution_block`. + */ export const useSubmitDcaOrder = () => { const { t } = useTranslation(["common", "trade"]) const { account } = useAccount() const address = account?.address + const { papiClient } = useRpcProvider() + const { + dca: { slippage: slippagePct }, + } = useTradeSettings() + const { createTransaction } = useTransactionsStore() + const queryClient = useQueryClient() return useMutation({ - mutationFn: async ([formValues, order, orderTx]: [ + // Signature kept for caller compatibility — `_orderTx` is no longer + // used (we build our own Intent.submit_intent extrinsic below). + mutationFn: async ([formValues, order, _orderTx]: [ DcaFormValues, TradeDcaOrder, AnyTransaction, @@ -39,6 +84,45 @@ export const useSubmitDcaOrder = () => { const frequency = order.tradeCount > 0 ? duration / order.tradeCount : 0 const isOpenBudget = orders.type === DcaOrdersMode.OpenBudget + // Permill = parts per million (u32, 0..=1_000_000). sdk-next's + // OrderTxBuilder uses `slippagePct * 1e4` (1% → 10_000 Permill). + // Clamp defensively: the form validator caps the setting but the + // runtime rejects values > 1_000_000 outright. + const slippagePermill = Math.max( + 0, + Math.min(1_000_000, Math.round(slippagePct * 10_000)), + ) + + // Runtime enforces `period >= MinDcaPeriod` (5 blocks on-chain). + // sdk-next already floors to 6, but this guards against future + // SDK changes / preview quirks. + const period = Math.max(order.tradePeriod, 6) + + const unsafeApi = papiClient.getUnsafeApi() as any + const tx = unsafeApi.tx.Intent.submit_intent({ + intent: { + data: { + type: "Dca", + value: { + asset_in: getIntentAssetId(sellAsset), + asset_out: getIntentAssetId(buyAsset), + amount_in: order.tradeAmountIn, + amount_out: order.tradeAmountOut, + slippage: slippagePermill, + // OpenBudget → no cap → chain runs intent until balance + // runs out or user cancels. + budget: isOpenBudget ? undefined : order.amountIn, + period, + }, + }, + // No deadline: the intent lives until budget exhausts (Limited) + // or user cancels (Open). Matches the old DCA.schedule + // behaviour which had no expiry. + deadline: undefined, + on_resolved: undefined, + }, + }) + const params = { amountIn: t("currency", { value: scaleHuman(order.tradeAmountIn, sellDecimals), @@ -52,23 +136,34 @@ export const useSubmitDcaOrder = () => { frequency: isOpenBudget ? duration : frequency, } - return createTransaction({ - tx: orderTx, - toasts: { - submitted: t( - `trade:dca.${isOpenBudget ? "openBudget" : "limitedBudget"}.tx.loading`, - params, - ), - success: t( - `trade:dca.${isOpenBudget ? "openBudget" : "limitedBudget"}.tx.success`, - params, - ), - error: t( - `trade:dca.${isOpenBudget ? "openBudget" : "limitedBudget"}.tx.error`, - params, - ), + return createTransaction( + { + tx, + toasts: { + submitted: t( + `trade:dca.${isOpenBudget ? "openBudget" : "limitedBudget"}.tx.loading`, + params, + ), + success: t( + `trade:dca.${isOpenBudget ? "openBudget" : "limitedBudget"}.tx.success`, + params, + ), + error: t( + `trade:dca.${isOpenBudget ? "openBudget" : "limitedBudget"}.tx.error`, + params, + ), + }, }, - }) + { + onSuccess: () => { + // Make the newly-created intent show up in Open Orders + // without waiting for the polling interval. + queryClient.invalidateQueries({ + queryKey: ["intents", "byAccount", address], + }) + }, + }, + ) }, }) } diff --git a/apps/main/src/modules/trade/swap/sections/Limit/Limit.tsx b/apps/main/src/modules/trade/swap/sections/Limit/Limit.tsx new file mode 100644 index 0000000000..70c70a98eb --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/Limit.tsx @@ -0,0 +1,34 @@ +import { useSearch } from "@tanstack/react-router" +import { FC } from "react" +import { FormProvider } from "react-hook-form" + +import { LimitFields } from "@/modules/trade/swap/sections/Limit/LimitFields" +import { LimitSubmit } from "@/modules/trade/swap/sections/Limit/LimitSubmit" +import { useLimitForm } from "@/modules/trade/swap/sections/Limit/useLimitForm" +import { useSubmitLimitOrder } from "@/modules/trade/swap/sections/Limit/useSubmitLimitOrder" + +export const Limit: FC = () => { + const { assetIn, assetOut } = useSearch({ from: "/trade/_history" }) + + const form = useLimitForm({ assetIn, assetOut }) + const submitLimitOrder = useSubmitLimitOrder() + + const isFormValid = form.formState.isValid + const isSubmitEnabled = isFormValid + + return ( + +
+ submitLimitOrder.mutate(values), + )} + > + + + +
+ ) +} diff --git a/apps/main/src/modules/trade/swap/sections/Limit/LimitFields.tsx b/apps/main/src/modules/trade/swap/sections/Limit/LimitFields.tsx new file mode 100644 index 0000000000..cba7beca78 --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/LimitFields.tsx @@ -0,0 +1,420 @@ +import styled from "@emotion/styled" +import { Icon } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { SELL_ONLY_ASSETS } from "@galacticcouncil/utils" +import { useQuery } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import Big from "big.js" +import { LockKeyhole, LockKeyholeOpen } from "lucide-react" +import { + FC, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react" +import { useFormContext } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { spotPriceQuery } from "@/api/spotPrice" +import { AssetSelectFormField } from "@/form/AssetSelectFormField" +import { LimitPriceSection } from "@/modules/trade/swap/sections/Limit/LimitPriceSection" +import { LimitSwitcher } from "@/modules/trade/swap/sections/Limit/LimitSwitcher" +import { formatCalcValue } from "@/modules/trade/swap/sections/Limit/limitUtils" +import { LimitFormValues } from "@/modules/trade/swap/sections/Limit/useLimitForm" +import { SwapSectionSeparator } from "@/modules/trade/swap/SwapPage.styled" +import { TAsset, useAssets } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" + +const RECALCULATE_DEBOUNCE_MS = 250 + +export const LimitFields: FC = () => { + const { t } = useTranslation(["common", "trade"]) + const { tradable } = useAssets() + const rpc = useRpcProvider() + const navigate = useNavigate() + + const { reset, getValues, setValue, trigger, watch } = + useFormContext() + + const [sellAsset, buyAsset, sellAmount, isLocked] = watch([ + "sellAsset", + "buyAsset", + "sellAmount", + "isLocked", + ]) + + // ── Lock icon positioning: measure asset button and place lock after it ── + const sellFieldRef = useRef(null) + const [lockPos, setLockPos] = useState< + { left: number; top: number } | undefined + >(undefined) + + useLayoutEffect(() => { + const container = sellFieldRef.current + if (!container) return + // Find the asset selector pill button inside the sell field + const assetBtn = container.querySelector( + 'button[type="button"]:not([aria-label])', + ) + if (!assetBtn) return + const containerRect = container.getBoundingClientRect() + const btnRect = assetBtn.getBoundingClientRect() + setLockPos({ + left: btnRect.right - containerRect.left + 4, + top: btnRect.top - containerRect.top, + }) + }, [sellAsset]) + + const buyableAssets = tradable.filter( + (asset) => !SELL_ONLY_ASSETS.includes(asset.id), + ) + + // Use spotPriceQuery — same clean theoretical rate as the swap page + // (TradeAssetSwitcher fallback). No fees or price impact. + const { data: spotData } = useQuery( + spotPriceQuery(rpc, sellAsset?.id ?? "", buyAsset?.id ?? ""), + ) + + const marketPrice = (() => { + if (!spotData?.spotPrice) return null + try { + const spot = new Big(spotData.spotPrice) + if (spot.lte(0)) return null + return formatCalcValue(spot) + } catch { + return null + } + })() + + // ── Recalculation helpers ── + + /** Given sell + price → buy */ + const calcBuyFromSellAndPrice = useCallback( + (sell: string, price: string): string => { + try { + if (!sell || !price) return "" + return formatCalcValue(new Big(sell).times(price)) + } catch { + return "" + } + }, + [], + ) + + /** Given buy + price → sell */ + const calcSellFromBuyAndPrice = useCallback( + (buy: string, price: string): string => { + try { + if (!buy || !price) return "" + const p = new Big(price) + if (p.lte(0)) return "" + return formatCalcValue(new Big(buy).div(p)) + } catch { + return "" + } + }, + [], + ) + + /** Given sell + buy → price */ + const calcPriceFromAmounts = useCallback( + (sell: string, buy: string): string => { + try { + if (!sell || !buy) return "" + const s = new Big(sell) + if (s.lte(0)) return "" + return formatCalcValue(new Big(buy).div(s)) + } catch { + return "" + } + }, + [], + ) + + // ── Keep limit price mirrored to spot until the user touches it ── + // As long as `priceAnchor === "spot"` we re-sync `limitPrice` to the + // latest `marketPrice` every time the spot query refetches (which it + // does on every new block via QUERY_KEY_BLOCK_PREFIX). Once the user + // types a custom price or % deviation, LimitPriceSection flips + // `priceAnchor` to "user" and this effect becomes a no-op — their + // typed value is preserved. + useEffect(() => { + if (!marketPrice) return + const values = getValues() + if (values.priceAnchor !== "spot") return + if (values.limitPrice === marketPrice) return + + setValue("limitPrice", marketPrice) + if (values.sellAmount) { + setValue( + "buyAmount", + calcBuyFromSellAndPrice(values.sellAmount, marketPrice), + ) + } + }, [marketPrice, getValues, setValue, calcBuyFromSellAndPrice]) + + // ── Field change handlers (Matcha anchor model) ── + + const debounceRef = useRef | null>(null) + + const debounced = useCallback((fn: () => void) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(fn, RECALCULATE_DEBOUNCE_MS) + }, []) + + /** User edits sell amount */ + const handleSellAmountChange = useCallback( + (newSellAmount: string) => { + // Record anchor synchronously so a subsequent price edit + // (which is not debounced) already sees the new anchor. + setValue("amountAnchor", "sell") + debounced(() => { + const values = getValues() + const price = values.limitPrice + + if (!price) return + + // Price-sacred (or locked): sell changed → recalc buy + const newBuy = calcBuyFromSellAndPrice(newSellAmount, price) + setValue("buyAmount", newBuy) + trigger() + }) + }, + [debounced, getValues, setValue, calcBuyFromSellAndPrice, trigger], + ) + + /** User edits buy amount */ + const handleBuyAmountChange = useCallback( + (newBuyAmount: string) => { + // When locked, sell is sacred → anchor stays "sell". + // Otherwise the user is now anchoring on buy. + const { isLocked } = getValues() + if (!isLocked) setValue("amountAnchor", "buy") + debounced(() => { + const values = getValues() + const price = values.limitPrice + + if (!values.isLocked) { + // Price-sacred: buy changed → recalc sell + if (price) { + const newSell = calcSellFromBuyAndPrice(newBuyAmount, price) + setValue("sellAmount", newSell) + } + } else { + // Sell-sacred: sell locked → recalc price + if (values.sellAmount) { + const newPrice = calcPriceFromAmounts( + values.sellAmount, + newBuyAmount, + ) + setValue("limitPrice", newPrice) + } + } + trigger() + }) + }, + [ + debounced, + getValues, + setValue, + calcSellFromBuyAndPrice, + calcPriceFromAmounts, + trigger, + ], + ) + + // ── Lock toggle ── + const handleLockToggle = useCallback(() => { + const values = getValues() + const nextLocked = !values.isLocked + setValue("isLocked", nextLocked) + // Turning the lock ON forces sell-sacred mode, so the anchor + // needs to match — otherwise a subsequent price edit would try + // to honor a buy anchor that contradicts the lock. + if (nextLocked) setValue("amountAnchor", "sell") + }, [getValues, setValue]) + + // ── Asset change handlers ── + + const handleSellAssetChange = useCallback( + (newSellAsset: TAsset) => { + const values = getValues() + // Reset price — will be re-populated from marketPrice by the + // spot-mirroring effect (priceAnchor stays "spot" after reset). + reset({ + ...values, + sellAsset: newSellAsset, + limitPrice: "", + buyAmount: "", + amountAnchor: "sell", + priceAnchor: "spot", + }) + trigger() + }, + [getValues, reset, trigger], + ) + + const handleBuyAssetChange = useCallback( + (newBuyAsset: TAsset) => { + const values = getValues() + reset({ + ...values, + buyAsset: newBuyAsset, + limitPrice: "", + buyAmount: "", + amountAnchor: "sell", + priceAnchor: "spot", + }) + trigger() + }, + [getValues, reset, trigger], + ) + + return ( +
+ {/* Sell field with lock icon next to asset button */} + + + assetFieldName="sellAsset" + amountFieldName="sellAmount" + label={t("sell")} + assets={tradable} + maxBalanceFallback="0" + onAssetChange={(sellAsset, previousSellAsset) => { + const { buyAsset } = getValues() + if (sellAsset.id === buyAsset?.id) { + setValue("sellAsset", previousSellAsset) + return + } + handleSellAssetChange(sellAsset) + navigate({ + to: ".", + search: (search) => ({ + ...search, + assetIn: sellAsset.id, + assetOut: buyAsset?.id, + }), + resetScroll: false, + }) + }} + onAmountChange={handleSellAmountChange} + /> + {sellAmount && ( + + + + )} + + + + + + assetFieldName="buyAsset" + amountFieldName="buyAmount" + label={t("trade:limit.receiveAtLeast")} + assets={buyableAssets} + hideMaxBalanceAction + maxBalanceFallback="0" + onAssetChange={(buyAsset, previousBuyAsset) => { + const { sellAsset } = getValues() + if (buyAsset.id === sellAsset?.id) { + setValue("buyAsset", previousBuyAsset) + return + } + handleBuyAssetChange(buyAsset) + navigate({ + to: ".", + search: (search) => ({ + ...search, + assetIn: sellAsset?.id, + assetOut: buyAsset.id, + }), + resetScroll: false, + }) + }} + onAmountChange={handleBuyAmountChange} + /> + + + + +
+ ) +} + +// ── Styled components ── + +/** Wrapper that enables absolute positioning of the lock pill */ +const SLockableField = styled.div` + position: relative; +` + +/** + * Lock pill — styled exactly like input/assetSelector from the design: + * same height (38px), border, border-radius (30px), and padding as the + * asset selector buttons. Contains a centered 14px lock icon. + * Position (left + top) is set dynamically via style prop. + * + * When `isLocked` is true the pill gets the blue accent treatment + * (same tokens as the selected expiry pill) so it reads clearly as an + * "on" state rather than a decorative icon. + */ +const SLockPill = styled.button<{ isLocked?: boolean }>( + ({ theme, isLocked }) => ` + all: unset; + box-sizing: border-box; + cursor: pointer; + position: absolute; + + display: flex; + align-items: center; + justify-content: center; + height: 38px; + padding: ${theme.space.base} ${theme.space.m}; + border-radius: 30px; + border: 1px solid ${ + isLocked + ? theme.buttons.secondary.accent.outline + : theme.buttons.secondary.low.borderRest + }; + background: ${ + isLocked ? theme.buttons.secondary.accent.hover : "transparent" + }; + transition: ${theme.transitions.colors}; + + &:hover { + border-color: ${ + isLocked + ? theme.buttons.secondary.accent.outline + : theme.buttons.secondary.low.hover + }; + background: ${ + isLocked + ? theme.buttons.secondary.accent.hover + : theme.buttons.secondary.low.primaryHover + }; + } + `, +) diff --git a/apps/main/src/modules/trade/swap/sections/Limit/LimitPriceSection.tsx b/apps/main/src/modules/trade/swap/sections/Limit/LimitPriceSection.tsx new file mode 100644 index 0000000000..59853975e4 --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/LimitPriceSection.tsx @@ -0,0 +1,935 @@ +import { css, useTheme } from "@emotion/react" +import styled from "@emotion/styled" +import { ArrowLeftRight } from "@galacticcouncil/ui/assets/icons" +import { + Flex, + Icon, + NumberInput, + Skeleton, + Text, + Toggle, + ToggleLabel, + ToggleRoot, + Tooltip, +} from "@galacticcouncil/ui/components" +import { getToken, pxToRem } from "@galacticcouncil/ui/utils" +import Big from "big.js" +import { Pencil, X } from "lucide-react" +import { FC, MouseEvent, useCallback, useEffect, useRef, useState } from "react" +import { useFormContext } from "react-hook-form" +import { Trans, useTranslation } from "react-i18next" + +import { useDisplayAssetPrice } from "@/components/AssetPrice" +import { formatCalcValue } from "@/modules/trade/swap/sections/Limit/limitUtils" +import { + EXPIRY_OPTIONS, + LimitFormValues, +} from "@/modules/trade/swap/sections/Limit/useLimitForm" +import { SwapSectionSeparator } from "@/modules/trade/swap/SwapPage.styled" + +/** vs prior `size="2xs"` (~8px): ×1.3 (see `theme.sizes["2xs"]`). */ +const PILL_SLICE_ICON_SIZE = 8 * 1.3 + +/** Edit / close icon column — shared by both `SPillSliceButton` instances (~20% under 34px). */ +const SPILL_SLICE_BUTTON_WIDTH_PX = 27 + +type Props = { + readonly marketPrice: string | null +} + +export const LimitPriceSection: FC = ({ marketPrice }) => { + const theme = useTheme() + const { t } = useTranslation(["trade", "common"]) + const { watch, setValue, getValues, trigger } = + useFormContext() + + const [ + limitPrice, + sellAsset, + buyAsset, + expiry, + partiallyFillable, + priceAnchor, + ] = watch([ + "limitPrice", + "sellAsset", + "buyAsset", + "expiry", + "partiallyFillable", + "priceAnchor", + ]) + + // Denomination toggle: "normal" = 1 SELL = X BUY, "inverted" = 1 BUY = X SELL + const [isInverted, setIsInverted] = useState(false) + + // Last raw text the user typed into the pill — used to repopulate + // the input on re-open so the value isn't lost between edit sessions. + const [lastPillValue, setLastPillValue] = useState("") + + /** + * When the user sets a % via the pill we store the exact typed number + * here and use it for the deviation display instead of re-deriving + * it from the rounded limitPrice. Otherwise rounding in + * `formatCalcValue(market × (1+pct/100))` would make "15%" redisplay + * as "15.01%" (or "14.99%") depending on which way the rounding went. + * + * `null` = no user pct currently in effect → fall back to computed + * deviation from limitPrice vs marketPrice. + */ + const [userPct, setUserPct] = useState(null) + + // Reset any remembered pill-% when the pair changes — a 5% deviation + // from HDX/USDC doesn't carry meaning over to, say, ETH/USDT. + useEffect(() => { + setUserPct(null) + setLastPillValue("") + }, [sellAsset?.id, buyAsset?.id]) + + // When the user types in the price field we store their raw input here + // so the NumberInput displays exactly what they typed (no round-trip + // through Big.js + formatCalcValue which can mangle digits). + // `canonical` is the limitPrice value we wrote at the same time; on + // every render we check that limitPrice still matches — if an external + // source (spot prefill, asset reset, sell-sacred recalc) has changed + // it, we ignore the cached user input and format the new value. + const userInputRef = useRef<{ + value: string + inverted: boolean + canonical: string + } | null>(null) + + const displayPrice = (() => { + const user = userInputRef.current + if (user && user.inverted === isInverted && user.canonical === limitPrice) { + return user.value + } + if (!limitPrice) return "" + try { + const value = isInverted + ? Big(1).div(new Big(limitPrice)) + : new Big(limitPrice) + if (value.lte(0)) return "" + return formatCalcValue(value) + } catch { + return "" + } + })() + + const denominationSuffix = isInverted + ? (sellAsset?.symbol ?? "") + : (buyAsset?.symbol ?? "") + + const denomAssetIdForFiat = (isInverted ? sellAsset?.id : buyAsset?.id) ?? "" + + const priceHumanForFiat = (() => { + if (!displayPrice.trim()) return null + try { + const n = new Big(displayPrice.replace(/\s/g, "").replace(",", ".")) + return n.gt(0) ? n.toString() : null + } catch { + return null + } + })() + + const [priceFiatDisplay, { isLoading: priceFiatLoading }] = + useDisplayAssetPrice(denomAssetIdForFiat, priceHumanForFiat ?? "0", { + compact: true, + maximumFractionDigits: 2, + }) + + const showPriceFiatRow = Boolean(denomAssetIdForFiat && priceHumanForFiat) + + // ── Deviation from market price ── + const deviation = (() => { + if (!limitPrice || !marketPrice) return null + try { + const limit = new Big(limitPrice) + const market = new Big(marketPrice) + if (market.lte(0)) return null + return limit.minus(market).div(market).times(100).toNumber() + } catch { + return null + } + })() + + // ── Spot price display value (same formatting as price field) ── + const spotDisplayValue = (() => { + if (!marketPrice) return null + try { + const raw = isInverted + ? Big(1).div(new Big(marketPrice)) + : new Big(marketPrice) + if (raw.lte(0)) return null + return formatCalcValue(raw) + } catch { + return null + } + })() + + /** + * Recalculate the non-anchored amount from the new price. + * Anchor rule: + * - Lock ON → always sell-sacred: keep sell, recalc buy (buy = sell × price) + * - Lock OFF, anchor = "sell" → keep sell, recalc buy + * - Lock OFF, anchor = "buy" → keep buy, recalc sell (sell = buy / price) + * Degenerate cases: if only one amount is filled, treat it as the anchor. + */ + const recalcFromPrice = useCallback( + (price: string) => { + if (!price) { + trigger() + return + } + try { + const p = new Big(price) + if (p.lte(0)) { + trigger() + return + } + + const values = getValues() + const hasSell = !!values.sellAmount + const hasBuy = !!values.buyAmount + + const keepBuy = + !values.isLocked && + (values.amountAnchor === "buy" || (!hasSell && hasBuy)) + + if (keepBuy && values.buyAmount) { + // buy-anchored → sell = buy / price + const newSell = new Big(values.buyAmount).div(p) + setValue("sellAmount", formatCalcValue(newSell)) + } else if (values.sellAmount) { + // sell-anchored (default / locked) → buy = sell × price + const newBuy = new Big(values.sellAmount).times(p) + setValue("buyAmount", formatCalcValue(newBuy)) + } + } catch { + // ignore invalid Big parses + } + trigger() + }, + [getValues, setValue, trigger], + ) + // Alias kept for callers below that still use the old name. + const recalcBuy = recalcFromPrice + + // ── Price change handler ── + const handlePriceChange = useCallback( + (displayValue: string) => { + let newLimitPrice: string + if (!isInverted) { + newLimitPrice = displayValue + } else { + try { + const p = new Big(displayValue) + if (p.lte(0)) return + // Store at full precision so flipping back round-trips cleanly. + // The non-inverted display path formats the value via + // formatCalcValue so the raw ~20-digit string never reaches + // the user. + newLimitPrice = Big(1).div(p).toString() + } catch { + return + } + } + + // Remember exactly what the user typed so the input shows it back + // unchanged (the canonical limitPrice may have been transformed). + // canonical lets us detect external changes to limitPrice and + // invalidate this cache without a stale-render. + userInputRef.current = { + value: displayValue, + inverted: isInverted, + canonical: newLimitPrice, + } + // User typed a custom price — stop auto-mirroring spot. + setValue("priceAnchor", "user") + setValue("limitPrice", newLimitPrice) + // Typing the price directly invalidates any remembered pill %. + setUserPct(null) + // Both modes: price changed → recalc buy + recalcBuy(newLimitPrice) + }, + [isInverted, setValue, recalcBuy], + ) + + // ── Set limit price to spot/market price ── + const handleSetSpotPrice = useCallback(() => { + if (!marketPrice) return + userInputRef.current = null + // Resume mirroring spot live (next block will confirm via the + // spot-mirroring effect in LimitFields, keeping them in sync). + setValue("priceAnchor", "spot") + setValue("limitPrice", marketPrice) + recalcBuy(marketPrice) + // Explicit reset wipes any remembered % — next edit starts clean. + setLastPillValue("") + setUserPct(null) + }, [marketPrice, setValue, recalcBuy]) + + const handlePillDeviationReset = useCallback( + (event: MouseEvent) => { + event.stopPropagation() + event.preventDefault() + setIsEditingPill(false) + handleSetSpotPrice() + }, + [handleSetSpotPrice], + ) + + // ── Inline editing mode for custom pill ── + const [isEditingPill, setIsEditingPill] = useState(false) + const pillInputRef = useRef(null) + + const handlePillEditStart = useCallback(() => { + setIsEditingPill(true) + setTimeout(() => pillInputRef.current?.focus(), 0) + }, []) + + /** + * Apply a percentage-deviation value to the form without exiting edit + * mode. Called on every keystroke so the price and receive amount + * update live as the user types the percentage. Handles these cases: + * + * - Empty / whitespace → resume mirroring spot (priceAnchor = "spot") + * - Valid percentage → set limitPrice = market × (1 + pct/100), + * priceAnchor = "user" + * - Invalid / partial → silently skip (last valid state stays, + * so typing "5", then "5.", the latter is + * a no-op) + * - `pct ≤ -100` → silently skip (would produce a zero or + * negative price, economically nonsensical) + */ + const applyPillValue = useCallback( + (value: string) => { + if (!marketPrice) return + const trimmed = value.trim() + if (!trimmed) { + userInputRef.current = null + setUserPct(null) + setValue("priceAnchor", "spot") + setValue("limitPrice", marketPrice) + recalcBuy(marketPrice) + return + } + try { + const pct = new Big(trimmed) + if (pct.lte(-100)) return + const market = new Big(marketPrice) + const newRaw = market.times(Big(1).plus(pct.div(100))) + if (newRaw.lte(0)) return + const newPrice = formatCalcValue(newRaw) + userInputRef.current = null + setUserPct(pct.toNumber()) + setValue("priceAnchor", "user") + setValue("limitPrice", newPrice) + recalcBuy(newPrice) + } catch { + // ignore — partial input like "5." or "-" is not yet valid + } + }, + [marketPrice, setValue, recalcBuy], + ) + + const handlePillEditCommit = useCallback( + (value: string) => { + setIsEditingPill(false) + applyPillValue(value) + // Persist the raw text so re-opening the editor shows it again. + setLastPillValue(value.trim()) + }, + [applyPillValue], + ) + + // ── Deviation display for pill ── + // Prefer the user's typed % verbatim (avoids the "15" → "15.01" + // drift from rounding). Fall back to computed deviation otherwise. + const deviationDisplay = (() => { + if (userPct !== null) { + const sign = userPct > 0 ? "+" : "" + return `${sign}${userPct.toFixed(2)}%` + } + if (deviation === null) return "0%" + const sign = deviation > 0 ? "+" : "" + return `${sign}${deviation.toFixed(2)}%` + })() + + /** Signed % vs market for styling (+ green / − red). */ + const signedDeviationPct = userPct ?? deviation ?? 0 + const pillTone: "neutral" | "positive" | "negative" = + signedDeviationPct > 0 + ? "positive" + : signedDeviationPct < 0 + ? "negative" + : "neutral" + + const showResetAction = + priceAnchor === "user" && Boolean(marketPrice) && !isEditingPill + + return ( + + {/* Price section — vertical padding uses sizes.l (20px token), not space.base. */} + + {/* Header: "When 1 HDX price is" (left) — custom % pill (right) */} + + + {t("trade:limit.priceLabel", { + symbol: isInverted + ? (buyAsset?.symbol ?? "") + : (sellAsset?.symbol ?? ""), + })} + + + {/* Custom % pill — same visual container in view & edit + modes, only the numeric text becomes an inline input. */} + + {isEditingPill ? ( + <> + e.target.select()} + // Live-recalc on every keystroke: price & receive + // amount reflect the typed % immediately without + // waiting for Enter/blur. Matches Matcha's behaviour. + onChange={(e) => applyPillValue(e.target.value)} + onBlur={(e) => handlePillEditCommit(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handlePillEditCommit( + (e.target as HTMLInputElement).value, + ) + } + if (e.key === "Escape") { + setIsEditingPill(false) + } + }} + /> + % + + ) : ( + <> + + {deviationDisplay} + + + + {showResetAction ? ( + + + + ) : ( + { + e.preventDefault() + handlePillEditStart() + }} + > + + + )} + + + )} + + + + + {/* Price input row — flip pill, then price + denom + fiat (Figma). */} + + setIsInverted((prev) => !prev)} + aria-label="Switch denomination" + > + + + + + { + if (source === "prop") return + handlePriceChange(value) + }} + placeholder="0" + sx={{ + fontWeight: 600, + fontSize: 16, + lineHeight: 1, + color: getToken("text.high"), + flex: 1, + minWidth: 0, + textAlign: "right", + }} + /> + + {denominationSuffix} + + + {showPriceFiatRow && ( + + {priceFiatLoading ? ( + + ) : ( + priceFiatDisplay + )} + + )} + + + + {/* Spot reset — extra top margin vs fiat so it’s clearly separated from the $ line */} + {spotDisplayValue && ( + + + }} + /> + + + )} + + + + + {/* Expiry selector */} + + + {t("trade:limit.expiry")} + + + {EXPIRY_OPTIONS.map((option) => ( + setValue("expiry", option)} + isActive={expiry === option} + > + {t(`trade:limit.expiry.${option}`)} + + ))} + + + + + + {/* Partially fillable toggle */} + + + + ]} + /> + + +
  • + ]} + /> +
  • +
  • + ]} + /> +
  • +
    +
    + } + > + + {t("trade:limit.partiallyFillable")} + + + + + {t("trade:limit.partiallyFillable.enabled")} + + + + setValue("partiallyFillable", !!checked) + } + /> + + + +
    + + ) +} + +// ── Styled components ── + +const SFlipPill = styled.button( + ({ theme }) => css` + all: unset; + box-sizing: border-box; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: ${theme.space.xs}; + padding: ${theme.space.base}; + border-radius: 30px; + border: 1px solid ${theme.buttons.secondary.low.borderRest}; + background: ${theme.buttons.secondary.low.rest}; + transition: ${theme.transitions.colors}; + + &:hover { + background: ${theme.buttons.secondary.low.hover}; + } + `, +) + +type PillTone = "neutral" | "positive" | "negative" + +const SCustomPill = styled.div<{ isActive?: boolean; tone?: PillTone }>( + ({ theme, isActive, tone = "neutral" }) => { + const idleNeutral = css` + color: ${theme.text.low}; + background: ${theme.buttons.secondary.low.rest}; + border: 1px solid ${theme.buttons.secondary.low.borderRest}; + + &:hover { + background: ${theme.buttons.secondary.low.hover}; + } + ` + + const activeNeutral = css` + cursor: default; + color: ${theme.buttons.secondary.accent.onRest}; + background: ${theme.buttons.secondary.accent.rest}; + border: 1px solid ${theme.buttons.secondary.accent.outline}; + + &:hover { + background: ${theme.buttons.secondary.accent.hover}; + } + ` + + const activeToned = css` + cursor: default; + + &:hover { + filter: brightness(1.03); + } + ` + + const idleByTone = + tone === "positive" + ? css` + color: ${theme.accents.success.emphasis}; + background: ${theme.accents.success.dim}; + border: 1px solid ${theme.accents.success.primary}; + + &:hover { + filter: brightness(1.04); + } + ` + : tone === "negative" + ? css` + color: ${theme.accents.danger.secondary}; + background: ${theme.accents.danger.dimBg}; + border: 1px solid ${theme.accents.danger.secondary}; + + &:hover { + filter: brightness(1.04); + } + ` + : idleNeutral + + const activeByTone = + tone === "positive" + ? css` + ${activeToned} + color: ${theme.accents.success.emphasis}; + background: ${theme.accents.success.dim}; + border: 1px solid ${theme.accents.success.primary}; + ` + : tone === "negative" + ? css` + ${activeToned} + color: ${theme.accents.danger.secondary}; + background: ${theme.accents.danger.dimBg}; + border: 1px solid ${theme.accents.danger.emphasis}; + ` + : activeNeutral + + return css` + box-sizing: border-box; + display: inline-flex; + align-items: stretch; + gap: 0; + /* Fixed 22px height: view ↔ edit stays aligned; px avoids rem/root drift (~24px). */ + height: 22px; + padding: 0 ${theme.space.base}; + border-radius: ${theme.radii.full}; + font-size: ${theme.fontSizes.p6}; + font-weight: 500; + line-height: 1; + transition: + ${theme.transitions.colors}, + filter 0.15s ease; + + ${isActive ? activeByTone : idleByTone} + ` + }, +) + +const SPillActions = styled.div( + ({ theme }) => css` + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + align-self: stretch; + margin-inline-start: ${theme.space.s}; + min-width: calc( + ${theme.space.s} + 1px + ${pxToRem(SPILL_SLICE_BUTTON_WIDTH_PX)} + ); + margin-inline-end: calc(-1 * ${theme.space.base}); + `, +) + +const SPillTrigger = styled.button( + ({ theme }) => css` + all: unset; + box-sizing: border-box; + cursor: pointer; + display: inline-flex; + align-items: center; + align-self: center; + min-width: 0; + color: inherit; + font: inherit; + line-height: inherit; + + &:focus-visible { + border-radius: ${theme.radii.full}; + outline: 2px solid ${theme.controls.outline.active}; + outline-offset: 1px; + } + `, +) + +const SPercentSuffix = styled.span( + ({ theme }) => css` + align-self: center; + margin-left: ${theme.space.xs}; + `, +) + +const SPillSeparator = styled.span( + ({ theme }) => css` + flex-shrink: 0; + align-self: stretch; + width: 1px; + margin-inline-end: 0; + margin-inline-start: ${theme.space.s}; + background: ${theme.controls.outline.base}; + `, +) + +/** Edit / close: full-height slice to the pill’s right edge, solid hover fill. */ +const SPillSliceButton = styled.button( + ({ theme }) => css` + all: unset; + box-sizing: border-box; + cursor: pointer; + display: flex; + flex: 0 0 ${pxToRem(SPILL_SLICE_BUTTON_WIDTH_PX)}; + align-items: center; + align-self: stretch; + justify-content: center; + width: ${pxToRem(SPILL_SLICE_BUTTON_WIDTH_PX)}; + line-height: 0; + padding: 0 ${theme.space.xs}; + border-radius: 0 ${theme.radii.full} ${theme.radii.full} 0; + color: inherit; + background: transparent; + transition: ${theme.transitions.colors}; + + &:hover { + background: ${theme.controls.dim.hover}; + } + + &:focus-visible { + outline: 2px solid ${theme.controls.outline.active}; + outline-offset: -1px; + } + `, +) + +/** + * Inline input embedded inside SCustomPill when editing. Styled to + * blend into the pill: no border, transparent background, inherits + * font/color from the pill so the swap between view and edit mode is + * visually stable. + */ +const SPillInlineInput = styled.input( + ({ theme }) => css` + all: unset; + /* Size just wide enough for a typical deviation like "-12.34". */ + width: 4ch; + align-self: center; + height: 1em; + flex-shrink: 0; + margin: 0; + padding: 0; + text-align: right; + font: inherit; + line-height: 1; + color: inherit; + + &::placeholder { + color: ${theme.text.low}; + opacity: 1; + } + + /* Hide the placeholder the moment the input is focused — the user + clicked to enter a value, not to read the old one. */ + &:focus::placeholder { + color: transparent; + } + `, +) + +const SSpotPrice = styled.span` + text-decoration: underline dotted; + text-underline-offset: 0.15em; +` + +const SSpotButton = styled.button( + ({ theme }) => css` + all: unset; + cursor: pointer; + font-size: ${theme.fontSizes.p5}; + font-weight: 500; + line-height: 1.2; + color: ${theme.text.medium}; + transition: ${theme.transitions.colors}; + + &:hover { + color: ${theme.text.high}; + } + `, +) + +const SEmphasis = styled.em` + font-style: italic; + font-weight: 500; +` + +const SBulletList = styled.ul` + list-style-type: disc; + padding-left: 16px; + margin: 0; + font-size: 12px; + line-height: 1.4; + + li + li { + margin-top: 4px; + } +` + +const SExpiryPill = styled.button<{ isActive?: boolean }>( + ({ theme, isActive }) => css` + all: unset; + box-sizing: border-box; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + height: 18px; + padding: 0 ${theme.space.base}; + border-radius: 20px; + font-size: 10px; + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + transition: all 0.15s ease; + + ${isActive + ? css` + color: ${theme.buttons.secondary.accent.onRest}; + background: ${theme.buttons.secondary.accent.hover}; + border: 1px solid ${theme.buttons.secondary.accent.outline}; + ` + : css` + color: ${theme.text.high}; + background: ${theme.buttons.secondary.low.rest}; + border: 1px solid ${theme.buttons.secondary.low.borderRest}; + + &:hover { + background: ${theme.buttons.secondary.low.hover}; + } + `} + `, +) diff --git a/apps/main/src/modules/trade/swap/sections/Limit/LimitSubmit.tsx b/apps/main/src/modules/trade/swap/sections/Limit/LimitSubmit.tsx new file mode 100644 index 0000000000..4a04725bab --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/LimitSubmit.tsx @@ -0,0 +1,29 @@ +import { Grid, LoadingButton } from "@galacticcouncil/ui/components" +import { FC } from "react" +import { useTranslation } from "react-i18next" + +import { AuthorizedAction } from "@/components/AuthorizedAction/AuthorizedAction" + +type Props = { + readonly isEnabled: boolean + readonly isLoading: boolean +} + +export const LimitSubmit: FC = ({ isEnabled, isLoading }) => { + const { t } = useTranslation("trade") + + return ( + + + + {t("limit.submit")} + + + + ) +} diff --git a/apps/main/src/modules/trade/swap/sections/Limit/LimitSwitcher.tsx b/apps/main/src/modules/trade/swap/sections/Limit/LimitSwitcher.tsx new file mode 100644 index 0000000000..8ff2bc048c --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/LimitSwitcher.tsx @@ -0,0 +1,78 @@ +import { css } from "@emotion/react" +import styled from "@emotion/styled" +import { ButtonTransparent, Flex, Icon } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { SELL_ONLY_ASSETS } from "@galacticcouncil/utils" +import { ArrowDown } from "lucide-react" +import { FC } from "react" +import { useFormContext } from "react-hook-form" + +import { LimitFormValues } from "@/modules/trade/swap/sections/Limit/useLimitForm" +import { useSwitchAssets } from "@/modules/trade/swap/sections/Limit/useSwitchAssets" + +export const LimitSwitcher: FC = () => { + const { watch } = useFormContext() + + const [sellAsset] = watch(["sellAsset"]) + + const switchAssets = useSwitchAssets() + + const isDisabled = + switchAssets.isPending || + (!!sellAsset && SELL_ONLY_ASSETS.includes(sellAsset.id)) + + return ( + + + switchAssets.mutate()} disabled={isDisabled}> + + + + + ) +} + +// ── Styled components ── + +const SSwitcherRow = styled(Flex)` + position: relative; +` + +const SLineShort = styled.div( + ({ theme }) => css` + flex-shrink: 0; + width: 32px; + height: 1px; + background: ${theme.details.borders}; + `, +) + +const SLine = styled.div( + ({ theme }) => css` + flex: 1; + height: 1px; + background: ${theme.details.borders}; + `, +) + +const SFlipButton = styled(ButtonTransparent)( + ({ theme }) => css` + border-radius: ${theme.radii.full}; + padding: 8px; + background: ${theme.controls.dim.base}; + transition: ${theme.transitions.transform}; + + &:hover:not([disabled]) { + background: ${theme.controls.dim.hover}; + transform: rotate(180deg); + } + + &[disabled] { + cursor: not-allowed; + } + `, +) diff --git a/apps/main/src/modules/trade/swap/sections/Limit/LimitWarning.tsx b/apps/main/src/modules/trade/swap/sections/Limit/LimitWarning.tsx new file mode 100644 index 0000000000..60820e3d1d --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/LimitWarning.tsx @@ -0,0 +1,48 @@ +import { css } from "@emotion/react" +import styled from "@emotion/styled" +import { Alert, Box, Text } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { FC } from "react" +import { useTranslation } from "react-i18next" + +const SInfoAlert = styled(Alert)` + width: 100%; + box-sizing: border-box; +` + +/** + * Persistent info callout for the Limit tab: intent-based limit orders are + * solver-matched, so execution at the exact specified price is not + * guaranteed when the market crosses it. + */ +export const LimitWarning: FC = () => { + const { t } = useTranslation("trade") + + return ( + + + {t("limit.warning.message")}{" "} + {/* TODO: wire the More Info link to docs once the URL is + finalized. */} + {t("limit.warning.moreInfo")} + + } + /> + + ) +} + +const SMoreInfo = styled.span( + ({ theme }) => css` + color: ${theme.accents.info.onPrimary}; + cursor: pointer; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } + `, +) diff --git a/apps/main/src/modules/trade/swap/sections/Limit/limitUtils.ts b/apps/main/src/modules/trade/swap/sections/Limit/limitUtils.ts new file mode 100644 index 0000000000..ebd6e7bbbc --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/limitUtils.ts @@ -0,0 +1,37 @@ +import Big from "big.js" + +/** + * Strip trailing zeros after the decimal point (and the decimal point + * itself if the fractional part is empty). "80000.00" → "80000", + * "0.00001250" → "0.0000125", "81237.40" → "81237.4". + */ +const stripTrailingZeros = (s: string): string => { + if (!s.includes(".") || s.includes("e") || s.includes("E")) return s + return s.replace(/\.?0+$/, "") +} + +/** + * Format a calculated Big value using the same significant-digit logic + * as the rest of the app (mirrors getMaxSignificantDigits from @galacticcouncil/utils). + * + * ≤ 1 → 4 significant digits (0.001834) + * 1–999 → 4 + intLen (543.544) + * 1000–99999 → 2 + intLen (2855) + * > 99999 → 0 + intLen (100000) + * + * Trailing zeros are stripped so user input like "80000" doesn't + * round-trip back as "80000.00". + */ +export const formatCalcValue = (value: Big): string => { + if (value.eq(0)) return "0" + const abs = value.abs() + if (abs.lte(1)) return stripTrailingZeros(value.toPrecision(4)) + + const intLen = Math.ceil(Math.log10(abs.toNumber() + 1)) + const sigDigits = Math.min( + 21, + (abs.gt(99999.9999) ? 0 : abs.gt(999.9999) ? 2 : 4) + intLen, + ) + + return stripTrailingZeros(value.toPrecision(sigDigits)) +} diff --git a/apps/main/src/modules/trade/swap/sections/Limit/useLimitForm.ts b/apps/main/src/modules/trade/swap/sections/Limit/useLimitForm.ts new file mode 100644 index 0000000000..dbb673d73b --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/useLimitForm.ts @@ -0,0 +1,104 @@ +import { useAccount } from "@galacticcouncil/web3-connect" +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema" +import { useEffect } from "react" +import { useForm } from "react-hook-form" +import * as z from "zod/v4" + +import { TAssetData } from "@/api/assets" +import { useAssets } from "@/providers/assetsProvider" +import { useAccountBalances } from "@/states/account" +import { + positiveOptional, + requiredObject, + useValidateFormMaxBalance, + validateAssetSellOnly, +} from "@/utils/validators" + +export const EXPIRY_OPTIONS = ["15min", "30min", "1h", "1d", "open"] as const +export type ExpiryOption = (typeof EXPIRY_OPTIONS)[number] + +const schemaBase = z.object({ + sellAsset: requiredObject(), + sellAmount: positiveOptional, + buyAsset: requiredObject().check(validateAssetSellOnly), + buyAmount: positiveOptional, + limitPrice: positiveOptional, + expiry: z.enum(EXPIRY_OPTIONS), + partiallyFillable: z.boolean(), + /** Lock toggle: false = price-sacred (default), true = sell-sacred */ + isLocked: z.boolean(), + /** + * Which amount was last touched by the user. On price change we + * recalculate the OTHER amount so the user's last explicit input + * is preserved. Lock ON overrides this and always forces 'sell'. + */ + amountAnchor: z.enum(["sell", "buy"]), + /** + * "spot" → limitPrice mirrors the live spot price every block. + * "user" → user has typed / edited a custom price or deviation %, + * so we stop auto-syncing. Reset to "spot" by clicking the + * Spot button or by changing assets. + */ + priceAnchor: z.enum(["spot", "user"]), +}) + +export type LimitFormValues = z.infer + +const useSchema = () => { + const { account } = useAccount() + const refineMaxBalance = useValidateFormMaxBalance() + + if (!account) { + return schemaBase + } + + return schemaBase.check( + refineMaxBalance("sellAmount", (form) => [form.sellAsset, form.sellAmount]), + ) +} + +type Args = { + readonly assetIn: string + readonly assetOut: string +} + +export const useLimitForm = ({ assetIn, assetOut }: Args) => { + const { account } = useAccount() + const { getAsset } = useAssets() + const { isBalanceLoaded, isBalanceLoading } = useAccountBalances() + + const defaultValues: LimitFormValues = { + sellAsset: getAsset(assetIn) ?? null, + sellAmount: "", + buyAsset: getAsset(assetOut) ?? null, + buyAmount: "", + limitPrice: "", + expiry: "open", + partiallyFillable: true, + isLocked: false, + amountAnchor: "sell", + priceAnchor: "spot", + } + + const form = useForm({ + defaultValues, + mode: "onChange", + resolver: standardSchemaResolver(useSchema()), + }) + + const { trigger, getValues } = form + + useEffect(() => { + const { sellAsset } = getValues() + + if (!account || !sellAsset) { + return + } + + if (isBalanceLoaded(sellAsset.id) || !isBalanceLoading) { + trigger("sellAmount") + } + }, [account, isBalanceLoading, trigger, getValues, isBalanceLoaded]) + + return form +} diff --git a/apps/main/src/modules/trade/swap/sections/Limit/useSubmitLimitOrder.ts b/apps/main/src/modules/trade/swap/sections/Limit/useSubmitLimitOrder.ts new file mode 100644 index 0000000000..a39c2315ec --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/useSubmitLimitOrder.ts @@ -0,0 +1,187 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useAccount } from "@galacticcouncil/web3-connect" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import Big from "big.js" +import { useTranslation } from "react-i18next" + +import { maxIntentDurationQuery } from "@/api/intents" +import { bestSellQuery } from "@/api/trade" +import { calculateSlippage } from "@/api/utils/slippage" +import { LimitFormValues } from "@/modules/trade/swap/sections/Limit/useLimitForm" +import { isErc20AToken, TAsset } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" +import { useTradeSettings } from "@/states/tradeSettings" +import { useTransactionsStore } from "@/states/transactions" +import { scale, scaleHuman } from "@/utils/formatting" + +// The Intent pallet works with native asset IDs. ERC20 wrapper tokens +// (e.g. HUSDT 1111) must be mapped to their underlying asset (USDT 111). +const getIntentAssetId = (asset: TAsset): number => { + if (isErc20AToken(asset)) { + return Number(asset.underlyingAssetId) + } + return Number(asset.id) +} + +// Expiry durations in milliseconds. Must stay within the runtime's +// `Intent.MaxAllowedIntentDuration` constant — going over it causes +// the extrinsic to fail with `Intent: InvalidDeadline`. The clamp +// below guarantees the deadline we submit is always strictly below +// the runtime cap, even under clock skew. +const EXPIRY_MS: Record = { + "15min": 15 * 60 * 1000, + "30min": 30 * 60 * 1000, + "1h": 60 * 60 * 1000, + "1d": 24 * 60 * 60 * 1000, +} + +/** + * Safety margin (ms) subtracted from the runtime's max intent duration + * before we use it to clamp `deadline - now`. Absorbs: + * - Clock skew between the user's machine and the chain + * - The time between `Date.now()` being sampled client-side and the + * tx actually being included in a block (usually 6–30s on Hydration) + * Without this buffer the "1 day" option sits exactly at the runtime + * cap and gets rejected with `Intent: InvalidDeadline`. + */ +const DEADLINE_SAFETY_MARGIN_MS = 60 * 1000 + +// When limit price is within this tolerance of the router quote rate we treat +// the order as "at market" and apply slippage buffer. +const MARKET_PRICE_TOLERANCE = 0.001 + +export const useSubmitLimitOrder = () => { + const { t } = useTranslation(["common", "trade"]) + const { account } = useAccount() + + const rpc = useRpcProvider() + const { papiClient } = rpc + + const { + swap: { + single: { swapSlippage }, + }, + } = useTradeSettings() + + const createTransaction = useTransactionsStore((s) => s.createTransaction) + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (values: LimitFormValues) => { + const { + sellAsset, + sellAmount, + buyAsset, + buyAmount, + limitPrice, + expiry, + partiallyFillable, + } = values + + if (!sellAsset || !buyAsset || !account) { + return + } + + const formattedSell = t("currency", { + value: sellAmount, + symbol: sellAsset.symbol, + }) + const formattedBuy = t("currency", { + value: buyAmount, + symbol: buyAsset.symbol, + }) + + // Build the deadline as `now + user-chosen window`, but clamp it + // to `(runtime max) - safety margin` so it never sits at or past + // the runtime cap. Fetched from chain; falls back to 24h. + const expiryMs = EXPIRY_MS[expiry] + let deadline: bigint | undefined + if (expiryMs !== undefined) { + const maxDurationMs = await queryClient.ensureQueryData( + maxIntentDurationQuery(rpc), + ) + const maxSafeMs = Number(maxDurationMs) - DEADLINE_SAFETY_MARGIN_MS + const effectiveMs = Math.max(1, Math.min(expiryMs, maxSafeMs)) + deadline = BigInt(Date.now() + effectiveMs) + } + + // Determine amount_out: + // - Market mode (limitPrice ~ router quote rate): apply slippage buffer + // - Custom price: send exact buyAmount + let amountOutRaw = BigInt(scale(buyAmount || "0", buyAsset.decimals)) + + try { + const bestSell = await queryClient.fetchQuery( + bestSellQuery(rpc, { + assetIn: sellAsset.id, + assetOut: buyAsset.id, + amountIn: sellAmount || "0", + }), + ) + if (bestSell && sellAmount && limitPrice) { + const marketRate = Big( + scaleHuman(bestSell.amountOut, buyAsset.decimals) || "0", + ).div(sellAmount) + if (marketRate.gt(0)) { + const drift = marketRate + .minus(Big(limitPrice)) + .abs() + .div(marketRate) + if (drift.lt(MARKET_PRICE_TOLERANCE)) { + amountOutRaw = + bestSell.amountOut - + calculateSlippage(bestSell.amountOut, swapSlippage) + } + } + } + } catch { + // Fall back to raw buyAmount if quote unavailable + } + + const unsafeApi = papiClient.getUnsafeApi() as any + const tx = unsafeApi.tx.Intent.submit_intent({ + intent: { + data: { + type: "Swap", + value: { + asset_in: getIntentAssetId(sellAsset), + asset_out: getIntentAssetId(buyAsset), + amount_in: BigInt(scale(sellAmount || "0", sellAsset.decimals)), + amount_out: amountOutRaw, + partial: partiallyFillable, + }, + }, + deadline, + on_resolved: undefined, + }, + }) + + return createTransaction( + { + tx, + toasts: { + submitted: t("trade:limit.tx.submitted", { + amountIn: formattedSell, + amountOut: formattedBuy, + }), + success: t("trade:limit.tx.success", { + amountIn: formattedSell, + amountOut: formattedBuy, + }), + error: t("trade:limit.tx.error", { + amountIn: formattedSell, + amountOut: formattedBuy, + }), + }, + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["intents", "byAccount", account?.address ?? ""], + }) + }, + }, + ) + }, + }) +} diff --git a/apps/main/src/modules/trade/swap/sections/Limit/useSwitchAssets.ts b/apps/main/src/modules/trade/swap/sections/Limit/useSwitchAssets.ts new file mode 100644 index 0000000000..ea6e28cf5c --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Limit/useSwitchAssets.ts @@ -0,0 +1,72 @@ +import { useMutation } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import Big from "big.js" +import { useFormContext } from "react-hook-form" + +import { LimitFormValues } from "@/modules/trade/swap/sections/Limit/useLimitForm" + +export const useSwitchAssets = () => { + const navigate = useNavigate() + const { reset, getValues, trigger } = useFormContext() + + return useMutation({ + mutationFn: async () => { + const values = getValues() + const { + sellAsset, + buyAsset, + sellAmount, + buyAmount, + limitPrice, + amountAnchor, + } = values + + // Invert the limit price. Store at full precision so the display + // layer (LimitPriceSection) can format without precision loss and + // flipping back still round-trips cleanly. + let newPrice = "" + if (limitPrice) { + try { + const priceBig = new Big(limitPrice) + if (priceBig.gt(0)) { + newPrice = Big(1).div(priceBig).toString() + } + } catch { + // ignore invalid price + } + } + + // Swap amounts: old sellAmount becomes new buyAmount (user was + // "selling N of X" — after flip they're now "buying N of X" by + // selling the opposite asset), and vice versa. + const newSellAmount = buyAmount + const newBuyAmount = sellAmount + + // Anchor follows the user's last-touched amount through the swap: + // the value that WAS in sell is now in buy and vice versa. + const newAnchor: "sell" | "buy" = amountAnchor === "sell" ? "buy" : "sell" + + reset({ + ...values, + sellAsset: buyAsset, + buyAsset: sellAsset, + sellAmount: newSellAmount, + buyAmount: newBuyAmount, + limitPrice: newPrice, + amountAnchor: newAnchor, + }) + + trigger() + + navigate({ + to: ".", + search: (search) => ({ + ...search, + assetIn: buyAsset?.id, + assetOut: sellAsset?.id, + }), + resetScroll: false, + }) + }, + }) +} diff --git a/apps/main/src/routes/trade/_history/swap.limit.tsx b/apps/main/src/routes/trade/_history/swap.limit.tsx new file mode 100644 index 0000000000..154edd2146 --- /dev/null +++ b/apps/main/src/routes/trade/_history/swap.limit.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { Limit } from "@/modules/trade/swap/sections/Limit/Limit" +import { SwapPageSkeleton } from "@/modules/trade/swap/SwapPageSkeleton" + +export const Route = createFileRoute("/trade/_history/swap/limit")({ + component: Limit, + pendingComponent: SwapPageSkeleton, +}) diff --git a/packages/ui/src/components/Input/Input.styled.ts b/packages/ui/src/components/Input/Input.styled.ts index cd53ae0711..703d082e6f 100644 --- a/packages/ui/src/components/Input/Input.styled.ts +++ b/packages/ui/src/components/Input/Input.styled.ts @@ -69,6 +69,13 @@ export const SInputContainer = styled.div< >(({ theme, customSize = "medium", variant = "standalone" }) => [ sizes(customSize), variants(variant), + ...(customSize === "medium" && variant === "embedded" + ? [ + css` + padding-right: 0; + `, + ] + : []), css` display: flex; gap: ${theme.space.s};