Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ yarn-error.log*
*.gen.*
*storybook.log
.cursor
.claude
.claude.mcp.json
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion apps/main/.env.production
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
3 changes: 3 additions & 0 deletions apps/main/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

# TanStack Router generated cache
.tanstack/
157 changes: 157 additions & 0 deletions apps/main/src/api/intents.ts
Original file line number Diff line number Diff line change
@@ -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<AccountIntent[]> => {
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)
},
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions apps/main/src/config/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 },
],
},
Expand Down Expand Up @@ -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"),
Expand Down
14 changes: 14 additions & 0 deletions apps/main/src/config/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions apps/main/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 28 additions & 1 deletion apps/main/src/i18n/locales/en/trade.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: <spotPrice>{{ value }}</spotPrice>",
"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</0> or <0>Fill or kill</0>.",
"limit.partiallyFillable.tooltip.partial": "<0>Partially fillable</0> orders may be filled partially if there isn't enough liquidity to fill the full amount.",
"limit.partiallyFillable.tooltip.fillOrKill": "<0>Fill or kill</0> 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 }}"
}
Loading
Loading