From 46c0ea4b5f51bc670bef76cd0ab8ee98e7d1bf6b Mon Sep 17 00:00:00 2001 From: vkulinich Date: Tue, 19 May 2026 12:42:58 +0200 Subject: [PATCH 01/18] Add max balance for trading based on trasanction cost --- .../trade/swap/sections/Market/Market.tsx | 21 ++++- .../swap/sections/Market/MarketFields.tsx | 4 +- .../swap/sections/Market/lib/useMarketForm.ts | 33 +++++-- .../sections/Market/lib/useMaxSellAmount.ts | 87 +++++++++++++++++++ .../swap/sections/Market/lib/useSwapFee.ts | 4 +- .../swap/sections/Market/lib/useTwapFee.ts | 5 +- .../transactions/hooks/useEstimateFee.ts | 2 +- .../hooks/useMaxBalanceWithFee.ts | 22 +++++ apps/main/src/utils/validators.ts | 4 +- 9 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts create mode 100644 apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts diff --git a/apps/main/src/modules/trade/swap/sections/Market/Market.tsx b/apps/main/src/modules/trade/swap/sections/Market/Market.tsx index 1c03d95c06..d976cec020 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/Market.tsx +++ b/apps/main/src/modules/trade/swap/sections/Market/Market.tsx @@ -5,6 +5,7 @@ import { FormProvider } from "react-hook-form" import { TradeType } from "@/api/trade" import { useMarketForm } from "@/modules/trade/swap/sections/Market/lib/useMarketForm" +import { useMaxSellAmount } from "@/modules/trade/swap/sections/Market/lib/useMaxSellAmount" import { useSubmitSwap } from "@/modules/trade/swap/sections/Market/lib/useSubmitSwap" import { useSubmitTwap } from "@/modules/trade/swap/sections/Market/lib/useSubmitTwap" import { useMarketBuyData } from "@/modules/trade/swap/sections/Market/Market.BuyData" @@ -24,7 +25,17 @@ export const Market: FC = () => { const submitSwap = useSubmitSwap() const submitTwap = useSubmitTwap() - const form = useMarketForm({ assetIn, assetOut }) + const { maxSwapSellBalance, maxTwapSellBalance } = useMaxSellAmount({ + assetIn, + assetOut, + }) + + const form = useMarketForm({ + assetIn, + assetOut, + maxSwapSellBalance, + maxTwapSellBalance, + }) const [type, isSingleTrade] = form.watch(["type", "isSingleTrade"]) const [healthFactorRiskAccepted, setHealthFactorRiskAccepted] = @@ -87,7 +98,13 @@ export const Market: FC = () => { : twap && submitTwap.mutate([values, twap]), )} > - + {isExpanded && ( = ({ swap, twap }) => { +export const MarketFields: FC = ({ swap, twap, maxSellBalance }) => { const { t } = useTranslation(["common", "trade"]) const { tradable } = useAssets() @@ -79,6 +80,7 @@ export const MarketFields: FC = ({ swap, twap }) => { label={t("sell")} assets={tradable} maxBalanceFallback="0" + maxBalance={maxSellBalance} onAssetChange={(sellAsset, previousSellAsset) => { const { buyAsset } = getValues() const isSwitch = sellAsset.id === buyAsset?.id diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useMarketForm.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useMarketForm.ts index 405ec5b22e..6582aea966 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/useMarketForm.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useMarketForm.ts @@ -9,10 +9,11 @@ import { TradeType } from "@/api/trade" import { useAssets } from "@/providers/assetsProvider" import { useAccountBalances } from "@/states/account" import { + maxBalanceError, positiveOptional, requiredObject, - useValidateFormMaxBalance, validateAssetSellOnly, + validateMaxBalance, } from "@/utils/validators" const schema = z.object({ @@ -24,16 +25,24 @@ const schema = z.object({ isSingleTrade: z.boolean(), }) -const useSchema = () => { +const useSchema = (maxSwapSellBalance: string, maxTwapSellBalance: string) => { const { account } = useAccount() - const refineMaxBalance = useValidateFormMaxBalance() if (!account) { return schema } - return schema.check( - refineMaxBalance("sellAmount", (form) => [form.sellAsset, form.sellAmount]), + return schema.refine( + (form) => { + return validateMaxBalance( + form.isSingleTrade ? maxSwapSellBalance : maxTwapSellBalance, + form.sellAmount, + ) + }, + { + error: maxBalanceError, + path: ["sellAmount"], + }, ) } @@ -42,11 +51,19 @@ export type MarketFormValues = z.infer> type Args = { readonly assetIn: string readonly assetOut: string + readonly maxSwapSellBalance: string + readonly maxTwapSellBalance: string } -export const useMarketForm = ({ assetIn, assetOut }: Args) => { +export const useMarketForm = ({ + assetIn, + assetOut, + maxSwapSellBalance, + maxTwapSellBalance, +}: Args) => { const { account } = useAccount() const { getAsset } = useAssets() + const { isBalanceLoaded, isBalanceLoading } = useAccountBalances() const defaultValues: MarketFormValues = { @@ -61,7 +78,9 @@ export const useMarketForm = ({ assetIn, assetOut }: Args) => { const form = useForm({ defaultValues, mode: "onChange", - resolver: standardSchemaResolver(useSchema()), + resolver: standardSchemaResolver( + useSchema(maxSwapSellBalance, maxTwapSellBalance), + ), }) const { trigger, getValues } = form diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts new file mode 100644 index 0000000000..650fb1e9cd --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts @@ -0,0 +1,87 @@ +import { useAccount } from "@galacticcouncil/web3-connect" +import { useQuery } from "@tanstack/react-query" + +import { useAccountFeePaymentAssetId } from "@/api/payments" +import { useMaxBalanceWithFee } from "@/modules/transactions/hooks/useMaxBalanceWithFee" +import { useAssets } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" +import { useAccountBalances } from "@/states/account" +import { useTradeSettings } from "@/states/tradeSettings" +import { scaleHuman } from "@/utils/formatting" + +export const useMaxSellAmount = ({ + assetIn, + assetOut, +}: { + assetIn: string + assetOut: string +}) => { + const { account } = useAccount() + const { getAssetWithFallback } = useAssets() + const rpc = useRpcProvider() + const { + swap: { + single: { swapSlippage }, + }, + } = useTradeSettings() + + const { data: accountFeePaymentAssetId } = useAccountFeePaymentAssetId() + const { getTransferableBalance } = useAccountBalances() + const enabled = + rpc.isApiLoaded && !!account && accountFeePaymentAssetId === Number(assetIn) + + const { data: tx } = useQuery({ + enabled, + queryKey: ["max-trade-balance", assetIn, assetOut], + queryFn: async () => { + const swap = await rpc.sdk.api.router.getBestSell( + Number(assetIn), + Number(assetOut), + "1", + ) + const twap = await rpc.sdk.api.scheduler.getTwapSellOrder( + Number(assetIn), + Number(assetOut), + "1", + ) + + const swapTx = await rpc.sdk.tx + .trade(swap) + .withSlippage(swapSlippage) + .withBeneficiary(account?.address ?? "") + .build() + .then((tx) => tx.get()) + + const twapTx = await rpc.sdk.tx + .order(twap) + .withBeneficiary(account?.address ?? "") + .build() + .then((tx) => tx.get()) + + return { + swapTx, + twapTx, + } + }, + }) + + const maxSwapBalanceWithFee = useMaxBalanceWithFee(tx?.swapTx ?? null) + const maxTwapBalanceWithFee = useMaxBalanceWithFee(tx?.twapTx ?? null) + + if (!enabled) { + const balance = scaleHuman( + getTransferableBalance(assetIn).toString(), + getAssetWithFallback(assetIn).decimals, + ) + + return { + maxSwapSellBalance: balance, + maxTwapSellBalance: balance, + } + } + + return { + maxSwapSellBalance: maxSwapBalanceWithFee?.maxBalanceHuman ?? "0", + maxTwapSellBalance: maxTwapBalanceWithFee?.maxBalanceHuman ?? "0", + } +} diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useSwapFee.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useSwapFee.ts index 93efd645a5..ebd9d7bad3 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/useSwapFee.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useSwapFee.ts @@ -1,4 +1,5 @@ import { QUERY_KEY_BLOCK_PREFIX } from "@galacticcouncil/utils" +import { useAccount } from "@galacticcouncil/web3-connect" import { useQuery } from "@tanstack/react-query" import { Trade } from "@/api/trade" @@ -8,13 +9,14 @@ import { useRpcProvider } from "@/providers/rpcProvider" export const useSwapFee = (swap: Trade) => { const { sdk } = useRpcProvider() + const { account } = useAccount() const { data: tx, isLoading: isTxLoading } = useQuery({ enabled: !!swap, queryKey: [QUERY_KEY_BLOCK_PREFIX, "trade", "swapFee", swap.type], queryFn: async () => { return sdk.tx .trade(swap) - .withBeneficiary(ENV.VITE_TRSRY_ADDR) + .withBeneficiary(account?.address ?? ENV.VITE_TRSRY_ADDR) .build() .then((tx) => tx.get()) }, diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useTwapFee.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useTwapFee.ts index f24415155a..a3d4df94b0 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/useTwapFee.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useTwapFee.ts @@ -1,4 +1,5 @@ import { QUERY_KEY_BLOCK_PREFIX } from "@galacticcouncil/utils" +import { useAccount } from "@galacticcouncil/web3-connect" import { useQuery } from "@tanstack/react-query" import { TradeOrder } from "@/api/trade" @@ -8,14 +9,14 @@ import { useRpcProvider } from "@/providers/rpcProvider" export const useTwapFee = (twap: TradeOrder) => { const { sdk } = useRpcProvider() - + const { account } = useAccount() const { data: tx, isLoading: isTxLoading } = useQuery({ enabled: !!twap, queryKey: [QUERY_KEY_BLOCK_PREFIX, "trade", "twapFee", twap.type], queryFn: async () => { return sdk.tx .order(twap) - .withBeneficiary(ENV.VITE_TRSRY_ADDR) + .withBeneficiary(account?.address ?? ENV.VITE_TRSRY_ADDR) .build() .then((tx) => tx.get()) }, diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 91cd560d4f..6017d42a0a 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -44,7 +44,6 @@ export const useEstimateFee = ( feePaymentAssetIdOverride || accountFeePaymentAssetId?.toString() const feeAsset = getAsset(feeAssetId ?? "") - const isEvmTx = !!anyTx && isEvmCall(anyTx) const tx = anyTx ? transformAnyToPapiTx(papi, anyTx) : null return useQuery({ @@ -65,6 +64,7 @@ export const useEstimateFee = ( queryFn: async () => { if (!tx) throw new Error("Invalid transaction") if (!feeAsset) throw new Error(`Asset ${feeAssetId} is not valid`) + const isEvmTx = !!anyTx && isEvmCall(anyTx) const getSpotPriceFn = getSpotPrice( sdk.api.router, diff --git a/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts new file mode 100644 index 0000000000..2103d87474 --- /dev/null +++ b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts @@ -0,0 +1,22 @@ +import Big from "big.js" + +import { useEstimateFee } from "@/modules/transactions/hooks/useEstimateFee" +import { AnyTransaction } from "@/modules/transactions/types" +import { useAssets } from "@/providers/assetsProvider" +import { scaleHuman } from "@/utils/formatting" + +export const useMaxBalanceWithFee = (tx: AnyTransaction | null) => { + const { getAssetWithFallback } = useAssets() + const { data: fee } = useEstimateFee(tx) + const meta = getAssetWithFallback(fee?.feeAssetId ?? "") + + if (!fee) return undefined + + return { + maxBalanceHuman: Big(fee.feeAssetBalance) + .minus(fee.feeEstimate) + .minus(scaleHuman(meta.existentialDeposit, meta.decimals)) + .toString(), + ...fee, + } +} diff --git a/apps/main/src/utils/validators.ts b/apps/main/src/utils/validators.ts index a3e3ec3db3..0a6b31e12a 100644 --- a/apps/main/src/utils/validators.ts +++ b/apps/main/src/utils/validators.ts @@ -80,7 +80,7 @@ export const validateFieldMaxBalance = (balance: string | number) => }) export const useValidateFormMaxBalance = () => { - const { getBalance } = useAccountBalances() + const { getTransferableBalance } = useAccountBalances() return ( path: Path>, @@ -94,7 +94,7 @@ export const useValidateFormMaxBalance = () => { return true } - const balance = getBalance(asset.id)?.transferable.toString() || "0" + const balance = getTransferableBalance(asset.id).toString() const balanceHuman = scaleHuman(balance, asset.decimals) return validateMaxBalance(balanceHuman, amount) From 4908cae3002e27d08e02250e9bf019001db36004 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Tue, 19 May 2026 16:20:24 +0200 Subject: [PATCH 02/18] add max balance to dca --- .../modules/trade/swap/sections/DCA/Dca.tsx | 19 +++- .../trade/swap/sections/DCA/DcaForm.tsx | 3 +- .../trade/swap/sections/DCA/useDcaForm.ts | 53 ++++++--- .../swap/sections/DCA/useMaxOrderBalance.ts | 104 ++++++++++++++++++ .../sections/Market/lib/useMaxSellAmount.ts | 2 +- 5 files changed, 161 insertions(+), 20 deletions(-) create mode 100644 apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts diff --git a/apps/main/src/modules/trade/swap/sections/DCA/Dca.tsx b/apps/main/src/modules/trade/swap/sections/DCA/Dca.tsx index 75cb224eac..b811299bf8 100644 --- a/apps/main/src/modules/trade/swap/sections/DCA/Dca.tsx +++ b/apps/main/src/modules/trade/swap/sections/DCA/Dca.tsx @@ -17,6 +17,7 @@ import { useDcaValidation, useOpenBudgetDcaHfValidation, } from "@/modules/trade/swap/sections/DCA/useDcaValidation" +import { useMaxOrderBalance } from "@/modules/trade/swap/sections/DCA/useMaxOrderBalance" import { useSubmitDcaOrder } from "@/modules/trade/swap/sections/DCA/useSubmitDcaOrder" import { SwapSectionSeparator } from "@/modules/trade/swap/SwapPage.styled" import { maxBalanceError } from "@/utils/validators" @@ -26,8 +27,18 @@ import { DcaOrdersMode, DEFAULT_DCA_DURATION, useDcaForm } from "./useDcaForm" export const Dca: FC = () => { const { t } = useTranslation(["trade"]) const { assetIn, assetOut } = useSearch({ from: "/trade/_history" }) + const { limitOrderMaxBalance, openBudgetOrderMaxBalance } = + useMaxOrderBalance({ + assetIn, + assetOut, + }) - const form = useDcaForm({ assetIn, assetOut }) + const form = useDcaForm({ + assetIn, + assetOut, + limitOrderMaxBalance, + openBudgetOrderMaxBalance, + }) const { order, @@ -138,7 +149,11 @@ export const Dca: FC = () => { /> )} /> - + { +export const DcaForm: FC<{ maxBalance: string }> = ({ maxBalance }) => { const { t } = useTranslation(["common", "trade"]) const { control, getValues, setValue, reset, trigger, watch } = useFormContext() @@ -123,6 +123,7 @@ export const DcaForm: FC = () => { : t("trade:dca.assetIn.title") } maxBalanceFallback="0" + maxBalance={maxBalance} onAssetChange={handleSellAssetChange} /> diff --git a/apps/main/src/modules/trade/swap/sections/DCA/useDcaForm.ts b/apps/main/src/modules/trade/swap/sections/DCA/useDcaForm.ts index 4324217122..a933050ab5 100644 --- a/apps/main/src/modules/trade/swap/sections/DCA/useDcaForm.ts +++ b/apps/main/src/modules/trade/swap/sections/DCA/useDcaForm.ts @@ -23,9 +23,10 @@ import { useRpcProvider } from "@/providers/rpcProvider" import { useAccountBalances } from "@/states/account" import { scaleHuman } from "@/utils/formatting" import { + maxBalanceError, positiveOptional, requiredObject, - useValidateFormMaxBalance, + validateMaxBalance, validNumber, } from "@/utils/validators" @@ -132,22 +133,35 @@ const schema = schemaBase } }) -const useSchema = (account: Account | null) => { +const useSchema = ( + account: Account | null, + limitOrderMaxBalance: string, + openBudgetOrderMaxBalance: string, +) => { const { t } = useTranslation(["common", "trade"]) const rpc = useRpcProvider() const queryClient = useQueryClient() - const refineMaxBalance = useValidateFormMaxBalance() - const { getBalance } = useAccountBalances() + const { getTransferableBalance } = useAccountBalances() + + if (!account) { + return schema + } const minBudgetSchema = schema - .check( - account - ? refineMaxBalance("sellAmount", (form) => [ - form.sellAsset, - form.sellAmount, - ]) - : z.refine(() => true), + .refine( + (form) => { + return validateMaxBalance( + form.orders.type === DcaOrdersMode.OpenBudget + ? openBudgetOrderMaxBalance + : limitOrderMaxBalance, + form.sellAmount, + ) + }, + { + error: maxBalanceError, + path: ["sellAmount"], + }, ) .check(async (ctx) => { const { sellAsset, sellAmount, orders } = ctx.value @@ -190,9 +204,7 @@ const useSchema = (account: Account | null) => { return } - const balance = getBalance(sellAsset.id) - - const minAmount = Big(balance?.transferable.toString() || "0") + const minAmount = Big(getTransferableBalance(sellAsset.id).toString()) .div(MIN_DCA_ORDERS) .toString() @@ -221,9 +233,16 @@ const useSchema = (account: Account | null) => { type Args = { readonly assetIn: string readonly assetOut: string + readonly limitOrderMaxBalance: string + readonly openBudgetOrderMaxBalance: string } -export const useDcaForm = ({ assetIn, assetOut }: Args) => { +export const useDcaForm = ({ + assetIn, + assetOut, + limitOrderMaxBalance, + openBudgetOrderMaxBalance, +}: Args) => { const { account } = useAccount() const { getAsset } = useAssets() const { isBalanceLoaded, isBalanceLoading } = useAccountBalances() @@ -241,7 +260,9 @@ export const useDcaForm = ({ assetIn, assetOut }: Args) => { const form = useForm({ mode: "onChange", defaultValues, - resolver: standardSchemaResolver(useSchema(account)), + resolver: standardSchemaResolver( + useSchema(account, limitOrderMaxBalance, openBudgetOrderMaxBalance), + ), }) const { trigger, getValues } = form diff --git a/apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts b/apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts new file mode 100644 index 0000000000..9e322fd21d --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts @@ -0,0 +1,104 @@ +import { useAccount } from "@galacticcouncil/web3-connect" +import { useQuery } from "@tanstack/react-query" + +import { useAccountFeePaymentAssetId } from "@/api/payments" +import { minimumOrderBudgetQuery } from "@/api/trade" +import { getTimeFrameMillis } from "@/components/TimeFrame/TimeFrame.utils" +import { DEFAULT_DCA_DURATION } from "@/modules/trade/swap/sections/DCA/useDcaForm" +import { useMaxBalanceWithFee } from "@/modules/transactions/hooks/useMaxBalanceWithFee" +import { useAssets } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" +import { useAccountBalances } from "@/states/account" +import { useTradeSettings } from "@/states/tradeSettings" +import { scaleHuman } from "@/utils/formatting" + +export const useMaxOrderBalance = ({ + assetIn, + assetOut, +}: { + assetIn: string + assetOut: string +}) => { + const { account } = useAccount() + const { getAssetWithFallback } = useAssets() + const rpc = useRpcProvider() + const { data: accountFeePaymentAssetId } = useAccountFeePaymentAssetId() + const { + dca: { slippage, maxRetries }, + } = useTradeSettings() + const { getTransferableBalance } = useAccountBalances() + const enabled = + rpc.isApiLoaded && !!account && accountFeePaymentAssetId === Number(assetIn) + const meta = getAssetWithFallback(assetIn) + + const { data: tx } = useQuery({ + enabled, + queryKey: ["maxOrderAmount", assetIn, assetOut], + queryFn: async () => { + const minAmount = await rpc.queryClient.ensureQueryData( + minimumOrderBudgetQuery(rpc, meta.id, meta.decimals), + ) + + const minAmountHuman = scaleHuman(minAmount.toString(), meta.decimals) + + const openBudgetDcaOrder = + await rpc.sdk.api.scheduler.getOpenBudgetDcaOrder( + Number(assetIn), + Number(assetOut), + minAmountHuman, + getTimeFrameMillis(DEFAULT_DCA_DURATION), + ) + const dcaOrder = await rpc.sdk.api.scheduler.getDcaOrder( + Number(assetIn), + Number(assetOut), + minAmountHuman, + getTimeFrameMillis(DEFAULT_DCA_DURATION), + undefined, + ) + + const dcaOrderTx = await rpc.sdk.tx + .order(dcaOrder) + .withBeneficiary(account?.address ?? "") + .withSlippage(slippage) + .withMaxRetries(maxRetries) + .build() + .then((tx) => tx.get()) + + const openBudgetDcaOrderTx = await rpc.sdk.tx + .order(openBudgetDcaOrder) + .withBeneficiary(account?.address ?? "") + .withSlippage(slippage) + .withMaxRetries(maxRetries) + .build() + .then((tx) => tx.get()) + + return { + dcaOrderTx, + openBudgetDcaOrderTx, + } + }, + }) + + const limitOrderBalanceWithFee = useMaxBalanceWithFee(tx?.dcaOrderTx ?? null) + const openBudgetOrderBalanceWithFee = useMaxBalanceWithFee( + tx?.openBudgetDcaOrderTx ?? null, + ) + + if (!enabled) { + const balance = scaleHuman( + getTransferableBalance(assetIn).toString(), + meta.decimals, + ) + + return { + limitOrderMaxBalance: balance, + openBudgetOrderMaxBalance: balance, + } + } + + return { + limitOrderMaxBalance: limitOrderBalanceWithFee?.maxBalanceHuman ?? "0", + openBudgetOrderMaxBalance: + openBudgetOrderBalanceWithFee?.maxBalanceHuman ?? "0", + } +} diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts index 650fb1e9cd..2dd7827ed1 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts @@ -32,7 +32,7 @@ export const useMaxSellAmount = ({ const { data: tx } = useQuery({ enabled, - queryKey: ["max-trade-balance", assetIn, assetOut], + queryKey: ["maxSellAmount", assetIn, assetOut], queryFn: async () => { const swap = await rpc.sdk.api.router.getBestSell( Number(assetIn), From 7949717f5b036d539381197276f102111003b4a6 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Wed, 27 May 2026 15:24:00 +0200 Subject: [PATCH 03/18] fix negative max balance --- .../transactions/hooks/useMaxBalanceWithFee.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts index 2103d87474..4bd6b3531d 100644 --- a/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts +++ b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts @@ -13,10 +13,13 @@ export const useMaxBalanceWithFee = (tx: AnyTransaction | null) => { if (!fee) return undefined return { - maxBalanceHuman: Big(fee.feeAssetBalance) - .minus(fee.feeEstimate) - .minus(scaleHuman(meta.existentialDeposit, meta.decimals)) - .toString(), + maxBalanceHuman: Big.max( + 0, + Big(fee.feeAssetBalance) + .minus(fee.feeEstimate) + .minus(scaleHuman(meta.existentialDeposit, meta.decimals)) + .toString(), + ), ...fee, } } From 00b6c1aeb4e4257e61dca403fcda027d50a5d6b9 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Wed, 27 May 2026 15:42:12 +0200 Subject: [PATCH 04/18] fix lint --- .../src/modules/transactions/hooks/useMaxBalanceWithFee.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts index 4bd6b3531d..e87adc2a14 100644 --- a/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts +++ b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts @@ -17,9 +17,8 @@ export const useMaxBalanceWithFee = (tx: AnyTransaction | null) => { 0, Big(fee.feeAssetBalance) .minus(fee.feeEstimate) - .minus(scaleHuman(meta.existentialDeposit, meta.decimals)) - .toString(), - ), + .minus(scaleHuman(meta.existentialDeposit, meta.decimals)), + ).toString(), ...fee, } } From dc2ea381fe851417e81bb43545d260aa69c9dbfb Mon Sep 17 00:00:00 2001 From: vkulinich Date: Thu, 28 May 2026 10:51:36 +0200 Subject: [PATCH 05/18] fix balance updating --- .../transactions/hooks/useEstimateFee.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 6017d42a0a..66a40014d8 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -46,8 +46,18 @@ export const useEstimateFee = ( const tx = anyTx ? transformAnyToPapiTx(papi, anyTx) : null + const feeAssetBalance = feeAsset + ? scaleHuman(getTransferableBalance(feeAsset.id), feeAsset.decimals) + : "0" + return useQuery({ placeholderData: keepPreviousData, + select: (data) => { + return { + ...data, + feeAssetBalance, + } + }, enabled: isLoaded && !!tx && @@ -83,11 +93,6 @@ export const useEstimateFee = ( ? Big(extraFee).add(feeEstimateNativeBase).toString() : feeEstimateNativeBase - const feeAssetBalance = scaleHuman( - getTransferableBalance(feeAsset.id), - feeAsset.decimals, - ) - if (spot?.spotPrice) { const feeEstimate = Big(feeEstimateNative) .mul(spot.spotPrice) @@ -96,7 +101,6 @@ export const useEstimateFee = ( return { feeEstimateNative, feeEstimate, - feeAssetBalance, feeAssetId, } } @@ -123,7 +127,6 @@ export const useEstimateFee = ( return { feeEstimateNative, feeEstimate, - feeAssetBalance, feeAssetId, } }, From 20969ec332522b8ff38e1d1f881eef3bb1b09248 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Thu, 18 Jun 2026 16:54:01 +0200 Subject: [PATCH 06/18] add max balance across the app --- .../AddIsolatedLiquidity.tsx | 7 +- .../AddIsolatedLiquidity.utils.ts | 44 ++-- .../AddLiquidity/AddLiqudity.utils.ts | 50 ++--- .../components/AddLiquidity/AddLiquidity.tsx | 4 +- .../AddMoneyMarketLiquidity.utils.tsx | 28 +-- .../AddStablepoolLiquidity.tsx | 6 +- .../AddStablepoolLiquidity.utils.ts | 72 +++---- .../CreateIsolatedPool/CreateIsolatedPool.tsx | 4 +- .../CreateIsolatedPool.utils.ts | 38 ++-- apps/main/src/modules/staking/Stake.form.ts | 123 ++++++++++- apps/main/src/modules/staking/Stake.stake.ts | 93 -------- apps/main/src/modules/staking/StakeForm.tsx | 17 +- .../trade/otc/fill-order/FillOrder.utils.ts | 24 +++ .../fill-order/FillOrderModalContent.form.ts | 30 ++- .../FillOrderModalContent.submit.ts | 28 +-- .../otc/fill-order/FillOrderModalContent.tsx | 20 +- .../PlaceOrderModalContent.form.ts | 141 +++++++------ .../PlaceOrderModalContent.submit.ts | 9 +- .../place-order/PlaceOrderModalContent.tsx | 5 +- .../transactions/hooks/useEstimateFee.ts | 39 +++- .../hooks/useFormMaxBalanceWithFee.ts | 61 ++++++ .../transactions/hooks/useMaxBalance.ts | 41 ++++ .../hooks/useMaxBalanceWithFee.ts | 14 +- .../hooks/useTransactionAlerts.ts | 12 +- .../modules/transactions/utils/permitFee.ts | 198 ++++++++++++++++++ .../assets/Transfer/TransferPosition.form.ts | 45 ++-- .../assets/Transfer/TransferPositionModal.tsx | 5 +- .../src/signers/EthereumSigner.ts | 33 +-- packages/web3-connect/src/utils/index.ts | 1 + packages/web3-connect/src/utils/permitGas.ts | 31 +++ 30 files changed, 841 insertions(+), 382 deletions(-) delete mode 100644 apps/main/src/modules/staking/Stake.stake.ts create mode 100644 apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts create mode 100644 apps/main/src/modules/transactions/hooks/useMaxBalance.ts create mode 100644 apps/main/src/modules/transactions/utils/permitFee.ts create mode 100644 packages/web3-connect/src/utils/permitGas.ts diff --git a/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.tsx b/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.tsx index 950f3efc97..3dbf933ccf 100644 --- a/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.tsx +++ b/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.tsx @@ -69,12 +69,11 @@ export const AddIsolatedLiquidityForm = ({ const { form, + getMaxBalance, reserveA, reserveB, ratio, mutation, - assetABalance, - assetBBalance, assetAMeta, assetBMeta, shares, @@ -127,7 +126,7 @@ export const AddIsolatedLiquidityForm = ({ assetFieldName="assetA" amountFieldName="amountA" assets={[]} - maxBalance={assetABalance} + maxBalance={getMaxBalance(assetAMeta)} disabledAssetSelector onAmountChange={(value) => { form.setValue("lastUpdated", "assetA") @@ -166,7 +165,7 @@ export const AddIsolatedLiquidityForm = ({ assetFieldName="assetB" amountFieldName="amountB" assets={[]} - maxBalance={assetBBalance} + maxBalance={getMaxBalance(assetBMeta)} disabledAssetSelector onAmountChange={(value) => { form.setValue("lastUpdated", "assetB") diff --git a/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.utils.ts b/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.utils.ts index e04f968dec..376309730d 100644 --- a/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.utils.ts +++ b/apps/main/src/modules/liquidity/components/AddIsolatedLiquidity/AddIsolatedLiquidity.utils.ts @@ -15,12 +15,15 @@ import { PoolToken } from "@/api/pools" import { spotPriceQuery } from "@/api/spotPrice" import { TXYKConsts, XYKPoolWithLiquidity } from "@/api/xyk" import { useXYKFarmMinShares } from "@/modules/liquidity/components/JoinFarms/JoinFarms.utils" +import { + useFormMaxBalanceWithFee, + ValidateFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import { TShareToken } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" -import { useAccountBalances } from "@/states/account" import { useTransactionsStore } from "@/states/transactions" import { scale, scaleHuman } from "@/utils/formatting" -import { positive, required, validateFieldMaxBalance } from "@/utils/validators" +import { positive, required } from "@/utils/validators" export const orders = ["assetA", "assetB"] as const type Order = (typeof orders)[number] @@ -35,8 +38,7 @@ export type TAddIsolatedLiquidityFormValues = { } export const useAddIsolatedLiquidityZod = ( - balanceA: string, - balanceB: string, + validateBalance: ValidateFormMaxBalanceWithFee, minTradingLimit: bigint, minPoolLiquidity: bigint, ) => { @@ -44,8 +46,8 @@ export const useAddIsolatedLiquidityZod = ( return z .object({ - amountA: required.pipe(positive).check(validateFieldMaxBalance(balanceA)), - amountB: required.check(validateFieldMaxBalance(balanceB)), + amountA: required.pipe(positive), + amountB: required, assetA: z.custom(), assetB: z.custom(), lastUpdated: z.literal(["assetA", "assetB"]), @@ -55,6 +57,10 @@ export const useAddIsolatedLiquidityZod = ( error: t("liquidity.add.modal.validation.minPoolLiquidity"), }), }) + .check( + validateBalance("amountA", (form) => [form.assetA, form.amountA]), + validateBalance("amountB", (form) => [form.assetB, form.amountB]), + ) .refine( ({ amountA, assetA }) => { const minAssetATradingLimit = Big( @@ -104,7 +110,6 @@ export const useAddIsolatedLiquidity = ({ const { t } = useTranslation("liquidity") const { papi } = useRpcProvider() const createTransaction = useTransactionsStore((s) => s.createTransaction) - const { getTransferableBalance } = useAccountBalances() const { data: farms } = useIsolatedPoolFarms(shareTokenMeta.poolAddress) const activeFarms = farms?.filter((farm) => farm.apr !== "0") ?? [] const isFarms = activeFarms.length > 0 @@ -117,22 +122,22 @@ export const useAddIsolatedLiquidity = ({ const [assetAMeta, assetBMeta] = shareTokenMeta.assets - const assetABalance = scaleHuman( - getTransferableBalance(assetAMeta.id), - assetAMeta.decimals, - ) - const assetBBalance = scaleHuman( - getTransferableBalance(assetBMeta.id), - assetBMeta.decimals, - ) - const { data: spotPriceData, isPending: isSpotPricePending } = useQuery( spotPriceQuery(rpc, assetAMeta.id, assetBMeta.id), ) + const addLiquidityTx = papi.tx.XYK.add_liquidity({ + asset_a: Number(assetAMeta.id), + asset_b: Number(assetBMeta.id), + amount_a: BigInt(scale("1", assetAMeta.decimals)), + amount_b_max_limit: BigInt(scale("1", assetBMeta.decimals)), + }) + + const { validateBalance, getMaxBalance } = + useFormMaxBalanceWithFee(addLiquidityTx) + const zodSchema = useAddIsolatedLiquidityZod( - assetABalance, - assetBBalance, + validateBalance, pool.minTradingLimit, consts.minPoolLiquidity, ) @@ -236,12 +241,11 @@ export const useAddIsolatedLiquidity = ({ return { form, + getMaxBalance, reserveA, reserveB, ratio, mutation, - assetABalance, - assetBBalance, assetAMeta, assetBMeta, shares, diff --git a/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiqudity.utils.ts b/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiqudity.utils.ts index e6c9e68660..103bf02419 100644 --- a/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiqudity.utils.ts +++ b/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiqudity.utils.ts @@ -21,15 +21,15 @@ import { useOmnipoolAssetsData, useOmnipoolMinLiquidity, } from "@/api/omnipool" -import i18n from "@/i18n" import { useMinOmnipoolFarmJoin } from "@/modules/liquidity/components/JoinFarms/JoinFarms.utils" +import { useFormMaxBalanceWithFee } from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" +import { AnyTransaction } from "@/modules/transactions/types" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" -import { useAccountBalances } from "@/states/account" import { useTradeSettings } from "@/states/tradeSettings" import { useTransactionsStore } from "@/states/transactions" -import { scale, scaleHuman, toDecimal } from "@/utils/formatting" -import { positive, required, validateMaxBalance } from "@/utils/validators" +import { scale, scaleHuman } from "@/utils/formatting" +import { positive, required } from "@/utils/validators" export type TAddLiquidityFormValues = { amount: string; asset: TAssetData } @@ -200,38 +200,30 @@ export const useAddLiquidityForm = ({ initialAmount, selectedAsset, rule, + tx, }: { selectedAsset: TAssetData initialAmount?: string rule?: ZodType | undefined + tx: AnyTransaction | null }) => { - const { getTransferableBalance } = useAccountBalances() + const { validateBalance, getMaxBalance } = useFormMaxBalanceWithFee(tx, 3) const form = useForm({ mode: "onChange", defaultValues: { amount: initialAmount ?? "", asset: selectedAsset }, resolver: rule ? standardSchemaResolver( - z.object({ amount: rule, asset: z.custom() }).refine( - (values) => { - return validateMaxBalance( - toDecimal( - getTransferableBalance(values.asset.id), - values.asset.decimals, - ), - values.amount, - ) - }, - { - message: i18n.t("error.maxBalance"), - path: ["amount"], - }, - ), + z + .object({ amount: rule, asset: z.custom() }) + .check( + validateBalance("amount", (form) => [form.asset, form.amount]), + ), ) : undefined, }) - return form + return { form, getMaxBalance } } export const useAddLiquidity = ({ @@ -264,19 +256,26 @@ export const useAddLiquidity = ({ : false const addLiquidityZod = useAddToOmnipoolZod(poolId) - const underlyingAssetId = isErc20Asset - ? getErc20AToken(poolId)?.underlyingAssetId - : undefined - const form = useAddLiquidityForm({ + const addLiquidityTx = papi.tx.Omnipool.add_liquidity_with_limit({ + asset: Number(poolId), + amount: BigInt(scale("1", poolMeta.decimals)), + min_shares_limit: 0n, + }) + + const { form, getMaxBalance } = useAddLiquidityForm({ initialAmount: "", selectedAsset: poolMeta, rule: addLiquidityZod, + tx: addLiquidityTx, }) const amount = Big(form.watch("amount") || "0") const selectedAsset = form.watch("asset") const isErc20SelectedAsset = isErc20AToken(selectedAsset) + const underlyingAssetId = isErc20Asset + ? getErc20AToken(poolId)?.underlyingAssetId + : undefined const { isJoinFarms, joinFarmErrorMessage, activeFarms } = useCheckJoinOmnipoolFarm({ @@ -380,6 +379,7 @@ export const useAddLiquidity = ({ return { form, + getMaxBalance, liquidityShares, isFarms, activeFarms, diff --git a/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiquidity.tsx b/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiquidity.tsx index 3c8cfc0152..4982a875df 100644 --- a/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiquidity.tsx +++ b/apps/main/src/modules/liquidity/components/AddLiquidity/AddLiquidity.tsx @@ -45,6 +45,7 @@ export const AddLiquidity: FC = ({ const { form, + getMaxBalance, liquidityShares, poolMeta, activeFarms, @@ -56,7 +57,7 @@ export const AddLiquidity: FC = ({ healthFactor, } = useAddLiquidity({ poolId: id, onSubmitted }) - const { formState, handleSubmit } = form + const { formState, handleSubmit, watch } = form const customErrors = getCustomErrors(formState.errors.amount) return ( @@ -73,6 +74,7 @@ export const AddLiquidity: FC = ({ assetFieldName="asset" amountFieldName="amount" assets={underlyingAssetMeta ? [underlyingAssetMeta, poolMeta] : []} + maxBalance={getMaxBalance(watch("asset"))} sx={{ pt: 0 }} disabledAssetSelector={!underlyingAssetMeta} /> diff --git a/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx b/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx index 83b113c7a5..4c03e4ff0b 100644 --- a/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx +++ b/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx @@ -28,6 +28,7 @@ import { } from "@/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.utils" import { useMinimumTradeAmount } from "@/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.utils" import { useAddableStablepoolTokens } from "@/modules/liquidity/Liquidity.utils" +import { useFormMaxBalanceWithFee } from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { useAccountBalances } from "@/states/account" @@ -44,14 +45,14 @@ export type TAddMoneyMarketLiquidityWrapperReturn = Omit< > export const useAddMoneyMarketLiquidityWrapper = ({ - stablepoolDetails: { reserves }, + stablepoolDetails: { reserves, pool }, stableswapId, erc20Id, initialOption, split: initialSplit, }: AddMoneyMarketLiquidityWrapperProps) => { const { getAssetWithFallback } = useAssets() - const { balances } = useAccountBalances() + const { papi } = useRpcProvider() const addableReserves = useAddableStablepoolTokens(stableswapId, reserves) const { data: omnipoolIds } = useOmnipoolIds() const isAddableToOmnipool = omnipoolIds?.includes(erc20Id) @@ -67,15 +68,16 @@ export const useAddMoneyMarketLiquidityWrapper = ({ reserve.asset_id.toString(), ) - const accountBalances = new Map( - Object.values(balances).map((balance) => [ - balance.assetId, - scaleHuman( - balance.transferable, - getAssetWithFallback(balance.assetId).decimals, - ), - ]), - ) + const addLiquidityTx = papi.tx.Stableswap.add_assets_liquidity({ + pool_id: pool.id, + assets: addableReserves.map((reserve) => ({ + asset_id: reserve.asset_id, + amount: BigInt(scale("1", reserve.meta.decimals)), + })), + min_shares: 0n, + }) + + const { getMaxBalance } = useFormMaxBalanceWithFee(addLiquidityTx, 5) const defaultOption = initialOption || (isAddableToOmnipool ? "omnipool" : "stablepool") @@ -95,7 +97,7 @@ export const useAddMoneyMarketLiquidityWrapper = ({ stablepoolId: stableswapId, omnipoolId: isAddableToOmnipool ? erc20Id : stableswapId, selectedAssetId: initialAssetIdToAdd ?? "", - accountBalances, + getMaxBalance, option: defaultOption, activeFieldIds: reserveIds, split: !enabledSplit ? false : initialSplit, @@ -126,7 +128,7 @@ export const useAddMoneyMarketLiquidityWrapper = ({ return { form, - accountBalances, + getMaxBalance, assetsToSelect: split ? [] : assetsToSelect, meta, reserveIds, diff --git a/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.tsx b/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.tsx index 27574d36dd..fd6d0a9124 100644 --- a/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.tsx +++ b/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.tsx @@ -153,7 +153,7 @@ export const AddStablepoolLiquidityForm = ({ stableswapId, closable, onBack, - accountBalances, + getMaxBalance, assetsToSelect, minReceiveAmount, meta, @@ -296,7 +296,7 @@ export const AddStablepoolLiquidityForm = ({ {activeFields.map((field, index) => { - const balance = accountBalances.get(field.assetId) ?? "0" + const asset = getAssetWithFallback(field.assetId) return ( @@ -311,7 +311,7 @@ export const AddStablepoolLiquidityForm = ({ label={t("liquidity.add.modal.selectAsset")} assets={[]} sortedAssets={assetsToSelect} - maxBalance={balance} + maxBalance={getMaxBalance(asset)} selectedAsset={getAssetWithFallback(value.assetId)} amountError={error?.message} value={value.amount} diff --git a/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.utils.ts b/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.utils.ts index 82bf13f4b3..7d09d2ddb0 100644 --- a/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.utils.ts +++ b/apps/main/src/modules/liquidity/components/AddStablepoolLiquidity/AddStablepoolLiquidity.utils.ts @@ -7,7 +7,7 @@ import Big from "big.js" import { useEffect, useMemo } from "react" import { ResolverOptions, useForm } from "react-hook-form" import { useTranslation } from "react-i18next" -import z from "zod" +import z from "zod/v4" import { omnipoolMiningPositionsKey, omnipoolPositionsKey } from "@/api/account" import { AssetType, TAssetData } from "@/api/assets" @@ -25,6 +25,10 @@ import { TStablepoolDetails, useAddableStablepoolTokens, } from "@/modules/liquidity/Liquidity.utils" +import { + GetMaxBalanceWithFee, + useFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { useAccountBalances } from "@/states/account" @@ -87,7 +91,6 @@ export const useStablepoolAddLiquidity = ({ const { getAssetWithFallback } = useAssets() const { papi } = useRpcProvider() const createTransaction = useTransactionsStore((s) => s.createTransaction) - const { getTransferableBalance } = useAccountBalances() const { liquidity: { slippage }, } = useTradeSettings() @@ -97,31 +100,30 @@ export const useStablepoolAddLiquidity = ({ const { data: omnipoolIds } = useOmnipoolIds() const isAddableToOmnipool = omnipoolIds?.includes(pool.id.toString()) - const { stablepoolAssets, accountBalances } = useMemo(() => { - const stablepoolAssets: { asset: TAssetData; balance: string }[] = [] - const accountBalances: Map = new Map() - for (const reserve of reserves) { - stablepoolAssets.push({ + const stablepoolAssets = useMemo( + () => + reserves.map((reserve) => ({ asset: reserve.meta, balance: reserve.amount, - }) - - accountBalances.set( - reserve.asset_id.toString(), - scaleHuman( - getTransferableBalance(reserve.asset_id.toString()), - reserve.meta.decimals, - ), - ) - } - - return { stablepoolAssets, accountBalances } - }, [reserves, getTransferableBalance]) + })), + [reserves], + ) const reserveIds = addebleReserves.map((reserve) => reserve.asset_id.toString(), ) + const addLiquidityTx = papi.tx.Stableswap.add_assets_liquidity({ + pool_id: pool.id, + assets: addebleReserves.map((reserve) => ({ + asset_id: reserve.asset_id, + amount: BigInt(scale("1", reserve.meta.decimals)), + })), + min_shares: 0n, + }) + + const { getMaxBalance } = useFormMaxBalanceWithFee(addLiquidityTx, 3) + const assetsToSelect = useAssetsToAddToStablepool({ reserves: addebleReserves, }) @@ -131,7 +133,7 @@ export const useStablepoolAddLiquidity = ({ const form = useStablepoolAddLiquidityForm({ stablepoolId: stableswapId, omnipoolId: stableswapId, - accountBalances, + getMaxBalance, activeFieldIds: reserveIds, selectedAssetId: initialAssetIdToAdd ?? "", split: enabledSplit, @@ -257,7 +259,7 @@ export const useStablepoolAddLiquidity = ({ return { form, - accountBalances, + getMaxBalance, assetsToSelect, minReceiveAmount, meta, @@ -311,10 +313,11 @@ export const getStablepoolShares = ( const useStablepoolAddLiquidityFormResolver = ( stablepoolId: string, - accountReserveBalances: Map, + getMaxBalance: GetMaxBalanceWithFee, omnipoolId?: string, ) => { const omnipoolZodSchema = useAddToOmnipoolZod(omnipoolId) + const { getAssetWithFallback } = useAssets() const { data: reserves } = useBorrowReserves() const assetReserve = reserves?.formattedReserves.find( @@ -338,15 +341,12 @@ const useStablepoolAddLiquidityFormResolver = ( assetId: z.string(), }) .refine( - (field) => { - const maxBalance = accountReserveBalances.get(field.assetId) - - if (!maxBalance) return false - return validateMaxBalance(maxBalance, field.amount || "0") - }, - { - message: maxBalanceError, - }, + (field) => + validateMaxBalance( + getMaxBalance(getAssetWithFallback(field.assetId)), + field.amount || "0", + ), + { error: maxBalanceError }, ), ) @@ -359,7 +359,7 @@ const useStablepoolAddLiquidityFormResolver = ( return Big(value).lte(maxCapToAdd) }, { - message: maxCapToAdd, + error: maxCapToAdd, path: ["supplyCap"], }, ), @@ -386,7 +386,7 @@ const useStablepoolAddLiquidityFormResolver = ( export const useStablepoolAddLiquidityForm = ({ omnipoolId, stablepoolId, - accountBalances, + getMaxBalance, option = "omnipool", activeFieldIds, selectedAssetId, @@ -395,14 +395,14 @@ export const useStablepoolAddLiquidityForm = ({ omnipoolId: string stablepoolId: string option?: TAddStablepoolLiquidityOption - accountBalances: Map + getMaxBalance: GetMaxBalanceWithFee activeFieldIds: string[] selectedAssetId: string split?: boolean }) => { const resolver = useStablepoolAddLiquidityFormResolver( stablepoolId, - accountBalances, + getMaxBalance, option === "omnipool" ? omnipoolId : undefined, ) diff --git a/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.tsx b/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.tsx index 63a800f547..b02d4b6ba5 100644 --- a/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.tsx +++ b/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.tsx @@ -35,7 +35,7 @@ export const CreateIsolatedPool: FC = ({ }) => { const { t } = useTranslation("liquidity") - const form = useIsolatedPoolForm() + const { form, getMaxBalance } = useIsolatedPoolForm() const [assetA, amountA, assetB, amountB] = form.watch([ "assetA", "amountA", @@ -100,6 +100,7 @@ export const CreateIsolatedPool: FC = ({ amountFieldName="amountA" label={t("liquidity.createPool.modal.assetA")} assets={allowedAssetsA} + maxBalance={getMaxBalance(assetA)} disabled={!assetA} onAssetChange={() => form.trigger()} /> @@ -118,6 +119,7 @@ export const CreateIsolatedPool: FC = ({ amountFieldName="amountB" label={t("liquidity.createPool.modal.assetB")} assets={allowedAssetsB} + maxBalance={getMaxBalance(assetB)} disabled={!assetB} onAssetChange={() => form.trigger()} /> diff --git a/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.utils.ts b/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.utils.ts index 75d5433ba3..85c9fdc98e 100644 --- a/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.utils.ts +++ b/apps/main/src/modules/liquidity/components/CreateIsolatedPool/CreateIsolatedPool.utils.ts @@ -7,16 +7,16 @@ import { useTranslation } from "react-i18next" import { z } from "zod/v4" import { TAssetData } from "@/api/assets" +import { + useFormMaxBalanceWithFee, + ValidateFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import { TAsset, useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { useAccountBalances } from "@/states/account" import { useTransactionsStore } from "@/states/transactions" -import { - positive, - required, - requiredObject, - useValidateFormMaxBalance, -} from "@/utils/validators" +import { scale } from "@/utils/formatting" +import { positive, required, requiredObject } from "@/utils/validators" const schema = z.object({ assetA: requiredObject(), @@ -27,12 +27,10 @@ const schema = z.object({ export type CreateIsolatedPoolFormData = z.infer -const useSchema = () => { - const refineFormMaxBalance = useValidateFormMaxBalance() - +const useSchema = (validateBalance: ValidateFormMaxBalanceWithFee) => { return schema.check( - refineFormMaxBalance("amountA", (form) => [form.assetA, form.amountA]), - refineFormMaxBalance("amountB", (form) => [form.assetB, form.amountB]), + validateBalance("amountA", (form) => [form.assetA, form.amountA]), + validateBalance("amountB", (form) => [form.assetB, form.amountB]), ) } @@ -44,11 +42,25 @@ const defaultValues: CreateIsolatedPoolFormData = { } export const useIsolatedPoolForm = () => { - return useForm({ + const { papi } = useRpcProvider() + + const createPoolTx = papi.tx.XYK.create_pool({ + asset_a: 0, + amount_a: BigInt(scale("1", 12)), + asset_b: 10, + amount_b: BigInt(scale("1", 12)), + }) + + const { validateBalance, getMaxBalance } = + useFormMaxBalanceWithFee(createPoolTx) + + const form = useForm({ mode: "onChange", defaultValues, - resolver: standardSchemaResolver(useSchema()), + resolver: standardSchemaResolver(useSchema(validateBalance)), }) + + return { form, getMaxBalance } } export const useSubmitCreateIsolatedPool = ({ diff --git a/apps/main/src/modules/staking/Stake.form.ts b/apps/main/src/modules/staking/Stake.form.ts index a0fa9bba61..c2daf04f07 100644 --- a/apps/main/src/modules/staking/Stake.form.ts +++ b/apps/main/src/modules/staking/Stake.form.ts @@ -1,14 +1,20 @@ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema" -import { useQuery } from "@tanstack/react-query" +import { useMutation, useQuery } from "@tanstack/react-query" import Big from "big.js" import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import { refine, z } from "zod/v4" import { stakingConstsQuery } from "@/api/constants" +import { TAccountVote } from "@/api/democracy" +import { HDXSupplyQueryKey, useInvalidateStakeData } from "@/api/staking" import i18n from "@/i18n" +import { useProcessedVotes } from "@/modules/staking/Stake.data" +import { useCreateBatchTx } from "@/modules/transactions/hooks/useBatchTx" +import { useEstimateFee } from "@/modules/transactions/hooks/useEstimateFee" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" -import { toDecimal } from "@/utils/formatting" +import { scale, scaleHuman, toBigInt, toDecimal } from "@/utils/formatting" import { positive, required, validateFieldMaxBalance } from "@/utils/validators" const MIN_INCREASE_PERCENTAGE = 1 @@ -32,14 +38,56 @@ const getSchema = (balance: string, minStake: string, nativeSymbol: string) => export type StakeFormValues = z.infer> -export const useStakeForm = (balance: string, stakedBalance: string) => { +export const useStake = ( + balance: string, + stakedBalance: string, + positionId: bigint, + votes: ReadonlyArray, + votesSuccess: boolean, +) => { + const { t } = useTranslation(["common", "staking"]) const rpc = useRpcProvider() + const { papi } = rpc const { native } = useAssets() + const createBatch = useCreateBatchTx() const { data: stakingConsts, isLoading: minStakeLoading } = useQuery( stakingConstsQuery(rpc), ) + const { newProcessedVotesIds, oldProcessedVotesIds } = useProcessedVotes( + votes, + votesSuccess, + ) + + const stakeTx = papi.tx.Utility.batch_all({ + calls: [ + ...oldProcessedVotesIds.map( + (id) => papi.tx.Democracy.remove_vote({ index: id }).decodedCall, + ), + ...newProcessedVotesIds.map( + ({ classId, id }) => + papi.tx.ConvictionVoting.remove_vote({ class: classId, index: id }) + .decodedCall, + ), + papi.tx.Staking.stake({ + amount: BigInt(scale(balance, native.decimals)), + }).decodedCall, + ], + }) + + const { data: fee } = useEstimateFee(stakeTx) + + const maxBalanceHuman = + fee && fee.feeAssetId === native.id + ? Big.max( + 0, + Big(balance) + .minus(fee.feeEstimate) + .minus(scaleHuman(native.existentialDeposit, native.decimals)), + ).toString() + : balance + const defaultValues: StakeFormValues = { amount: "", } @@ -57,14 +105,79 @@ export const useStakeForm = (balance: string, stakedBalance: string) => { const form = useForm({ defaultValues, resolver: standardSchemaResolver( - getSchema(balance, minStakeUsed, native.symbol), + getSchema(maxBalanceHuman, minStakeUsed, native.symbol), ), disabled: minStakeLoading, mode: "onChange", }) + const getMessage = (amount: string, type: "onLoading" | "onSuccess") => + t( + `staking:stake.${positionId === 0n ? "stake" : "increaseStake"}.toasts.${type}`, + { + amount, + }, + ) + + const invalidateStakeData = useInvalidateStakeData() + + const mutation = useMutation({ + mutationFn: async (amount: string) => { + const isStakePosition = positionId !== 0n + + const formattedAmount = t("currency", { + value: amount, + symbol: native.symbol, + }) + + const rawAmount = toBigInt(amount, native.decimals) + + const txs = (() => { + if (!isStakePosition) { + return [papi.tx.Staking.stake({ amount: rawAmount })] + } + + const increaseStakeTx = papi.tx.Staking.increase_stake({ + position_id: positionId, + amount: rawAmount, + }) + + if (!newProcessedVotesIds.length && !oldProcessedVotesIds.length) { + return [increaseStakeTx] + } + + return [ + ...oldProcessedVotesIds.map((id) => + papi.tx.Democracy.remove_vote({ index: id }), + ), + ...newProcessedVotesIds.map(({ classId, id }) => + papi.tx.ConvictionVoting.remove_vote({ class: classId, index: id }), + ), + increaseStakeTx, + ] + })() + await createBatch({ + txs, + transaction: { + toasts: { + submitted: getMessage(formattedAmount, "onLoading"), + success: getMessage(formattedAmount, "onSuccess"), + }, + invalidateQueries: [HDXSupplyQueryKey], + }, + options: { + onSuccess: () => form.reset(), + }, + }) + + await invalidateStakeData.mutateAsync() + }, + }) + return { - form, + mutation, + maxBalanceHuman, minStake: minStakeUsed, + form, } } diff --git a/apps/main/src/modules/staking/Stake.stake.ts b/apps/main/src/modules/staking/Stake.stake.ts deleted file mode 100644 index 5c982d0608..0000000000 --- a/apps/main/src/modules/staking/Stake.stake.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useMutation } from "@tanstack/react-query" -import { useTranslation } from "react-i18next" - -import { TAccountVote } from "@/api/democracy" -import { HDXSupplyQueryKey, useInvalidateStakeData } from "@/api/staking" -import { useProcessedVotes } from "@/modules/staking/Stake.data" -import { useCreateBatchTx } from "@/modules/transactions/hooks/useBatchTx" -import { useAssets } from "@/providers/assetsProvider" -import { useRpcProvider } from "@/providers/rpcProvider" -import { toBigInt } from "@/utils/formatting" - -export const useStake = ( - positionId: bigint, - votes: ReadonlyArray, - votesSuccess: boolean, - onSuccess: () => void, -) => { - const { t } = useTranslation(["common", "staking"]) - - const rpc = useRpcProvider() - const { papi } = rpc - - const { native } = useAssets() - const createBatch = useCreateBatchTx() - - const { newProcessedVotesIds, oldProcessedVotesIds } = useProcessedVotes( - votes, - votesSuccess, - ) - - const getMessage = (amount: string, type: "onLoading" | "onSuccess") => - t( - `staking:stake.${positionId === 0n ? "stake" : "increaseStake"}.toasts.${type}`, - { - amount, - }, - ) - - const invalidateStakeData = useInvalidateStakeData() - - return useMutation({ - mutationFn: async (amount: string) => { - const isStakePosition = positionId !== 0n - - const formattedAmount = t("currency", { - value: amount, - symbol: native.symbol, - }) - - const rawAmount = toBigInt(amount, native.decimals) - - const txs = (() => { - if (!isStakePosition) { - return [papi.tx.Staking.stake({ amount: rawAmount })] - } - - const increaseStakeTx = papi.tx.Staking.increase_stake({ - position_id: positionId, - amount: rawAmount, - }) - - if (!newProcessedVotesIds.length && !oldProcessedVotesIds.length) { - return [increaseStakeTx] - } - - return [ - ...oldProcessedVotesIds.map((id) => - papi.tx.Democracy.remove_vote({ index: id }), - ), - ...newProcessedVotesIds.map(({ classId, id }) => - papi.tx.ConvictionVoting.remove_vote({ class: classId, index: id }), - ), - increaseStakeTx, - ] - })() - await createBatch({ - txs, - transaction: { - toasts: { - submitted: getMessage(formattedAmount, "onLoading"), - success: getMessage(formattedAmount, "onSuccess"), - }, - invalidateQueries: [HDXSupplyQueryKey], - }, - options: { - onSuccess, - }, - }) - - await invalidateStakeData.mutateAsync() - }, - }) -} diff --git a/apps/main/src/modules/staking/StakeForm.tsx b/apps/main/src/modules/staking/StakeForm.tsx index a4a17bdfcb..b212b35e87 100644 --- a/apps/main/src/modules/staking/StakeForm.tsx +++ b/apps/main/src/modules/staking/StakeForm.tsx @@ -16,8 +16,7 @@ import { useDebounce } from "react-use" import { TAccountVote } from "@/api/democracy" import { AssetSelect } from "@/components/AssetSelect/AssetSelect" import { AuthorizedAction } from "@/components/AuthorizedAction/AuthorizedAction" -import { useStakeForm } from "@/modules/staking/Stake.form" -import { useStake } from "@/modules/staking/Stake.stake" +import { useStake } from "@/modules/staking/Stake.form" import { useIncreaseStake } from "@/modules/staking/Stake.utils" import { useAssets } from "@/providers/assetsProvider" import { toBigInt } from "@/utils/formatting" @@ -42,14 +41,16 @@ export const StakeForm: FC = ({ const { t } = useTranslation(["common", "staking"]) const { native } = useAssets() - const { form, minStake } = useStakeForm(balance, staked || "0") - - const stakeMutation = useStake(positionId, votes, votesSuccess, () => - form.reset(), + const { form, minStake, maxBalanceHuman, mutation } = useStake( + balance, + staked || "0", + positionId, + votes, + votesSuccess, ) const submitForm = form.handleSubmit((values) => - stakeMutation.mutate(values.amount), + mutation.mutate(values.amount), ) const amount = form.watch("amount") @@ -79,7 +80,7 @@ export const StakeForm: FC = ({ assets={[]} selectedAsset={native} modalDisabled - maxBalance={balance} + maxBalance={maxBalanceHuman} value={field.value} onChange={field.onChange} amountError={fieldState.error?.message} diff --git a/apps/main/src/modules/trade/otc/fill-order/FillOrder.utils.ts b/apps/main/src/modules/trade/otc/fill-order/FillOrder.utils.ts index 667844b582..b81feed60b 100644 --- a/apps/main/src/modules/trade/otc/fill-order/FillOrder.utils.ts +++ b/apps/main/src/modules/trade/otc/fill-order/FillOrder.utils.ts @@ -2,6 +2,30 @@ import { formatAssetAmount } from "@galacticcouncil/utils" import Big from "big.js" import { OtcOffer } from "@/modules/trade/otc/table/OtcTable.query" +import { Papi } from "@/providers/rpcProvider" +import { scale } from "@/utils/formatting" + +const FULL_ORDER_PCT_LBOUND = 99 + +export const getOtcFillOrderTx = ( + papi: Papi, + otcOffer: OtcOffer, + sellAmount: string, +) => { + const filledPct = new Big(sellAmount) + .div(otcOffer.assetAmountIn) + .mul(100) + .toNumber() + + return otcOffer.isPartiallyFillable && filledPct <= FULL_ORDER_PCT_LBOUND + ? papi.tx.OTC.partial_fill_order({ + order_id: Number(otcOffer.id), + amount_in: BigInt(scale(sellAmount, otcOffer.assetIn.decimals)), + }) + : papi.tx.OTC.fill_order({ + order_id: Number(otcOffer.id), + }) +} export const getOtcFeeMultiplier = (feePct: string) => { return Big(1).minus(feePct || 0) diff --git a/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.form.ts b/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.form.ts index e18230bf85..a1aa7dae5c 100644 --- a/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.form.ts +++ b/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.form.ts @@ -8,22 +8,27 @@ import { type infer as Infer, object, refine } from "zod/v4" import { otcExistentialDepositorMultiplierQuery } from "@/api/otc" import i18n from "@/i18n" import { getOtcBuyAmountAfterFee } from "@/modules/trade/otc/fill-order/FillOrder.utils" +import { getOtcFillOrderTx } from "@/modules/trade/otc/fill-order/FillOrder.utils" import { OtcOfferTabular } from "@/modules/trade/otc/table/OtcTable.columns" +import { + useFormMaxBalanceWithFee, + ValidateFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import { useRpcProvider } from "@/providers/rpcProvider" import { positiveOptional, - useValidateFormMaxBalance, validateFieldExistentialDeposit, } from "@/utils/validators" -const useSchema = (offer: OtcOfferTabular) => { +const useSchema = ( + offer: OtcOfferTabular, + validateBalance: ValidateFormMaxBalanceWithFee, +) => { const rpc = useRpcProvider() const { data: existentialDepositMultiplier } = useQuery( otcExistentialDepositorMultiplierQuery(rpc), ) - const refineMaxBalance = useValidateFormMaxBalance() - return object({ sellAmount: positiveOptional.check( refine( @@ -42,7 +47,7 @@ const useSchema = (offer: OtcOfferTabular) => { ), ), }).check( - refineMaxBalance("sellAmount", (form) => [offer.assetIn, form.sellAmount]), + validateBalance("sellAmount", (form) => [offer.assetIn, form.sellAmount]), ) } @@ -53,6 +58,8 @@ export const useFillOrderForm = ( isUsersOffer: boolean, feePrice: string, ) => { + const { papi } = useRpcProvider() + const defaultValues: FillOrderFormValues = !isUsersOffer && otcOffer.isPartiallyFillable ? { @@ -70,11 +77,20 @@ export const useFillOrderForm = ( ), } - const schema = useSchema(otcOffer) + const { validateBalance, getMaxBalance } = useFormMaxBalanceWithFee( + getOtcFillOrderTx(papi, otcOffer, "1"), + ) + + const schema = useSchema(otcOffer, validateBalance) - return useForm({ + const form = useForm({ defaultValues, resolver: isUsersOffer ? undefined : standardSchemaResolver(schema), mode: "onChange", }) + + return { + form, + getMaxBalance, + } } diff --git a/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.submit.ts b/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.submit.ts index 553ffdf150..7813adc9d1 100644 --- a/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.submit.ts +++ b/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.submit.ts @@ -1,43 +1,19 @@ import { useMutation } from "@tanstack/react-query" -import Big from "big.js" import { useTranslation } from "react-i18next" import { AAVE_GAS_LIMIT } from "@/api/aave" +import { getOtcFillOrderTx } from "@/modules/trade/otc/fill-order/FillOrder.utils" import { FillOrderFormValues } from "@/modules/trade/otc/fill-order/FillOrderModalContent.form" import { OtcOfferTabular } from "@/modules/trade/otc/table/OtcTable.columns" -import { OtcOffer } from "@/modules/trade/otc/table/OtcTable.query" import { useAssets } from "@/providers/assetsProvider" -import { Papi, useRpcProvider } from "@/providers/rpcProvider" +import { useRpcProvider } from "@/providers/rpcProvider" import { useTransactionsStore } from "@/states/transactions" -import { scale } from "@/utils/formatting" - -const FULL_ORDER_PCT_LBOUND = 99 type Args = { readonly otcOffer: OtcOfferTabular readonly onSubmitted: () => void } -export const getOtcFillOrderTx = ( - papi: Papi, - otcOffer: OtcOffer, - sellAmount: string, -) => { - const filledPct = new Big(sellAmount) - .div(otcOffer.assetAmountIn) - .mul(100) - .toNumber() - - return otcOffer.isPartiallyFillable && filledPct <= FULL_ORDER_PCT_LBOUND - ? papi.tx.OTC.partial_fill_order({ - order_id: Number(otcOffer.id), - amount_in: BigInt(scale(sellAmount, otcOffer.assetIn.decimals)), - }) - : papi.tx.OTC.fill_order({ - order_id: Number(otcOffer.id), - }) -} - export const useSubmitFillOrder = ({ otcOffer, onSubmitted }: Args) => { const { t } = useTranslation(["trade", "common"]) const { papi } = useRpcProvider() diff --git a/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.tsx b/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.tsx index d76cd83af2..d190cdd613 100644 --- a/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.tsx +++ b/apps/main/src/modules/trade/otc/fill-order/FillOrderModalContent.tsx @@ -29,8 +29,6 @@ import { TradeFee } from "@/modules/trade/otc/TradeFee" import { otcTradeFeeQuery } from "@/modules/trade/otc/TradeFee.query" import { isBond } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" -import { useAccountBalance } from "@/states/account" -import { scaleHuman } from "@/utils/formatting" type Props = { readonly otcOffer: OtcOfferTabular @@ -47,15 +45,18 @@ export const FillOrderModalContent: FC = ({ const { t } = useTranslation(["trade", "common"]) const [isSubmitCancelOpen, setIsSubmitCancelOpen] = useState(false) - const inBalance = useAccountBalance(otcOffer.assetIn.id) - const assetInBalance = inBalance - ? scaleHuman(inBalance.transferable, otcOffer.assetIn.decimals) - : "0" - const assetInMax = Big.min(otcOffer.assetAmountIn, assetInBalance).toString() - const { data: feePct = "0" } = useQuery(otcTradeFeeQuery(rpc)) - const form = useFillOrderForm(otcOffer, isUsersOffer, feePct) + const { form, getMaxBalance } = useFillOrderForm( + otcOffer, + isUsersOffer, + feePct, + ) + const maxAccountBalance = getMaxBalance(otcOffer.assetIn) + const assetInMax = Big.min( + otcOffer.assetAmountIn, + maxAccountBalance, + ).toString() const submit = useSubmitFillOrder({ otcOffer, onSubmitted: onClose, @@ -150,6 +151,7 @@ export const FillOrderModalContent: FC = ({ disabled={isUsersOffer || !otcOffer.isPartiallyFillable} modalDisabled maxButtonBalance={assetInMax} + maxBalance={maxAccountBalance} maxBalanceFallback="0" hideMaxBalanceAction={!otcOffer.isPartiallyFillable} amountError={fieldState.error?.message} diff --git a/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.form.ts b/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.form.ts index 95ea0fa162..abc3ca7a2c 100644 --- a/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.form.ts +++ b/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.form.ts @@ -5,14 +5,18 @@ import * as z from "zod/v4" import { otcExistentialDepositorMultiplierQuery } from "@/api/otc" import i18n from "@/i18n" +import { + useFormMaxBalanceWithFee, + ValidateFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import { TAsset } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" +import { scale } from "@/utils/formatting" import { positive, positiveOptional, required, requiredObject, - useValidateFormMaxBalance, validateFormExistentialDeposit, } from "@/utils/validators" @@ -20,71 +24,69 @@ export const marketPriceOptions = [-3, -1, 0, 3, 5, 10] as const const view = z.literal(["offerPrice", "buyPrice"]) -const useSchema = () => { +const placeOrderFormBaseSchema = z.object({ + offerAsset: requiredObject(), + offerAmount: positiveOptional, + buyAsset: requiredObject(), + buyAmount: positiveOptional, + priceSettings: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("relative"), + percentage: z.literal(marketPriceOptions), + }), + z.object({ + type: z.literal("fixed"), + offerPrice: required.pipe(positive), + buyPrice: required.pipe(positive), + view, + }), + ]), + priceConfirmation: z + .object({ + confirmed: z.boolean(), + }) + .nullable(), + view, + isPartiallyFillable: z.boolean(), +}) + +export type PlaceOrderFormValues = z.infer +export type PriceSettings = PlaceOrderFormValues["priceSettings"] +export type PlaceOrderView = PlaceOrderFormValues["view"] + +const useSchema = (validateBalance: ValidateFormMaxBalanceWithFee) => { const rpc = useRpcProvider() const { data: existentialDepositMultiplier } = useQuery( otcExistentialDepositorMultiplierQuery(rpc), ) - const refineFormMaxBalance = useValidateFormMaxBalance() - - return z - .object({ - offerAsset: requiredObject(), - offerAmount: positiveOptional, - buyAsset: requiredObject(), - buyAmount: positiveOptional, - priceSettings: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("relative"), - percentage: z.literal(marketPriceOptions), - }), - z.object({ - type: z.literal("fixed"), - offerPrice: required.pipe(positive), - buyPrice: required.pipe(positive), - view, - }), - ]), - priceConfirmation: z - .object({ - confirmed: z.boolean(), - }) - .nullable(), - view, - isPartiallyFillable: z.boolean(), - }) - .check( - z.refine( - ({ offerAsset, buyAsset }) => - !offerAsset || !buyAsset || offerAsset.id !== buyAsset.id, - { - error: i18n.t("trade:otc.placeOrder.validation.sameAssets"), - path: ["buyAsset"], - }, - ), - validateFormExistentialDeposit("offerAmount", (form) => [ - existentialDepositMultiplier, - form.offerAsset, - form.offerAmount, - ]), - refineFormMaxBalance("offerAmount", (form) => [ - form.offerAsset, - form.offerAmount, - ]), - validateFormExistentialDeposit("buyAmount", (form) => [ - existentialDepositMultiplier, - form.buyAsset, - form.buyAmount, - ]), - ) + return placeOrderFormBaseSchema.check( + z.refine( + ({ offerAsset, buyAsset }) => + !offerAsset || !buyAsset || offerAsset.id !== buyAsset.id, + { + error: i18n.t("trade:otc.placeOrder.validation.sameAssets"), + path: ["buyAsset"], + }, + ), + validateFormExistentialDeposit("offerAmount", (form) => [ + existentialDepositMultiplier, + form.offerAsset, + form.offerAmount, + ]), + validateBalance("offerAmount", (form) => [ + form.offerAsset, + form.offerAmount, + ]), + validateFormExistentialDeposit("buyAmount", (form) => [ + existentialDepositMultiplier, + form.buyAsset, + form.buyAmount, + ]), + ) } -export type PlaceOrderFormValues = z.infer> -export type PriceSettings = PlaceOrderFormValues["priceSettings"] -export type PlaceOrderView = PlaceOrderFormValues["view"] - -export const usePlaceOrderForm = () => { +export const usePlaceOrder = () => { const defaultValues: PlaceOrderFormValues = { offerAsset: null, offerAmount: "", @@ -99,9 +101,26 @@ export const usePlaceOrderForm = () => { isPartiallyFillable: true, } - return useForm({ + const { papi } = useRpcProvider() + + const tx = papi.tx.OTC.place_order({ + amount_in: BigInt(scale(1, 12)), + amount_out: BigInt(scale(1, 12)), + asset_in: Number(0), + asset_out: Number(10), + partially_fillable: true, + }) + + const { validateBalance, getMaxBalance } = useFormMaxBalanceWithFee(tx) + + const form = useForm({ defaultValues, - resolver: standardSchemaResolver(useSchema()), + resolver: standardSchemaResolver(useSchema(validateBalance)), mode: "onChange", }) + + return { + form, + getMaxBalance, + } } diff --git a/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.submit.ts b/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.submit.ts index e72b73ca6f..aee7429524 100644 --- a/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.submit.ts +++ b/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.submit.ts @@ -1,7 +1,6 @@ import { useMutation } from "@tanstack/react-query" import { useTranslation } from "react-i18next" -import { AAVE_GAS_LIMIT } from "@/api/aave" import { PlaceOrderFormValues } from "@/modules/trade/otc/place-order/PlaceOrderModalContent.form" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" @@ -48,12 +47,8 @@ export const useSubmitPlaceOrder = ({ onSubmitted }: Args) => { await createTransaction( { - tx: hasAToken - ? papi.tx.Dispatcher.dispatch_with_extra_gas({ - call: tx.decodedCall, - extra_gas: AAVE_GAS_LIMIT, - }) - : tx, + withExtraGas: hasAToken, + tx, toasts: { submitted: t("otc.placeOrder.loading", { amount: formattedAmount, diff --git a/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.tsx b/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.tsx index d72c97c65c..51fc428216 100644 --- a/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.tsx +++ b/apps/main/src/modules/trade/otc/place-order/PlaceOrderModalContent.tsx @@ -20,7 +20,7 @@ import { PartiallyFillableToggle } from "@/modules/trade/otc/place-order/Partial import { PlaceOrderFormValues, PriceSettings, - usePlaceOrderForm, + usePlaceOrder, } from "@/modules/trade/otc/place-order/PlaceOrderModalContent.form" import { useSubmitPlaceOrder } from "@/modules/trade/otc/place-order/PlaceOrderModalContent.submit" import { @@ -88,7 +88,7 @@ export const PlaceOrderModalContent: FC = ({ onClose }) => { const { all } = useAssets() - const form = usePlaceOrderForm() + const { form, getMaxBalance } = usePlaceOrder() const { getValues, setValue, watch, control } = form const [offerAsset, offerAmount, buyAsset, buyAmount, priceSettings, view] = @@ -406,6 +406,7 @@ export const PlaceOrderModalContent: FC = ({ onClose }) => { label={t("common:offer")} assets={sufficientAssets} maxBalanceFallback="0" + maxBalance={getMaxBalance(offerAsset)} ignoreBalance={!areAssetsSelected} ignoreDisplayValue={!areAssetsSelected} disabledInput={!areAssetsSelected} diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 66a40014d8..7d6906bd7a 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -3,13 +3,23 @@ import { isValidBigSource, safeStringify, } from "@galacticcouncil/utils" -import { useAccount } from "@galacticcouncil/web3-connect" +import { + isEthereumSigner, + useAccount, + useWallet, +} from "@galacticcouncil/web3-connect" import { keepPreviousData, useQuery } from "@tanstack/react-query" import Big from "big.js" import { useAccountFeePaymentAssetId } from "@/api/payments" import { getSpotPrice } from "@/api/spotPrice" import { AnyTransaction } from "@/modules/transactions/types" +import { + estimatePermitFee, + getPermitFeeQueryKeyPart, + isPermitFeeEstimation, +} from "@/modules/transactions/utils/permitFee" +import { isPapiTransaction } from "@/modules/transactions/utils/polkadot" import { getExtraTxFeeByWeight, transformAnyToPapiTx, @@ -28,6 +38,7 @@ export const useEstimateFee = ( const { papi, sdk, isLoaded } = rpc const { native, getAsset } = useAssets() const { account } = useAccount() + const wallet = useWallet() const { data: accountFeePaymentAssetId, @@ -46,6 +57,11 @@ export const useEstimateFee = ( const tx = anyTx ? transformAnyToPapiTx(papi, anyTx) : null + const isUsingPermitFee = isPermitFeeEstimation( + feeAssetId ?? "", + isEthereumSigner(wallet?.signer), + ) + const feeAssetBalance = feeAsset ? scaleHuman(getTransferableBalance(feeAsset.id), feeAsset.decimals) : "0" @@ -60,7 +76,7 @@ export const useEstimateFee = ( }, enabled: isLoaded && - !!tx && + !!anyTx && !!feeAsset && !isLoadingFeePaymentAssetId && !isBalanceLoading && @@ -69,12 +85,25 @@ export const useEstimateFee = ( "estimateFee", feeAssetId, address, - safeStringify(tx?.decodedCall), + isUsingPermitFee, + safeStringify(getPermitFeeQueryKeyPart(anyTx) ?? tx?.decodedCall), ], queryFn: async () => { - if (!tx) throw new Error("Invalid transaction") + if (!anyTx) throw new Error("Invalid transaction") if (!feeAsset) throw new Error(`Asset ${feeAssetId} is not valid`) - const isEvmTx = !!anyTx && isEvmCall(anyTx) + + if (isUsingPermitFee) { + try { + return await estimatePermitFee(rpc, address, anyTx, feeAsset, native) + } catch { + // Fall back to substrate-only estimate for PAPI txs if EVM simulation fails. + if (!isPapiTransaction(anyTx)) + throw new Error("Permit fee estimation failed") + } + } + + if (!tx) throw new Error("Invalid transaction") + const isEvmTx = isEvmCall(anyTx) const getSpotPriceFn = getSpotPrice( sdk.api.router, diff --git a/apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts b/apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts new file mode 100644 index 0000000000..6fbd8558dd --- /dev/null +++ b/apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts @@ -0,0 +1,61 @@ +import { FieldValues, Path } from "react-hook-form" +import * as z from "zod/v4" + +import { useAccountFeePaymentAssetId } from "@/api/payments" +import { useMaxBalanceWithFee } from "@/modules/transactions/hooks/useMaxBalanceWithFee" +import { AnyTransaction } from "@/modules/transactions/types" +import { TAsset } from "@/providers/assetsProvider" +import { useAccountBalances } from "@/states/account" +import { scaleHuman } from "@/utils/formatting" +import { maxBalanceError, validateMaxBalance } from "@/utils/validators" + +export const useFormMaxBalanceWithFee = ( + tx: AnyTransaction | null, + feePctBuffer?: number, +) => { + const { data: accountFeePaymentAssetId } = useAccountFeePaymentAssetId() + const { getTransferableBalance } = useAccountBalances() + const maxBalanceWithFee = useMaxBalanceWithFee(tx, feePctBuffer) + + const getMaxBalance = (asset: TAsset | null) => { + if (!asset) { + return "0" + } + + if (accountFeePaymentAssetId === Number(asset.id)) { + return maxBalanceWithFee?.maxBalanceHuman ?? "0" + } + + return scaleHuman(getTransferableBalance(asset.id), asset.decimals) + } + + const validateBalance = ( + path: Path>, + selectData: (form: TFormValues) => [asset: TAsset | null, amount: string], + ) => + z.refine( + (form) => { + const [asset, amount] = selectData(form) + + if (!asset) { + return true + } + + return validateMaxBalance(getMaxBalance(asset), amount) + }, + { + error: maxBalanceError, + path: [path], + }, + ) + + return { getMaxBalance, validateBalance } +} + +export type ValidateFormMaxBalanceWithFee = ReturnType< + typeof useFormMaxBalanceWithFee +>["validateBalance"] + +export type GetMaxBalanceWithFee = ReturnType< + typeof useFormMaxBalanceWithFee +>["getMaxBalance"] diff --git a/apps/main/src/modules/transactions/hooks/useMaxBalance.ts b/apps/main/src/modules/transactions/hooks/useMaxBalance.ts new file mode 100644 index 0000000000..ee3043b23d --- /dev/null +++ b/apps/main/src/modules/transactions/hooks/useMaxBalance.ts @@ -0,0 +1,41 @@ +import { useAccountFeePaymentAssetId } from "@/api/payments" +import { useMaxBalanceWithFee } from "@/modules/transactions/hooks/useMaxBalanceWithFee" +import { AnyTransaction } from "@/modules/transactions/types" +import { TAsset } from "@/providers/assetsProvider" +import { useAccountBalances } from "@/states/account" +import { scaleHuman } from "@/utils/formatting" + +export const useMaxBalance = ( + asset: TAsset | null, + tx: AnyTransaction | null, + feePctBuffer?: number, +) => { + const { data: accountFeePaymentAssetId } = useAccountFeePaymentAssetId() + const { getTransferableBalance } = useAccountBalances() + + const isFeePaymentAsset = asset + ? accountFeePaymentAssetId === Number(asset.id) + : false + + const maxBalanceWithFee = useMaxBalanceWithFee( + isFeePaymentAsset ? tx : null, + feePctBuffer, + ) + + if (!asset) { + return { + maxBalanceHuman: "0", + } + } + + if (!isFeePaymentAsset) { + return { + maxBalanceHuman: scaleHuman( + getTransferableBalance(asset.id), + asset.decimals, + ), + } + } + + return { maxBalanceHuman: maxBalanceWithFee?.maxBalanceHuman ?? "0" } +} diff --git a/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts index e87adc2a14..0c8bb40af6 100644 --- a/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts +++ b/apps/main/src/modules/transactions/hooks/useMaxBalanceWithFee.ts @@ -5,9 +5,15 @@ import { AnyTransaction } from "@/modules/transactions/types" import { useAssets } from "@/providers/assetsProvider" import { scaleHuman } from "@/utils/formatting" -export const useMaxBalanceWithFee = (tx: AnyTransaction | null) => { +const DEFAULT_FEE_PERCENTAGE_BUFFER = 1 + +export const useMaxBalanceWithFee = ( + tx: AnyTransaction | null, + feePctBuffer?: number, +) => { const { getAssetWithFallback } = useAssets() const { data: fee } = useEstimateFee(tx) + const meta = getAssetWithFallback(fee?.feeAssetId ?? "") if (!fee) return undefined @@ -16,7 +22,11 @@ export const useMaxBalanceWithFee = (tx: AnyTransaction | null) => { maxBalanceHuman: Big.max( 0, Big(fee.feeAssetBalance) - .minus(fee.feeEstimate) + .minus( + Big(fee.feeEstimate).mul( + 1 + (feePctBuffer ?? DEFAULT_FEE_PERCENTAGE_BUFFER) / 100, + ), + ) .minus(scaleHuman(meta.existentialDeposit, meta.decimals)), ).toString(), ...fee, diff --git a/apps/main/src/modules/transactions/hooks/useTransactionAlerts.ts b/apps/main/src/modules/transactions/hooks/useTransactionAlerts.ts index 149ab0eb81..f6d1761417 100644 --- a/apps/main/src/modules/transactions/hooks/useTransactionAlerts.ts +++ b/apps/main/src/modules/transactions/hooks/useTransactionAlerts.ts @@ -10,7 +10,9 @@ import { isObjectType } from "remeda" import { useAccountPendingPermit } from "@/api/account" import { useMultisigDeposit, useMultisigSignerBalance } from "@/api/multisig" import { useTransaction } from "@/modules/transactions/TransactionProvider" +import { useAssets } from "@/providers/assetsProvider" import { useTransactionsStore } from "@/states/transactions" +import { scaleHuman } from "@/utils/formatting" export enum TransactionAlertFlag { InsufficientFeeBalance = "insufficientFeeBalance", @@ -24,6 +26,7 @@ type TransactionAlert = AlertProps & { export const useTransactionAlerts = () => { const { t } = useTranslation() + const { getAssetWithFallback } = useAssets() const { account } = useAccount() const activeMultisigConfig = useActiveMultisigConfig() @@ -33,6 +36,7 @@ export const useTransactionAlerts = () => { feeEstimate, feeEstimateNative, feeAssetBalance, + feeAssetId, isUsingPermit, nonce, } = useTransaction() @@ -49,13 +53,19 @@ export const useTransactionAlerts = () => { multisigConfig?.signers.length ?? 0, ) + const feeAsset = getAssetWithFallback(feeAssetId) + // For multisig, fee is paid by the signer (not the multisig account itself), // so skip the regular fee balance check — isSignerBalanceInsufficient covers it. const isFeeBalanceInsufficient = !isMultisig && feeAssetBalance && feeEstimate && - Big(feeAssetBalance).lt(feeEstimate) + Big(feeAssetBalance).lt( + Big(feeEstimate).plus( + scaleHuman(feeAsset.existentialDeposit, feeAsset.decimals), + ), + ) // For multisig: signer must cover native tx fee + multisig deposit const isSignerBalanceInsufficient = diff --git a/apps/main/src/modules/transactions/utils/permitFee.ts b/apps/main/src/modules/transactions/utils/permitFee.ts new file mode 100644 index 0000000000..941bbb0b00 --- /dev/null +++ b/apps/main/src/modules/transactions/utils/permitFee.ts @@ -0,0 +1,198 @@ +import { safeConvertSS58toH160 } from "@galacticcouncil/utils" +import { EVM_DISPATCH_ADDRESS } from "@galacticcouncil/web3-connect/src/config/evm" +import { + getPermitGasFromWeight, + getPermitGasLimit, + getPermitGasPrice, +} from "@galacticcouncil/web3-connect/src/utils/permitGas" +import Big from "big.js" +import { Binary } from "polkadot-api" +import { EstimateGasParameters, formatEther, Hex } from "viem" + +import { getSpotPrice, spotPriceQuery } from "@/api/spotPrice" +import { paymentInfoQuery } from "@/api/transaction" +import { AnyTransaction } from "@/modules/transactions/types" +import { isPapiTransaction } from "@/modules/transactions/utils/polkadot" +import { transformAnyToPapiTx } from "@/modules/transactions/utils/tx" +import { isEvmCall } from "@/modules/transactions/utils/xcm" +import { TAsset } from "@/providers/assetsProvider" +import { TProviderContext } from "@/providers/rpcProvider" +import { NATIVE_EVM_ASSET_ID } from "@/utils/consts" +import { scaleHuman } from "@/utils/formatting" + +type PermitFeeEstimate = { + feeEstimateNative: string + feeEstimate: string + feeAssetId: string +} + +const getPermitWeight = async ( + rpc: TProviderContext, + address: string, + anyTx: AnyTransaction, +): Promise => { + const info = await rpc.queryClient.ensureQueryData( + paymentInfoQuery(rpc, address, anyTx), + ) + + if (!info) { + throw new Error("EVM permit fee estimation failed") + } + + return info.weight.ref_time +} + +const getPermitGas = async ( + rpc: TProviderContext, + address: string, + anyTx: AnyTransaction, + weight: bigint, +): Promise => { + const evmAddress = safeConvertSS58toH160(address) as Hex + + if (isEvmCall(anyTx)) { + return rpc.evm.estimateGas({ + account: evmAddress, + to: anyTx.to as Hex, + data: anyTx.data as Hex, + value: anyTx.value ?? 0n, + }) + } + + if (weight > 0n) { + return getPermitGasFromWeight(weight) + } + + return rpc.evm.estimateGas( + await getPermitDispatchGasEstimate(rpc, anyTx, address), + ) +} + +const getPermitDispatchGasEstimate = async ( + rpc: TProviderContext, + anyTx: AnyTransaction, + address: string, +): Promise => { + const papiTx = transformAnyToPapiTx(rpc.papi, anyTx) + const evmAddress = safeConvertSS58toH160(address) as Hex + + if (!papiTx) { + throw new Error("Unsupported transaction type for permit fee estimation") + } + + return { + account: evmAddress, + to: EVM_DISPATCH_ADDRESS as Hex, + data: Binary.toHex(await papiTx.getEncodedData()) as Hex, + value: 0n, + } +} + +const evmWeiToNativeFee = async ( + rpc: TProviderContext, + wei: bigint, + nativeAssetId: string, +): Promise => { + const spot = await rpc.queryClient.ensureQueryData( + spotPriceQuery(rpc, NATIVE_EVM_ASSET_ID, nativeAssetId), + ) + + if (!spot?.spotPrice) return "0" + + return Big(formatEther(wei)).times(spot.spotPrice.toString()).toString() +} + +const convertNativeFeeToFeeAsset = async ( + rpc: TProviderContext, + feeEstimateNative: string, + feeAsset: TAsset, + native: TAsset, +): Promise => { + const spot = await getSpotPrice(rpc.sdk.api.router, native.id, feeAsset.id)() + + if (spot?.spotPrice) { + return Big(feeEstimateNative).mul(spot.spotPrice).toString() + } + + const assetPaymentValue = + await rpc.papi.query.MultiTransactionPayment.AcceptedCurrencies.getValue( + Number(feeAsset.id), + ) + + if (!assetPaymentValue) { + throw new Error(`Asset ${feeAsset.id} is not accepted for payment`) + } + + const assetPaymentValueAdjusted = scaleHuman( + assetPaymentValue, + feeAsset.decimals, + ) + + return Big(1).div(assetPaymentValueAdjusted).mul(feeEstimateNative).toString() +} + +export const isPermitFeeEstimation = ( + feeAssetId: string, + isEthereumWallet: boolean, +) => isEthereumWallet && feeAssetId !== NATIVE_EVM_ASSET_ID + +export const getPermitFeeQueryKeyPart = (anyTx: AnyTransaction | null) => { + if (!anyTx) return null + + if (isEvmCall(anyTx)) { + return { + type: "evm" as const, + from: anyTx.from, + to: anyTx.to, + data: anyTx.data, + } + } + + if (isPapiTransaction(anyTx)) { + return { + type: "papi" as const, + call: anyTx.decodedCall, + } + } + + return null +} + +export const estimatePermitFee = async ( + rpc: TProviderContext, + address: string, + anyTx: AnyTransaction, + feeAsset: TAsset, + native: TAsset, +): Promise => { + const weight = await getPermitWeight(rpc, address, anyTx) + + const [gas, gasPriceBase] = await Promise.all([ + getPermitGas(rpc, address, anyTx, weight), + rpc.evm.getGasPrice(), + ]) + + const gasLimit = getPermitGasLimit(gas, weight) + const gasPrice = getPermitGasPrice(gasPriceBase) + + // MetaMask validates balance against gasLimit × gasPrice before signing the permit. + // On-chain, only gas_used × gas_price is charged. + const feeEstimateNative = await evmWeiToNativeFee( + rpc, + gasLimit * gasPrice, + native.id, + ) + + const feeEstimate = await convertNativeFeeToFeeAsset( + rpc, + feeEstimateNative, + feeAsset, + native, + ) + + return { + feeEstimateNative, + feeEstimate, + feeAssetId: feeAsset.id, + } +} diff --git a/apps/main/src/modules/wallet/assets/Transfer/TransferPosition.form.ts b/apps/main/src/modules/wallet/assets/Transfer/TransferPosition.form.ts index 5d2eb87f7a..9524d60194 100644 --- a/apps/main/src/modules/wallet/assets/Transfer/TransferPosition.form.ts +++ b/apps/main/src/modules/wallet/assets/Transfer/TransferPosition.form.ts @@ -11,21 +11,20 @@ import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import * as z from "zod/v4" -import { TAsset, useAssets } from "@/providers/assetsProvider" import { - positive, - required, - requiredObject, - useValidateFormMaxBalance, -} from "@/utils/validators" + useFormMaxBalanceWithFee, + ValidateFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" +import { TAsset, useAssets } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" +import { scale } from "@/utils/formatting" +import { positive, required, requiredObject } from "@/utils/validators" -const useSchema = () => { +const useSchema = (validateBalance: ValidateFormMaxBalanceWithFee) => { const { t } = useTranslation("wallet") const { account } = useAccount() const currentPublicKey = account?.publicKey - const refineMaxBalance = useValidateFormMaxBalance() - return z .object({ address: required @@ -58,7 +57,7 @@ const useSchema = () => { asset: requiredObject(), amount: required.pipe(positive), }) - .check(refineMaxBalance("amount", (form) => [form.asset, form.amount])) + .check(validateBalance("amount", (form) => [form.asset, form.amount])) } export type TransferPositionFormValues = z.infer> @@ -67,9 +66,11 @@ type Props = { readonly assetId?: string } -export const useTransferPositionForm = (props?: Props) => { +export const useTransferPosition = (props?: Props) => { + const { account } = useAccount() const { getAsset } = useAssets() - const asset = props?.assetId ? (getAsset(props?.assetId) ?? null) : null + const { papi } = useRpcProvider() + const asset = props?.assetId ? (getAsset(props.assetId) ?? null) : null const defaultValues: TransferPositionFormValues = { address: "", @@ -77,9 +78,25 @@ export const useTransferPositionForm = (props?: Props) => { amount: "", } - return useForm({ + const normalizedDest = + safeConvertAddressSS58(account?.address ?? "") || account?.address || "" + + const tx = papi.tx.Currencies.transfer({ + currency_id: asset ? Number(asset.id) : 0, + dest: normalizedDest, + amount: BigInt(scale(1, asset?.decimals ?? 12)), + }) + + const { getMaxBalance, validateBalance } = useFormMaxBalanceWithFee(tx) + + const form = useForm({ mode: "onChange", defaultValues, - resolver: standardSchemaResolver(useSchema()), + resolver: standardSchemaResolver(useSchema(validateBalance)), }) + + return { + form, + getMaxBalance, + } } diff --git a/apps/main/src/modules/wallet/assets/Transfer/TransferPositionModal.tsx b/apps/main/src/modules/wallet/assets/Transfer/TransferPositionModal.tsx index 4d02f650dd..21291eb1b4 100644 --- a/apps/main/src/modules/wallet/assets/Transfer/TransferPositionModal.tsx +++ b/apps/main/src/modules/wallet/assets/Transfer/TransferPositionModal.tsx @@ -30,7 +30,7 @@ import { AddressBookFormField } from "@/form/AddressBookFormField" import { AssetSelectFormField } from "@/form/AssetSelectFormField" import { TransferPositionFormValues, - useTransferPositionForm, + useTransferPosition, } from "@/modules/wallet/assets/Transfer/TransferPosition.form" import { useSubmitTransferPosition } from "@/modules/wallet/assets/Transfer/TransferPositionModal.submit" import { isErc20AToken, useAssets } from "@/providers/assetsProvider" @@ -47,7 +47,7 @@ export const TransferPositionModal: FC = ({ assetId, onClose }) => { const { tradable, native } = useAssets() const transferPosition = useSubmitTransferPosition({ onClose }) - const form = useTransferPositionForm({ assetId }) + const { form, getMaxBalance } = useTransferPosition({ assetId }) const shouldValidate = form.formState.isSubmitted const [isMyContactsOpen, setIsMyContactsOpen] = useState(false) @@ -133,6 +133,7 @@ export const TransferPositionModal: FC = ({ assetId, onClose }) => { label={t("transfer.modal.asset.label")} assetFieldName="asset" amountFieldName="amount" + maxBalance={getMaxBalance(asset)} assets={tradable} /> diff --git a/packages/web3-connect/src/signers/EthereumSigner.ts b/packages/web3-connect/src/signers/EthereumSigner.ts index 458a3af01d..a74172d52e 100644 --- a/packages/web3-connect/src/signers/EthereumSigner.ts +++ b/packages/web3-connect/src/signers/EthereumSigner.ts @@ -1,9 +1,5 @@ import { ExtendedEvmCall } from "@galacticcouncil/money-market/types" -import { - clampBigInt, - HYDRATION_CHAIN_KEY, - isAnyEvmChain, -} from "@galacticcouncil/utils" +import { HYDRATION_CHAIN_KEY, isAnyEvmChain } from "@galacticcouncil/utils" import { chainsMap } from "@galacticcouncil/xc-cfg" import { isObjectType } from "remeda" import { @@ -29,11 +25,13 @@ import { EVM_CALL_PERMIT_TYPES, EVM_DEFAULT_CHAIN_KEY, EVM_DISPATCH_ADDRESS, - EVM_GAS_TO_WEIGHT, - EVM_MAX_GAS_LIMIT, - EVM_MIN_GAS_LIMIT, } from "@/config/evm" import { requestNetworkSwitch } from "@/utils" +import { + getPermitGasFromWeight, + getPermitGasLimit, + getPermitGasPrice, +} from "@/utils/permitGas" type PermitMessage = { from: string @@ -96,12 +94,7 @@ export class EthereumSigner { const isPrecompileTx = tx.to === EVM_DISPATCH_ADDRESS if (isPrecompileTx && weight > 0n) { - const gasByWeight = clampBigInt(weight / EVM_GAS_TO_WEIGHT, { - min: EVM_MIN_GAS_LIMIT, - max: EVM_MAX_GAS_LIMIT, - }) - const gasLimitSurplus = (gasByWeight * 30n) / 100n // 30% surplus - return gasByWeight + gasLimitSurplus + return getPermitGasFromWeight(weight) } return this.publicClient.estimateGas({ @@ -115,14 +108,8 @@ export class EthereumSigner { this.getGas(tx, weight), this.publicClient.getGasPrice(), ]) - - const gasPriceSurplus = (gasPriceBase * 5n) / 100n // 5% surplus - const gasPrice = gasPriceBase + gasPriceSurplus - - const gasByWeight = weight / EVM_GAS_TO_WEIGHT - const baseGasLimit = gasByWeight > gas ? gasByWeight : gas - const gasLimitSurplus = (baseGasLimit * 30n) / 100n // 30% surplus - const gasLimit = baseGasLimit + gasLimitSurplus + const gasPrice = getPermitGasPrice(gasPriceBase) + const gasLimit = getPermitGasLimit(gas, weight) return { gas, @@ -196,7 +183,6 @@ export class EthereumSigner { const weight = options.weight ?? 0n const isExtendedEvmCall = isObjectType(call) - const tx = isExtendedEvmCall ? { from: call.from as Address, @@ -218,7 +204,6 @@ export class EthereumSigner { isExtendedEvmCall && call.gasLimit ? call.gasLimit : (await this.estimateGas(tx, weight)).gasLimit - const nonce = options?.nonce ?? (await this.getPermitNonce()) const createPermitMessageData = () => { diff --git a/packages/web3-connect/src/utils/index.ts b/packages/web3-connect/src/utils/index.ts index 60fd23b922..eadeb63565 100644 --- a/packages/web3-connect/src/utils/index.ts +++ b/packages/web3-connect/src/utils/index.ts @@ -1,6 +1,7 @@ export * from "./errors" export * from "./ethereum" export * from "./identity" +export * from "./permitGas" export * from "./polkadot" export * from "./signer" export * from "./wallet" diff --git a/packages/web3-connect/src/utils/permitGas.ts b/packages/web3-connect/src/utils/permitGas.ts new file mode 100644 index 0000000000..3007017a7e --- /dev/null +++ b/packages/web3-connect/src/utils/permitGas.ts @@ -0,0 +1,31 @@ +import { clampBigInt } from "@galacticcouncil/utils" + +import { + EVM_GAS_TO_WEIGHT, + EVM_MAX_GAS_LIMIT, + EVM_MIN_GAS_LIMIT, +} from "@/config/evm" + +const GAS_LIMIT_SURPLUS_PERCENT = 30n +const GAS_PRICE_SURPLUS_PERCENT = 5n + +export const getPermitGasFromWeight = (weight: bigint): bigint => { + const gasByWeight = clampBigInt(weight / EVM_GAS_TO_WEIGHT, { + min: EVM_MIN_GAS_LIMIT, + max: EVM_MAX_GAS_LIMIT, + }) + const gasLimitSurplus = (gasByWeight * GAS_LIMIT_SURPLUS_PERCENT) / 100n + + return gasByWeight + gasLimitSurplus +} + +export const getPermitGasLimit = (gas: bigint, weight: bigint): bigint => { + const gasByWeight = weight / EVM_GAS_TO_WEIGHT + const baseGasLimit = gasByWeight > gas ? gasByWeight : gas + const gasLimitSurplus = (baseGasLimit * GAS_LIMIT_SURPLUS_PERCENT) / 100n + + return baseGasLimit + gasLimitSurplus +} + +export const getPermitGasPrice = (gasPriceBase: bigint): bigint => + gasPriceBase + (gasPriceBase * GAS_PRICE_SURPLUS_PERCENT) / 100n From bd1c22d582b11fe169cd3ecb7c61f856912bd864 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Thu, 18 Jun 2026 17:24:46 +0200 Subject: [PATCH 07/18] fix lint --- .../strategies/stable-bonds/hooks/useSubmitStableBondsOrder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/main/src/modules/strategies/stable-bonds/hooks/useSubmitStableBondsOrder.ts b/apps/main/src/modules/strategies/stable-bonds/hooks/useSubmitStableBondsOrder.ts index 163dda69b5..1a71da7088 100644 --- a/apps/main/src/modules/strategies/stable-bonds/hooks/useSubmitStableBondsOrder.ts +++ b/apps/main/src/modules/strategies/stable-bonds/hooks/useSubmitStableBondsOrder.ts @@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query" import { useTranslation } from "react-i18next" import type { StableBondsFormValues } from "@/modules/strategies/stable-bonds/components/StableBondsDeposit.form" -import { getOtcFillOrderTx } from "@/modules/trade/otc/fill-order/FillOrderModalContent.submit" +import { getOtcFillOrderTx } from "@/modules/trade/otc/fill-order/FillOrder.utils" import { type OtcOffer } from "@/modules/trade/otc/table/OtcTable.query" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" From eb321a14eac1fa870756b8686882c33dc0405810 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Thu, 25 Jun 2026 11:21:24 +0200 Subject: [PATCH 08/18] add missing form's max balances --- .../RemoveMoneyMarketLiquidity.tsx | 5 +-- .../RemoveMoneyMarketLiquidity.utils.tsx | 15 ++++++-- .../RemoveStablepoolLiquidity.tsx | 1 - .../RemoveStablepoolLiquidity.utils.ts | 35 ++++++++++++------- .../components/StableBondsDeposit.form.ts | 28 +++++++++++---- .../components/StableBondsDeposit.tsx | 12 ++----- .../transactions/hooks/useEstimateFee.ts | 5 ++- .../hooks/useFormMaxBalanceWithFee.ts | 7 ++-- .../modules/transactions/utils/permitFee.ts | 23 ------------ 9 files changed, 69 insertions(+), 62 deletions(-) diff --git a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.tsx b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.tsx index e8cf6996dd..ae7284af1f 100644 --- a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.tsx +++ b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.tsx @@ -83,6 +83,7 @@ export const RemoveMoneyMarketLiquidityForm = ( const { form, balance, + getMaxBalance, receiveAssetsProportionally, meta, tradeMinReceive, @@ -97,7 +98,7 @@ export const RemoveMoneyMarketLiquidityForm = ( watch, } = form - const [receiveAsset, split] = watch(["receiveAsset", "split"]) + const [asset, receiveAsset, split] = watch(["asset", "receiveAsset", "split"]) const onSubmit = () => { mutation.mutate() @@ -117,7 +118,7 @@ export const RemoveMoneyMarketLiquidityForm = ( assetFieldName="asset" amountFieldName="amount" label={t("common:amount")} - maxBalance={balance} + maxBalance={getMaxBalance(asset)} assets={[]} sx={{ py: 0 }} disabledAssetSelector diff --git a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.utils.tsx b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.utils.tsx index aef91e9aae..c2c4780e55 100644 --- a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.utils.tsx +++ b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.utils.tsx @@ -57,10 +57,20 @@ export const useRemoveMoneyMarketLiquidity = ({ const initialReceiveAsset = reserves[0]?.meta - const form = useRemoveStablepoolLiquidityForm({ + const { data: feeEstimateTrade } = useQuery( + bestSellWithTxQuery(rpc, { + assetIn: erc20Id, + assetOut: stableswapId, + amountIn: "1", + slippage: swapSlippage, + address: account?.address ?? "", + }), + ) + + const { form, getMaxBalance } = useRemoveStablepoolLiquidityForm({ receiveAsset: initialReceiveAsset!, - balance: balanceShifted, asset: { ...meta, iconId: meta.id }, + tx: feeEstimateTrade?.tx ?? null, }) const [removeAmountShifted = "0", receiveAsset, split, receiveAmount] = @@ -300,6 +310,7 @@ export const useRemoveMoneyMarketLiquidity = ({ return { form, balance: balanceShifted, + getMaxBalance, meta, reserves: reserves, receiveAssetsProportionally, diff --git a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.tsx b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.tsx index d1f470eb02..caf7ae2d0f 100644 --- a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.tsx +++ b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.tsx @@ -137,7 +137,6 @@ const RemoveStablepoolLiquidityJSX = ({ assetFieldName="asset" amountFieldName="amount" label={t("common:amount")} - maxBalance={balance} assets={[]} sx={{ py: 0 }} disabledAssetSelector diff --git a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.utils.ts b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.utils.ts index 813fa741e5..9d6c3f5438 100644 --- a/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.utils.ts +++ b/apps/main/src/modules/liquidity/components/RemoveLiquidity/RemoveStablepoolLiquidity.utils.ts @@ -14,13 +14,15 @@ import { TSelectedAsset } from "@/components/AssetSelect/AssetSelect" import { TAssetWithBalance } from "@/components/AssetSelectModal/AssetSelectModal.utils" import { TRemoveLiquidityFormValues } from "@/modules/liquidity/components/RemoveLiquidity/RemoveLiquidity.utils" import { calculatePoolFee, TReserve } from "@/modules/liquidity/Liquidity.utils" +import { useFormMaxBalanceWithFee } from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" +import { AnyTransaction } from "@/modules/transactions/types" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { useAccountBalances } from "@/states/account" import { useTradeSettings } from "@/states/tradeSettings" import { useTransactionsStore } from "@/states/transactions" import { scale, scaleHuman, toBigInt, toDecimal } from "@/utils/formatting" -import { positive, required, validateFieldMaxBalance } from "@/utils/validators" +import { positive, required } from "@/utils/validators" export type TRemoveStablepoolLiquidityFormValues = TRemoveLiquidityFormValues & { @@ -57,10 +59,10 @@ export const useStablepoolRemoveLiquidity = ({ meta.decimals, ) - const form = useRemoveStablepoolLiquidityForm({ + const { form, getMaxBalance } = useRemoveStablepoolLiquidityForm({ receiveAsset: initialReceiveAsset, - balance: balanceShifted, asset: { ...meta, iconId: meta.underlyingAssetId }, + tx: null, }) const removeAmountShifted = form.watch("amount") || "0" @@ -181,6 +183,7 @@ export const useStablepoolRemoveLiquidity = ({ return { form, balance: balanceShifted, + getMaxBalance, fee, receiveAssetsProportionally, mutation, @@ -191,14 +194,16 @@ export const useStablepoolRemoveLiquidity = ({ export const useRemoveStablepoolLiquidityForm = ({ asset, - balance, receiveAsset, + tx, }: { asset?: TSelectedAsset receiveAsset: TAssetData - balance: string + tx: AnyTransaction | null }) => { - return useForm({ + const { validateBalance, getMaxBalance } = useFormMaxBalanceWithFee(tx, 3) + + const form = useForm({ mode: "onChange", defaultValues: { amount: "", @@ -208,13 +213,17 @@ export const useRemoveStablepoolLiquidityForm = ({ receiveAmount: "", }, resolver: standardSchemaResolver( - z.object({ - amount: required.pipe(positive).check(validateFieldMaxBalance(balance)), - asset: z.custom(), - split: z.boolean(), - receiveAsset: z.custom(), - receiveAmount: z.string(), - }), + z + .object({ + amount: required.pipe(positive), + asset: z.custom(), + split: z.boolean(), + receiveAsset: z.custom(), + receiveAmount: z.string(), + }) + .check(validateBalance("amount", (form) => [form.asset, form.amount])), ), }) + + return { form, getMaxBalance } } diff --git a/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.form.ts b/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.form.ts index c2998fe22a..ca5e57a2f6 100644 --- a/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.form.ts +++ b/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.form.ts @@ -6,13 +6,17 @@ import { useTranslation } from "react-i18next" import * as z from "zod/v4" import { otcExistentialDepositorMultiplierQuery } from "@/api/otc" +import { getOtcFillOrderTx } from "@/modules/trade/otc/fill-order/FillOrder.utils" import { OtcOffer } from "@/modules/trade/otc/table/OtcTable.query" +import { + useFormMaxBalanceWithFee, + ValidateFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import type { TAsset } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { positiveOptional, requiredObject, - useValidateFormMaxBalance, validateFormExistentialDeposit, } from "@/utils/validators" @@ -25,15 +29,16 @@ const isFillableOrder = (order: OtcOffer) => const getDefaultDepositAsset = (orders: OtcOffer[]) => orders.find(isFillableOrder)?.assetIn ?? orders[0]?.assetIn ?? null -const useSchema = (orders: OtcOffer[]) => { +const useSchema = ( + orders: OtcOffer[], + validateBalance: ValidateFormMaxBalanceWithFee, +) => { const { t } = useTranslation(["trade"]) const rpc = useRpcProvider() const { data: existentialDepositMultiplier } = useQuery( otcExistentialDepositorMultiplierQuery(rpc), ) - const refineFormMaxBalance = useValidateFormMaxBalance() - return z .object({ depositAsset: requiredObject(), @@ -62,7 +67,7 @@ const useSchema = (orders: OtcOffer[]) => { form.depositAmount, ] }), - refineFormMaxBalance("depositAmount", (form) => [ + validateBalance("depositAmount", (form) => [ form.depositAsset, form.depositAmount, ]), @@ -72,12 +77,21 @@ const useSchema = (orders: OtcOffer[]) => { export type StableBondsFormValues = z.infer> export const useStableBondsForm = (orders: OtcOffer[]) => { - return useForm({ + const { papi } = useRpcProvider() + const defaultOrder = getSelectedOrder(orders, getDefaultDepositAsset(orders)) + + const { validateBalance, getMaxBalance } = useFormMaxBalanceWithFee( + defaultOrder ? getOtcFillOrderTx(papi, defaultOrder, "1") : null, + ) + + const form = useForm({ defaultValues: { depositAsset: getDefaultDepositAsset(orders), depositAmount: "", }, - resolver: standardSchemaResolver(useSchema(orders)), + resolver: standardSchemaResolver(useSchema(orders, validateBalance)), mode: "onChange", }) + + return { form, getMaxBalance } } diff --git a/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.tsx b/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.tsx index b1da25a9ac..e3178bad36 100644 --- a/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.tsx +++ b/apps/main/src/modules/strategies/stable-bonds/components/StableBondsDeposit.tsx @@ -40,8 +40,6 @@ import { OtcOffer } from "@/modules/trade/otc/table/OtcTable.query" import { otcTradeFeeQuery } from "@/modules/trade/otc/TradeFee.query" import { isBond, useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" -import { useAccountBalance } from "@/states/account" -import { scaleHuman } from "@/utils/formatting" export type StableBondsDepositProps = { orders: OtcOffer[] @@ -60,7 +58,7 @@ export const StableBondsDeposit: React.FC = ({ const { data: feePct = "0", isPending: isFeePending } = useQuery( otcTradeFeeQuery(rpc), ) - const form = useStableBondsForm(orders) + const { form, getMaxBalance } = useStableBondsForm(orders) const depositAssets = useMemo(() => { return [ @@ -83,18 +81,13 @@ export const StableBondsDeposit: React.FC = ({ const isSelectedOrderFillable = !!selectedOrder?.id && Big(selectedOrder.assetAmountIn).gt(0) - const depositAssetId = selectedOrder?.assetIn.id ?? "" const receiveAsset = selectedOrder?.assetOut ?? null const underlyingAssetId = receiveAsset && isBond(receiveAsset) ? receiveAsset.underlyingAssetId : "" const underlyingAsset = getAssetWithFallback(underlyingAssetId) - const depositBalance = useAccountBalance(depositAssetId) - const depositAssetBalance = - selectedOrder && depositBalance - ? scaleHuman(depositBalance.transferable, selectedOrder.assetIn.decimals) - : "0" + const depositAssetBalance = selectedOrder ? getMaxBalance(depositAsset) : "0" const assetInMax = selectedOrder ? Big.min(selectedOrder.assetAmountIn, depositAssetBalance).toString() @@ -127,6 +120,7 @@ export const StableBondsDeposit: React.FC = ({ assets={depositAssets} disabled={!isSelectedOrderFillable} maxButtonBalance={assetInMax} + maxBalance={depositAssetBalance} /> diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 7d6906bd7a..144c39e58d 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -16,7 +16,6 @@ import { getSpotPrice } from "@/api/spotPrice" import { AnyTransaction } from "@/modules/transactions/types" import { estimatePermitFee, - getPermitFeeQueryKeyPart, isPermitFeeEstimation, } from "@/modules/transactions/utils/permitFee" import { isPapiTransaction } from "@/modules/transactions/utils/polkadot" @@ -86,7 +85,7 @@ export const useEstimateFee = ( feeAssetId, address, isUsingPermitFee, - safeStringify(getPermitFeeQueryKeyPart(anyTx) ?? tx?.decodedCall), + safeStringify(tx?.decodedCall), ], queryFn: async () => { if (!anyTx) throw new Error("Invalid transaction") @@ -125,7 +124,7 @@ export const useEstimateFee = ( if (spot?.spotPrice) { const feeEstimate = Big(feeEstimateNative) .mul(spot.spotPrice) - .toString() + .toFixed(feeAsset.decimals) return { feeEstimateNative, diff --git a/apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts b/apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts index 6fbd8558dd..a90c07c492 100644 --- a/apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts +++ b/apps/main/src/modules/transactions/hooks/useFormMaxBalanceWithFee.ts @@ -2,6 +2,7 @@ import { FieldValues, Path } from "react-hook-form" import * as z from "zod/v4" import { useAccountFeePaymentAssetId } from "@/api/payments" +import { TSelectedAsset } from "@/components/AssetSelect/AssetSelect" import { useMaxBalanceWithFee } from "@/modules/transactions/hooks/useMaxBalanceWithFee" import { AnyTransaction } from "@/modules/transactions/types" import { TAsset } from "@/providers/assetsProvider" @@ -17,7 +18,7 @@ export const useFormMaxBalanceWithFee = ( const { getTransferableBalance } = useAccountBalances() const maxBalanceWithFee = useMaxBalanceWithFee(tx, feePctBuffer) - const getMaxBalance = (asset: TAsset | null) => { + const getMaxBalance = (asset: TAsset | TSelectedAsset | null) => { if (!asset) { return "0" } @@ -31,7 +32,9 @@ export const useFormMaxBalanceWithFee = ( const validateBalance = ( path: Path>, - selectData: (form: TFormValues) => [asset: TAsset | null, amount: string], + selectData: ( + form: TFormValues, + ) => [asset: TAsset | TSelectedAsset | null, amount: string], ) => z.refine( (form) => { diff --git a/apps/main/src/modules/transactions/utils/permitFee.ts b/apps/main/src/modules/transactions/utils/permitFee.ts index 941bbb0b00..472bc5b968 100644 --- a/apps/main/src/modules/transactions/utils/permitFee.ts +++ b/apps/main/src/modules/transactions/utils/permitFee.ts @@ -12,7 +12,6 @@ import { EstimateGasParameters, formatEther, Hex } from "viem" import { getSpotPrice, spotPriceQuery } from "@/api/spotPrice" import { paymentInfoQuery } from "@/api/transaction" import { AnyTransaction } from "@/modules/transactions/types" -import { isPapiTransaction } from "@/modules/transactions/utils/polkadot" import { transformAnyToPapiTx } from "@/modules/transactions/utils/tx" import { isEvmCall } from "@/modules/transactions/utils/xcm" import { TAsset } from "@/providers/assetsProvider" @@ -136,28 +135,6 @@ export const isPermitFeeEstimation = ( isEthereumWallet: boolean, ) => isEthereumWallet && feeAssetId !== NATIVE_EVM_ASSET_ID -export const getPermitFeeQueryKeyPart = (anyTx: AnyTransaction | null) => { - if (!anyTx) return null - - if (isEvmCall(anyTx)) { - return { - type: "evm" as const, - from: anyTx.from, - to: anyTx.to, - data: anyTx.data, - } - } - - if (isPapiTransaction(anyTx)) { - return { - type: "papi" as const, - call: anyTx.decodedCall, - } - } - - return null -} - export const estimatePermitFee = async ( rpc: TProviderContext, address: string, From 736fdb60dc2c0156b1e1754466088e7cdff45f8b Mon Sep 17 00:00:00 2001 From: vkulinich Date: Fri, 26 Jun 2026 16:58:19 +0200 Subject: [PATCH 09/18] support weth signing --- .../transactions/hooks/useEstimateFee.ts | 31 +++++---- .../modules/transactions/utils/permitFee.ts | 64 +++++++++++++------ 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 144c39e58d..0494165c1c 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -15,6 +15,7 @@ import { useAccountFeePaymentAssetId } from "@/api/payments" import { getSpotPrice } from "@/api/spotPrice" import { AnyTransaction } from "@/modules/transactions/types" import { + estimateEvmFee, estimatePermitFee, isPermitFeeEstimation, } from "@/modules/transactions/utils/permitFee" @@ -56,10 +57,7 @@ export const useEstimateFee = ( const tx = anyTx ? transformAnyToPapiTx(papi, anyTx) : null - const isUsingPermitFee = isPermitFeeEstimation( - feeAssetId ?? "", - isEthereumSigner(wallet?.signer), - ) + const isEthereumWallet = isEthereumSigner(wallet?.signer) const feeAssetBalance = feeAsset ? scaleHuman(getTransferableBalance(feeAsset.id), feeAsset.decimals) @@ -84,21 +82,30 @@ export const useEstimateFee = ( "estimateFee", feeAssetId, address, - isUsingPermitFee, safeStringify(tx?.decodedCall), ], queryFn: async () => { if (!anyTx) throw new Error("Invalid transaction") if (!feeAsset) throw new Error(`Asset ${feeAssetId} is not valid`) - if (isUsingPermitFee) { - try { - return await estimatePermitFee(rpc, address, anyTx, feeAsset, native) - } catch { - // Fall back to substrate-only estimate for PAPI txs if EVM simulation fails. - if (!isPapiTransaction(anyTx)) - throw new Error("Permit fee estimation failed") + if (isEthereumWallet) { + if (isPermitFeeEstimation(feeAssetId ?? "")) { + try { + return await estimatePermitFee( + rpc, + address, + anyTx, + feeAsset, + native, + ) + } catch { + // Fall back to substrate-only estimate for PAPI txs if EVM simulation fails. + if (!isPapiTransaction(anyTx)) + throw new Error("Permit fee estimation failed") + } } + + return estimateEvmFee(rpc, address, anyTx, feeAsset, native) } if (!tx) throw new Error("Invalid transaction") diff --git a/apps/main/src/modules/transactions/utils/permitFee.ts b/apps/main/src/modules/transactions/utils/permitFee.ts index 472bc5b968..b9dabb22ab 100644 --- a/apps/main/src/modules/transactions/utils/permitFee.ts +++ b/apps/main/src/modules/transactions/utils/permitFee.ts @@ -19,12 +19,32 @@ import { TProviderContext } from "@/providers/rpcProvider" import { NATIVE_EVM_ASSET_ID } from "@/utils/consts" import { scaleHuman } from "@/utils/formatting" -type PermitFeeEstimate = { +type FeeEstimate = { feeEstimateNative: string feeEstimate: string feeAssetId: string } +const getEvmGasFeeWei = async ( + rpc: TProviderContext, + address: string, + anyTx: AnyTransaction, +): Promise => { + const weight = await getPermitWeight(rpc, address, anyTx) + + const [gas, gasPriceBase] = await Promise.all([ + getPermitGas(rpc, address, anyTx, weight), + rpc.evm.getGasPrice(), + ]) + + const gasLimit = getPermitGasLimit(gas, weight) + const gasPrice = getPermitGasPrice(gasPriceBase) + + // Wallets validate balance against gasLimit × gasPrice before signing. + // On-chain, only gas_used × gas_price is charged. + return gasLimit * gasPrice +} + const getPermitWeight = async ( rpc: TProviderContext, address: string, @@ -130,35 +150,37 @@ const convertNativeFeeToFeeAsset = async ( return Big(1).div(assetPaymentValueAdjusted).mul(feeEstimateNative).toString() } -export const isPermitFeeEstimation = ( - feeAssetId: string, - isEthereumWallet: boolean, -) => isEthereumWallet && feeAssetId !== NATIVE_EVM_ASSET_ID +export const isPermitFeeEstimation = (feeAssetId: string) => + feeAssetId !== NATIVE_EVM_ASSET_ID -export const estimatePermitFee = async ( +export const estimateEvmFee = async ( rpc: TProviderContext, address: string, anyTx: AnyTransaction, feeAsset: TAsset, native: TAsset, -): Promise => { - const weight = await getPermitWeight(rpc, address, anyTx) +): Promise => { + const wei = await getEvmGasFeeWei(rpc, address, anyTx) - const [gas, gasPriceBase] = await Promise.all([ - getPermitGas(rpc, address, anyTx, weight), - rpc.evm.getGasPrice(), - ]) + const feeEstimateNative = await evmWeiToNativeFee(rpc, wei, native.id) - const gasLimit = getPermitGasLimit(gas, weight) - const gasPrice = getPermitGasPrice(gasPriceBase) + return { + feeEstimateNative, + feeEstimate: formatEther(wei), + feeAssetId: feeAsset.id, + } +} - // MetaMask validates balance against gasLimit × gasPrice before signing the permit. - // On-chain, only gas_used × gas_price is charged. - const feeEstimateNative = await evmWeiToNativeFee( - rpc, - gasLimit * gasPrice, - native.id, - ) +export const estimatePermitFee = async ( + rpc: TProviderContext, + address: string, + anyTx: AnyTransaction, + feeAsset: TAsset, + native: TAsset, +): Promise => { + const wei = await getEvmGasFeeWei(rpc, address, anyTx) + + const feeEstimateNative = await evmWeiToNativeFee(rpc, wei, native.id) const feeEstimate = await convertNativeFeeToFeeAsset( rpc, From 56a3a96c54b344eddbd0beb81114d61e4f40675c Mon Sep 17 00:00:00 2001 From: vkulinich Date: Tue, 30 Jun 2026 11:04:45 +0200 Subject: [PATCH 10/18] add max balance with fee to MM --- .../modules/borrow/BorrowContextProvider.tsx | 10 +++ .../transactions/hooks/useMaxBalance.ts | 29 ++++--- .../src/components/MoneyMarketProvider.tsx | 17 ++++- .../transactions/repay/RepayModalContent.tsx | 33 +++++++- .../supply/SupplyModalContent.tsx | 23 +++++- .../withdraw/WithdrawModalContent.tsx | 42 ++++++++--- .../src/hooks/useRepayEstimationTx.ts | 69 +++++++++++++++++ .../src/hooks/useSupplyEstimationTx.ts | 44 +++++++++++ .../src/hooks/useWithdrawEstimationTx.ts | 55 ++++++++++++++ .../libs/web3-data-provider/Web3Provider.tsx | 70 +---------------- packages/money-market/src/types/index.ts | 15 ++++ .../ui-config/SharedDependenciesProvider.tsx | 9 ++- packages/money-market/src/utils/convertTx.ts | 75 +++++++++++++++++++ 13 files changed, 393 insertions(+), 98 deletions(-) create mode 100644 packages/money-market/src/hooks/useRepayEstimationTx.ts create mode 100644 packages/money-market/src/hooks/useSupplyEstimationTx.ts create mode 100644 packages/money-market/src/hooks/useWithdrawEstimationTx.ts create mode 100644 packages/money-market/src/utils/convertTx.ts diff --git a/apps/main/src/modules/borrow/BorrowContextProvider.tsx b/apps/main/src/modules/borrow/BorrowContextProvider.tsx index 2de1259f23..faebcae2c5 100644 --- a/apps/main/src/modules/borrow/BorrowContextProvider.tsx +++ b/apps/main/src/modules/borrow/BorrowContextProvider.tsx @@ -12,7 +12,9 @@ import { ApyProvider } from "@/modules/borrow/context/ApyContext" import { useExternalApyData } from "@/modules/borrow/hooks/useExternalApyData" import { useFormatReserve } from "@/modules/borrow/hooks/useFormatReserve" import { useCreateBatchTx } from "@/modules/transactions/hooks/useBatchTx" +import { useMaxBalance } from "@/modules/transactions/hooks/useMaxBalance" import { transformEvmCallToPapiTx } from "@/modules/transactions/utils/tx" +import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { useTransactionsStore } from "@/states/transactions" @@ -29,6 +31,7 @@ export const BorrowContextProvider: React.FC = ({ const createBatchTx = useCreateBatchTx() const { evm, dataEnv, papi } = useRpcProvider() const squidClient = useSquidClient() + const { getRelatedAToken } = useAssets() const createTx = useCallback( ({ tx, toasts }, options, withExtraGas) => { @@ -50,6 +53,11 @@ export const BorrowContextProvider: React.FC = ({ [createTransaction, createBatchTx, papi], ) + const getRelatedATokenId = useCallback( + (id: string) => getRelatedAToken(id)?.id, + [getRelatedAToken], + ) + return ( = ({ provider={evm.transport} squidClient={squidClient} onCreateTransaction={createTx} + useMaxBalance={useMaxBalance} formatCurrency={createFormatterFn(t, "currency")} formatNumber={createFormatterFn(t, "number")} formatPercent={createFormatterFn(t, "percent")} formatReserve={useFormatReserve()} externalApyData={useExternalApyData()} + getRelatedATokenId={getRelatedATokenId} > {children} diff --git a/apps/main/src/modules/transactions/hooks/useMaxBalance.ts b/apps/main/src/modules/transactions/hooks/useMaxBalance.ts index ee3043b23d..cf6168ea4b 100644 --- a/apps/main/src/modules/transactions/hooks/useMaxBalance.ts +++ b/apps/main/src/modules/transactions/hooks/useMaxBalance.ts @@ -1,20 +1,27 @@ import { useAccountFeePaymentAssetId } from "@/api/payments" import { useMaxBalanceWithFee } from "@/modules/transactions/hooks/useMaxBalanceWithFee" import { AnyTransaction } from "@/modules/transactions/types" -import { TAsset } from "@/providers/assetsProvider" +import { useAssets } from "@/providers/assetsProvider" import { useAccountBalances } from "@/states/account" import { scaleHuman } from "@/utils/formatting" -export const useMaxBalance = ( - asset: TAsset | null, - tx: AnyTransaction | null, - feePctBuffer?: number, -) => { +export const useMaxBalance = ({ + assetId, + tx, + feePctBuffer, + balance, +}: { + assetId?: string + tx: AnyTransaction | null + balance?: string + feePctBuffer?: number +}) => { const { data: accountFeePaymentAssetId } = useAccountFeePaymentAssetId() + const { getAssetWithFallback } = useAssets() const { getTransferableBalance } = useAccountBalances() - const isFeePaymentAsset = asset - ? accountFeePaymentAssetId === Number(asset.id) + const isFeePaymentAsset = assetId + ? accountFeePaymentAssetId === Number(assetId) : false const maxBalanceWithFee = useMaxBalanceWithFee( @@ -22,7 +29,7 @@ export const useMaxBalance = ( feePctBuffer, ) - if (!asset) { + if (!assetId) { return { maxBalanceHuman: "0", } @@ -31,8 +38,8 @@ export const useMaxBalance = ( if (!isFeePaymentAsset) { return { maxBalanceHuman: scaleHuman( - getTransferableBalance(asset.id), - asset.decimals, + balance ?? getTransferableBalance(assetId), + getAssetWithFallback(assetId).decimals, ), } } diff --git a/packages/money-market/src/components/MoneyMarketProvider.tsx b/packages/money-market/src/components/MoneyMarketProvider.tsx index 13c5494804..015be3740d 100644 --- a/packages/money-market/src/components/MoneyMarketProvider.tsx +++ b/packages/money-market/src/components/MoneyMarketProvider.tsx @@ -12,7 +12,12 @@ import { ModalContextProvider } from "@/hooks/useModal" import { PermissionProvider } from "@/hooks/usePermissions" import { Web3ContextProvider } from "@/libs/web3-data-provider/Web3Provider" import { useRootStore } from "@/store/root" -import { ExternalApyData, MoneyMarketEnv, MoneyMarketTxFn } from "@/types" +import { + ExternalApyData, + MoneyMarketEnv, + MoneyMarketTxFn, + UseMaxBalanceFn, +} from "@/types" import { SharedDependenciesProvider } from "@/ui-config/SharedDependenciesProvider" import { CustomMarket } from "@/utils" @@ -58,6 +63,8 @@ export type MoneyMarketProviderProps = AppFormattersProvidersContextType & { env: MoneyMarketEnv squidClient: SquidSdk onCreateTransaction: MoneyMarketTxFn + useMaxBalance: UseMaxBalanceFn + getRelatedATokenId: (id: string) => string | undefined externalApyData: ExternalApyData } @@ -65,6 +72,8 @@ export const MoneyMarketProvider: FC = ({ children, env, onCreateTransaction, + useMaxBalance, + getRelatedATokenId, provider: externalProvider, squidClient, externalApyData, @@ -97,7 +106,11 @@ export const MoneyMarketProvider: FC = ({ - + {children} diff --git a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx index af6f341117..85616695d3 100644 --- a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx +++ b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx @@ -13,7 +13,7 @@ import { Stack, SummaryRow, } from "@galacticcouncil/ui/components" -import { bigShift } from "@galacticcouncil/utils" +import { bigShift, getAssetIdFromAddress } from "@galacticcouncil/utils" import Big, { BigSource } from "big.js" import BigNumber from "bignumber.js" import { useEffect, useRef, useState } from "react" @@ -24,7 +24,9 @@ import { TxModalWrapperRenderProps } from "@/components/transactions/TxModalWrap import { useAppDataContext } from "@/hooks/app-data-provider/useAppDataProvider" import { useAppFormatters } from "@/hooks/app-data-provider/useAppFormatters" import { useProtocolDataContext } from "@/hooks/useProtocolDataContext" +import { useRepayEstimationTx } from "@/hooks/useRepayEstimationTx" import { useRootStore } from "@/store/root" +import { useSharedDependencies } from "@/ui-config/SharedDependenciesProvider" import { formatHealthFactorResult } from "@/utils" import { getNetworkConfig } from "@/utils/marketsAndNetworksConfig" @@ -83,6 +85,28 @@ export const RepayModalContent: React.FC< .mul("1.0025") .round(poolReserve.decimals, BigNumber.ROUND_UP) + const { useMaxBalance } = useSharedDependencies() + const { data: repayEstimationTx } = useRepayEstimationTx({ + poolAddress: repayWithATokens + ? poolReserve.underlyingAsset + : (tokenToRepayWith.address ?? ""), + debtType, + repayWithATokens, + amount: safeAmountToRepayAll.toString(), + decimals: poolReserve.decimals, + enabled: !repayWithATokens, + }) + const repayAssetId = getAssetIdFromAddress( + tokenToRepayWith.address ?? poolReserve.underlyingAsset, + ) + + const maxBalanceWithFee = useMaxBalance({ + tx: repayEstimationTx ?? null, + assetId: repayAssetId, + feePctBuffer: 0.1, + }) + const balanceWithFee = maxBalanceWithFee?.maxBalanceHuman + // calculate max amount abailable to repay let maxAmountToRepay: BigNumber let balance: string @@ -96,8 +120,11 @@ export const RepayModalContent: React.FC< ? minRemainingBaseTokenBalance : "0", ) - balance = normalizedWalletBalance.toString() - maxAmountToRepay = BigNumber.min(normalizedWalletBalance, debt) + + const walletBalance = balanceWithFee ?? normalizedWalletBalance.toString() + balance = walletBalance + + maxAmountToRepay = BigNumber.min(walletBalance, debt) } const isMaxSelected = diff --git a/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx b/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx index 9525b75d82..b75dda9aa6 100644 --- a/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx +++ b/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx @@ -25,8 +25,10 @@ import { useAppFormatters } from "@/hooks/app-data-provider/useAppFormatters" import { useAssetCaps } from "@/hooks/useAssetCaps" import { useModalContext } from "@/hooks/useModal" import { useProtocolDataContext } from "@/hooks/useProtocolDataContext" +import { useSupplyEstimationTx } from "@/hooks/useSupplyEstimationTx" import { useRootStore } from "@/store/root" import { PRIME_APY, PRIME_ASSET_ID } from "@/ui-config" +import { useSharedDependencies } from "@/ui-config/SharedDependenciesProvider" import { formatHealthFactorResult, isShowIsolationWarning } from "@/utils" import { getMaxAmountAvailableToSupply } from "@/utils/getMaxAmountAvailableToSupply" import { getAssetCollateralType } from "@/utils/transactions" @@ -63,9 +65,24 @@ export const SupplyModalContent = React.memo( underlyingAsset.toLowerCase() === API_ETH_MOCK_ADDRESS.toLowerCase() const walletBalance = supplyUnWrapped ? nativeBalance : tokenBalance + const poolAddress = poolReserve.underlyingAsset + + const { useMaxBalance } = useSharedDependencies() + const supplyAssetId = getAssetIdFromAddress(poolAddress) + const { data: supplyEstimationTx } = useSupplyEstimationTx({ + poolAddress, + amount: walletBalance, + decimals: poolReserve.decimals, + }) + const maxBalanceWithFee = useMaxBalance({ + tx: supplyEstimationTx ?? null, + assetId: supplyAssetId, + feePctBuffer: 0.1, + }) + const walletBalanceWithFee = + maxBalanceWithFee?.maxBalanceHuman ?? walletBalance const isIsolated = poolReserve.isIsolated - const poolAddress = poolReserve.underlyingAsset const isPrimeAsset = PRIME_ASSET_ID === getAssetIdFromAddress(poolAddress) const { @@ -124,7 +141,7 @@ export const SupplyModalContent = React.memo( const maxAmountToSupply = useMemo( () => getMaxAmountAvailableToSupply( - walletBalance, + walletBalanceWithFee, { supplyCap, totalLiquidity, @@ -137,7 +154,7 @@ export const SupplyModalContent = React.memo( minRemainingBaseTokenBalance, ), [ - walletBalance, + walletBalanceWithFee, supplyCap, totalLiquidity, isFrozen, diff --git a/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx b/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx index 3103d7b663..f15aeaaee8 100644 --- a/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx +++ b/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx @@ -1,4 +1,5 @@ import { Separator, Stack, SummaryRow } from "@galacticcouncil/ui/components" +import { getAssetIdFromAddress } from "@galacticcouncil/utils" import Big from "big.js" import { useRef, useState } from "react" @@ -9,6 +10,8 @@ import { TxModalWrapperRenderProps } from "@/components/transactions/TxModalWrap import { useAppDataContext } from "@/hooks/app-data-provider/useAppDataProvider" import { useAppFormatters } from "@/hooks/app-data-provider/useAppFormatters" import { useModalContext } from "@/hooks/useModal" +import { useWithdrawEstimationTx } from "@/hooks/useWithdrawEstimationTx" +import { useSharedDependencies } from "@/ui-config/SharedDependenciesProvider" import { formatHealthFactorResult } from "@/utils" import { calculateHFAfterWithdraw } from "@/utils/hfUtils" import { zeroLTVBlockingWithdraw } from "@/utils/transactions" @@ -44,25 +47,42 @@ export const WithdrawModalContent: React.FC = ({ userReserve, poolReserve, ) + + const { useMaxBalance, getRelatedATokenId } = useSharedDependencies() + + const withdrawAssetId = getAssetIdFromAddress(poolReserve.underlyingAsset) + const relatedATokenId = getRelatedATokenId(withdrawAssetId) + + const { data: withdrawEstimationTx } = useWithdrawEstimationTx({ + poolAddress: poolReserve.underlyingAsset, + aTokenAddress: poolReserve.aTokenAddress, + amount: maxAmountToWithdraw.toString(), + }) + const maxBalance = useMaxBalance({ + tx: withdrawEstimationTx ?? null, + assetId: relatedATokenId, + feePctBuffer: 0.1, + }) + const maxAmountToWithdrawWithFee = + maxBalance?.maxBalanceHuman ?? maxAmountToWithdraw.toString() + const underlyingBalance = Big(userReserve?.underlyingBalance || "0") const isMaxSelected = - !!_amount && Big(_amount).gte(maxAmountToWithdraw.toString()) - const withdrawAmount = isMaxSelected - ? maxAmountToWithdraw.toString() - : _amount + !!_amount && Big(_amount).gte(maxAmountToWithdrawWithFee) + const withdrawAmount = isMaxSelected ? maxAmountToWithdrawWithFee : _amount const isMaxExceeded = - !!withdrawAmount && Big(withdrawAmount).gt(maxAmountToWithdraw) + !!withdrawAmount && Big(withdrawAmount).gt(maxAmountToWithdrawWithFee) const handleChange = (value: string) => { const maxSelected = - !!value && Big(value).gte(maxAmountToWithdraw.toString()) - amountRef.current = maxSelected ? maxAmountToWithdraw.toString() : value + !!value && Big(value).gte(maxAmountToWithdrawWithFee.toString()) + amountRef.current = maxSelected ? maxAmountToWithdrawWithFee : value setAmount(value) - if (maxSelected && maxAmountToWithdraw.eq(underlyingBalance)) { + if (maxSelected && Big(maxAmountToWithdrawWithFee).eq(underlyingBalance)) { setWithdrawMax("-1") } else { - setWithdrawMax(maxAmountToWithdraw.toString()) + setWithdrawMax(maxAmountToWithdrawWithFee) } } @@ -107,14 +127,14 @@ export const WithdrawModalContent: React.FC = ({ symbol={symbol} assets={[ { - balance: maxAmountToWithdraw.toString(), + balance: maxAmountToWithdrawWithFee, symbol: symbol, iconSymbol: poolReserve.iconSymbol, address: poolReserve.underlyingAsset, }, ]} disabled={withdrawTxState.loading} - maxButtonBalance={maxAmountToWithdraw.toString()} + maxButtonBalance={maxAmountToWithdrawWithFee} balanceLabel="Withdrawable balance" amountError={ isMaxExceeded ? "Insufficient balance on your account." : errorText diff --git a/packages/money-market/src/hooks/useRepayEstimationTx.ts b/packages/money-market/src/hooks/useRepayEstimationTx.ts new file mode 100644 index 0000000000..0dd18fe571 --- /dev/null +++ b/packages/money-market/src/hooks/useRepayEstimationTx.ts @@ -0,0 +1,69 @@ +import { InterestRate, ProtocolAction } from "@aave/contract-helpers" +import { useQuery } from "@tanstack/react-query" +import { parseUnits } from "ethers/lib/utils" + +import { useRootStore } from "@/store/root" +import { ExtendedEvmCall } from "@/types" +import { convertPopulatedTransactionToEvmCall } from "@/utils/convertTx" + +type UseRepayEstimationTxParams = { + poolAddress: string + debtType: InterestRate + repayWithATokens: boolean + amount: string + decimals: number + enabled?: boolean +} + +export const useRepayEstimationTx = ({ + poolAddress, + debtType, + repayWithATokens, + amount, + decimals, + enabled = true, +}: UseRepayEstimationTxParams) => { + const [repay, encodeRepayParams, estimateGasLimit, optimizedPath, account] = + useRootStore((store) => [ + store.repay, + store.encodeRepayParams, + store.estimateGasLimit, + store.useOptimizedPath, + store.account, + ]) + + return useQuery({ + enabled: enabled && !!account && !!amount && !!poolAddress, + queryKey: [ + "repayEstimationTx", + poolAddress, + debtType, + repayWithATokens, + amount, + ], + queryFn: async (): Promise => { + const repayParams = { + amountToRepay: parseUnits(amount, decimals).toString(), + poolAddress, + repayWithATokens, + debtType, + } + + let encodedParams: string | undefined + if (optimizedPath()) { + encodedParams = await encodeRepayParams(repayParams) + } + + let repayTxData = repay({ + ...repayParams, + encodedTxData: encodedParams, + }) + repayTxData = await estimateGasLimit(repayTxData, ProtocolAction.repay) + + return convertPopulatedTransactionToEvmCall( + repayTxData, + ProtocolAction.repay, + ) + }, + }) +} diff --git a/packages/money-market/src/hooks/useSupplyEstimationTx.ts b/packages/money-market/src/hooks/useSupplyEstimationTx.ts new file mode 100644 index 0000000000..7dc9f007c6 --- /dev/null +++ b/packages/money-market/src/hooks/useSupplyEstimationTx.ts @@ -0,0 +1,44 @@ +import { ProtocolAction } from "@aave/contract-helpers" +import { useQuery } from "@tanstack/react-query" +import { parseUnits } from "ethers/lib/utils" + +import { useRootStore } from "@/store/root" +import { ExtendedEvmCall } from "@/types" +import { convertPopulatedTransactionToEvmCall } from "@/utils/convertTx" + +type UseSupplyEstimationTxParams = { + poolAddress: string + amount: string + decimals: number + enabled?: boolean +} + +export const useSupplyEstimationTx = ({ + poolAddress, + amount, + decimals, + enabled = true, +}: UseSupplyEstimationTxParams) => { + const [supply, estimateGasLimit, account] = useRootStore((store) => [ + store.supply, + store.estimateGasLimit, + store.account, + ]) + + return useQuery({ + enabled: enabled && !!account && !!amount && !!poolAddress, + queryKey: ["supplyEstimationTx", poolAddress, amount], + queryFn: async (): Promise => { + let supplyTxData = supply({ + amount: parseUnits(amount, decimals).toString(), + reserve: poolAddress, + }) + supplyTxData = await estimateGasLimit(supplyTxData, ProtocolAction.supply) + + return convertPopulatedTransactionToEvmCall( + supplyTxData, + ProtocolAction.supply, + ) + }, + }) +} diff --git a/packages/money-market/src/hooks/useWithdrawEstimationTx.ts b/packages/money-market/src/hooks/useWithdrawEstimationTx.ts new file mode 100644 index 0000000000..5c8ae2453b --- /dev/null +++ b/packages/money-market/src/hooks/useWithdrawEstimationTx.ts @@ -0,0 +1,55 @@ +import { ProtocolAction } from "@aave/contract-helpers" +import { useQuery } from "@tanstack/react-query" +import { PopulatedTransaction } from "ethers" + +import { useRootStore } from "@/store/root" +import { ExtendedEvmCall } from "@/types" +import { convertPopulatedTransactionToEvmCall } from "@/utils/convertTx" + +type UseWithdrawEstimationTxParams = { + poolAddress: string + aTokenAddress: string + amount: string + enabled?: boolean +} + +export const useWithdrawEstimationTx = ({ + poolAddress, + aTokenAddress, + amount, + enabled = true, +}: UseWithdrawEstimationTxParams) => { + const [withdraw, estimateGasLimit, account] = useRootStore((store) => [ + store.withdraw, + store.estimateGasLimit, + store.account, + ]) + + return useQuery({ + enabled: enabled && !!account && !!amount && !!poolAddress, + queryKey: ["withdrawEstimationTx", poolAddress, aTokenAddress, amount], + queryFn: async (): Promise => { + const txs = await withdraw({ + reserve: poolAddress, + amount, + aTokenAddress, + }) + + const withdrawTx = txs.find((tx) => tx.txType === "DLP_ACTION") + if (!withdrawTx) { + throw new Error("Withdraw transaction not found") + } + + let withdrawTxData = (await withdrawTx.tx()) as PopulatedTransaction + withdrawTxData = await estimateGasLimit( + withdrawTxData, + ProtocolAction.withdraw, + ) + + return convertPopulatedTransactionToEvmCall( + withdrawTxData, + ProtocolAction.withdraw, + ) + }, + }) +} diff --git a/packages/money-market/src/libs/web3-data-provider/Web3Provider.tsx b/packages/money-market/src/libs/web3-data-provider/Web3Provider.tsx index b456c8e712..d916abf5cf 100644 --- a/packages/money-market/src/libs/web3-data-provider/Web3Provider.tsx +++ b/packages/money-market/src/libs/web3-data-provider/Web3Provider.tsx @@ -1,9 +1,6 @@ import { ProtocolAction } from "@aave/contract-helpers" -import { IAaveIncentivesControllerV2__factory } from "@aave/contract-helpers/src/incentive-controller-v2/typechain/IAaveIncentivesControllerV2__factory" -import { IPool__factory } from "@aave/contract-helpers/src/v3-pool-contract/typechain/IPool__factory" -import { HexString, safeConvertSS58toH160 } from "@galacticcouncil/utils" +import { safeConvertSS58toH160 } from "@galacticcouncil/utils" import { useAccount } from "@galacticcouncil/web3-connect" -import { CallType } from "@galacticcouncil/xc-core" import { useQueryClient } from "@tanstack/react-query" import { PopulatedTransaction } from "ethers" import React, { ReactElement, useCallback, useEffect } from "react" @@ -13,10 +10,7 @@ import { Web3Context } from "@/libs/hooks/useWeb3Context" import { useRootStore } from "@/store/root" import { ExtendedEvmCall, MoneyMarketTxFn, ToastsConfig } from "@/types" import { queryKeysFactory } from "@/ui-config/queries" -import { getFunctionDefsFromAbi } from "@/utils/utils" - -const CLAIM_ALL_METHOD_HASH = "0xbb492bf5" -const CLAIM_METHOD_HASH = "0x236300dc" +import { convertPopulatedTransactionToEvmCall } from "@/utils/convertTx" export type ERC20TokenType = { address: string @@ -44,65 +38,7 @@ export type Web3Data = { sendTxs: SendTxsFn } -const getAbiMethodByProtocolAction = (action: ProtocolAction) => { - switch (action) { - case ProtocolAction.switchBorrowRateMode: - return "swapBorrowRateMode" - case ProtocolAction.setUsageAsCollateral: - return "setUserUseReserveAsCollateral" - case ProtocolAction.setEModeUsage: - return "setUserEMode" - default: - return action - } -} - -const getPoolTransactionAbi = (action?: ProtocolAction) => { - if (!action) { - return "" - } - - return action - ? getFunctionDefsFromAbi( - IPool__factory.abi, - getAbiMethodByProtocolAction(action), - ) - : undefined -} - -const getClaimTransactionAbi = (tx: PopulatedTransaction) => { - const factory = IAaveIncentivesControllerV2__factory - const isClaimAll = tx.data?.startsWith(CLAIM_ALL_METHOD_HASH) - return isClaimAll - ? getFunctionDefsFromAbi(factory.abi, "claimAllRewards") - : getFunctionDefsFromAbi(factory.abi, "claimRewards") -} - -const convertTx = (tx: PopulatedTransaction, action?: ProtocolAction) => { - const isClaimTx = - tx.data?.startsWith(CLAIM_ALL_METHOD_HASH) || - tx.data?.startsWith(CLAIM_METHOD_HASH) - - const abi = isClaimTx - ? getClaimTransactionAbi(tx) - : getPoolTransactionAbi(action) - - const evmCall: ExtendedEvmCall = { - data: tx.data ?? "", - from: tx.from ?? "", - to: tx.to as HexString, - type: CallType.Evm, - abi, - gasLimit: tx.gasLimit ? BigInt(tx.gasLimit.toString()) : 0n, - maxFeePerGas: tx.maxFeePerGas ? BigInt(tx.maxFeePerGas.toString()) : 0n, - maxPriorityFeePerGas: tx.maxPriorityFeePerGas - ? BigInt(tx.maxPriorityFeePerGas.toString()) - : 0n, - dryRun: (() => {}) as ExtendedEvmCall["dryRun"], - } - - return evmCall -} +const convertTx = convertPopulatedTransactionToEvmCall export const Web3ContextProvider: React.FC<{ children: ReactElement diff --git a/packages/money-market/src/types/index.ts b/packages/money-market/src/types/index.ts index 5ae25224d2..e7c09b3f4b 100644 --- a/packages/money-market/src/types/index.ts +++ b/packages/money-market/src/types/index.ts @@ -26,6 +26,21 @@ export type MoneyMarketTxFn = ( withExtraGas?: boolean, ) => void +type MaxBalanceResult = { + maxBalanceHuman: string +} + +type MaxBalanceParams = { + assetId?: string + tx: ExtendedEvmCall | null + feePctBuffer?: number + balance?: string +} + +export type UseMaxBalanceFn = ( + params: MaxBalanceParams, +) => MaxBalanceResult | undefined + export interface ExtendedEvmCall extends EvmCall { nonce?: bigint gasLimit?: bigint diff --git a/packages/money-market/src/ui-config/SharedDependenciesProvider.tsx b/packages/money-market/src/ui-config/SharedDependenciesProvider.tsx index 8d92a999b5..2e55b6e8d7 100644 --- a/packages/money-market/src/ui-config/SharedDependenciesProvider.tsx +++ b/packages/money-market/src/ui-config/SharedDependenciesProvider.tsx @@ -6,6 +6,7 @@ import { ApprovedAmountService } from "@/services/ApprovedAmountService" import { UiIncentivesService } from "@/services/UIIncentivesService" import { UiPoolService } from "@/services/UIPoolService" import { WalletBalanceService } from "@/services/WalletBalanceService" +import { UseMaxBalanceFn } from "@/types" import { getProvider } from "@/utils/provider" interface SharedDependenciesContextProps { @@ -14,6 +15,8 @@ interface SharedDependenciesContextProps { uiIncentivesService: UiIncentivesService uiPoolService: UiPoolService squidClient: SquidSdk + useMaxBalance: UseMaxBalanceFn + getRelatedATokenId: (id: string) => string | undefined } const SharedDependenciesContext = @@ -22,7 +25,9 @@ const SharedDependenciesContext = export const SharedDependenciesProvider: React.FC<{ children?: React.ReactNode squidClient: SquidSdk -}> = ({ children, squidClient }) => { + useMaxBalance: UseMaxBalanceFn + getRelatedATokenId: (id: string) => string | undefined +}> = ({ children, squidClient, useMaxBalance, getRelatedATokenId }) => { const poolTokensBalanceService = new WalletBalanceService(getProvider) const approvedAmountService = new ApprovedAmountService(getProvider) @@ -37,6 +42,8 @@ export const SharedDependenciesProvider: React.FC<{ uiPoolService, uiIncentivesService, squidClient, + useMaxBalance, + getRelatedATokenId, }} > {children} diff --git a/packages/money-market/src/utils/convertTx.ts b/packages/money-market/src/utils/convertTx.ts new file mode 100644 index 0000000000..202cbcebaf --- /dev/null +++ b/packages/money-market/src/utils/convertTx.ts @@ -0,0 +1,75 @@ +import { ProtocolAction } from "@aave/contract-helpers" +import { IAaveIncentivesControllerV2__factory } from "@aave/contract-helpers/src/incentive-controller-v2/typechain/IAaveIncentivesControllerV2__factory" +import { IPool__factory } from "@aave/contract-helpers/src/v3-pool-contract/typechain/IPool__factory" +import { HexString } from "@galacticcouncil/utils" +import { CallType } from "@galacticcouncil/xc-core" +import { PopulatedTransaction } from "ethers" + +import { ExtendedEvmCall } from "@/types" +import { getFunctionDefsFromAbi } from "@/utils/utils" + +const CLAIM_ALL_METHOD_HASH = "0xbb492bf5" +const CLAIM_METHOD_HASH = "0x236300dc" + +const getAbiMethodByProtocolAction = (action: ProtocolAction) => { + switch (action) { + case ProtocolAction.switchBorrowRateMode: + return "swapBorrowRateMode" + case ProtocolAction.setUsageAsCollateral: + return "setUserUseReserveAsCollateral" + case ProtocolAction.setEModeUsage: + return "setUserEMode" + default: + return action + } +} + +const getPoolTransactionAbi = (action?: ProtocolAction) => { + if (!action) { + return "" + } + + return action + ? getFunctionDefsFromAbi( + IPool__factory.abi, + getAbiMethodByProtocolAction(action), + ) + : undefined +} + +const getClaimTransactionAbi = (tx: PopulatedTransaction) => { + const factory = IAaveIncentivesControllerV2__factory + const isClaimAll = tx.data?.startsWith(CLAIM_ALL_METHOD_HASH) + return isClaimAll + ? getFunctionDefsFromAbi(factory.abi, "claimAllRewards") + : getFunctionDefsFromAbi(factory.abi, "claimRewards") +} + +export const convertPopulatedTransactionToEvmCall = ( + tx: PopulatedTransaction, + action?: ProtocolAction, +) => { + const isClaimTx = + tx.data?.startsWith(CLAIM_ALL_METHOD_HASH) || + tx.data?.startsWith(CLAIM_METHOD_HASH) + + const abi = isClaimTx + ? getClaimTransactionAbi(tx) + : getPoolTransactionAbi(action) + + const evmCall: ExtendedEvmCall = { + data: tx.data ?? "", + from: tx.from ?? "", + to: tx.to as HexString, + type: CallType.Evm, + abi, + gasLimit: tx.gasLimit ? BigInt(tx.gasLimit.toString()) : 0n, + maxFeePerGas: tx.maxFeePerGas ? BigInt(tx.maxFeePerGas.toString()) : 0n, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas + ? BigInt(tx.maxPriorityFeePerGas.toString()) + : 0n, + dryRun: (() => {}) as ExtendedEvmCall["dryRun"], + } + + return evmCall +} From c413acd03e780061ca3272cf91615954db2cb083 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Tue, 30 Jun 2026 14:48:16 +0200 Subject: [PATCH 11/18] support GHO as repay asset --- .../transactions/repay/RepayModalContent.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx index 85616695d3..cb9886f1a9 100644 --- a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx +++ b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx @@ -27,7 +27,7 @@ import { useProtocolDataContext } from "@/hooks/useProtocolDataContext" import { useRepayEstimationTx } from "@/hooks/useRepayEstimationTx" import { useRootStore } from "@/store/root" import { useSharedDependencies } from "@/ui-config/SharedDependenciesProvider" -import { formatHealthFactorResult } from "@/utils" +import { formatHealthFactorResult, GHO_ASSET_ID, isGho } from "@/utils" import { getNetworkConfig } from "@/utils/marketsAndNetworksConfig" import { RepayActions } from "./RepayActions" @@ -96,15 +96,16 @@ export const RepayModalContent: React.FC< decimals: poolReserve.decimals, enabled: !repayWithATokens, }) - const repayAssetId = getAssetIdFromAddress( - tokenToRepayWith.address ?? poolReserve.underlyingAsset, - ) - + const repayAssetId = isGho(poolReserve) + ? GHO_ASSET_ID + : getAssetIdFromAddress(tokenToRepayWith.address) + console.log(repayAssetId) const maxBalanceWithFee = useMaxBalance({ tx: repayEstimationTx ?? null, assetId: repayAssetId, feePctBuffer: 0.1, }) + const balanceWithFee = maxBalanceWithFee?.maxBalanceHuman // calculate max amount abailable to repay From f43bc2eeb14e52e9ec1016b8d1463d950dc5315c Mon Sep 17 00:00:00 2001 From: vkulinich Date: Tue, 30 Jun 2026 15:07:58 +0200 Subject: [PATCH 12/18] fix rounding --- .../components/transactions/repay/RepayModalContent.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx index cb9886f1a9..8a864e59fc 100644 --- a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx +++ b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx @@ -99,14 +99,16 @@ export const RepayModalContent: React.FC< const repayAssetId = isGho(poolReserve) ? GHO_ASSET_ID : getAssetIdFromAddress(tokenToRepayWith.address) - console.log(repayAssetId) + const maxBalanceWithFee = useMaxBalance({ tx: repayEstimationTx ?? null, assetId: repayAssetId, feePctBuffer: 0.1, }) - const balanceWithFee = maxBalanceWithFee?.maxBalanceHuman + const balanceWithFee = Big(maxBalanceWithFee?.maxBalanceHuman ?? "0") + .round(poolReserve.decimals, BigNumber.ROUND_UP) + .toString() // calculate max amount abailable to repay let maxAmountToRepay: BigNumber @@ -122,7 +124,7 @@ export const RepayModalContent: React.FC< : "0", ) - const walletBalance = balanceWithFee ?? normalizedWalletBalance.toString() + const walletBalance = balanceWithFee || normalizedWalletBalance.toString() balance = walletBalance maxAmountToRepay = BigNumber.min(walletBalance, debt) From ed23d7a2652565669c163791f0d1d566d08485bb Mon Sep 17 00:00:00 2001 From: vkulinich Date: Thu, 2 Jul 2026 15:37:04 +0200 Subject: [PATCH 13/18] update estimation fee tx --- .../AddMoneyMarketLiquidity.utils.tsx | 51 +++++++++++++++---- .../transactions/repay/RepayModalContent.tsx | 2 +- .../supply/SupplyModalContent.tsx | 3 +- .../withdraw/WithdrawModalContent.tsx | 2 +- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx b/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx index 4c03e4ff0b..4ee554e6fd 100644 --- a/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx +++ b/apps/main/src/modules/liquidity/components/AddMoneyMarketLiquidity/AddMoneyMarketLiquidity.utils.tsx @@ -4,7 +4,7 @@ import Big from "big.js" import { useEffect, useMemo } from "react" import { useFormContext } from "react-hook-form" import { useTranslation } from "react-i18next" -import { first } from "remeda" +import { first, isNullish } from "remeda" import { useDebounce } from "use-debounce" import { @@ -13,6 +13,7 @@ import { healthFactorQuery, } from "@/api/aave" import { omnipoolMiningPositionsKey, omnipoolPositionsKey } from "@/api/account" +import { useAccountFeePaymentAssetId } from "@/api/payments" import { useOmnipoolIds } from "@/api/pools" import { bestSellWithTxQuery } from "@/api/trade" import { @@ -45,14 +46,22 @@ export type TAddMoneyMarketLiquidityWrapperReturn = Omit< > export const useAddMoneyMarketLiquidityWrapper = ({ - stablepoolDetails: { reserves, pool }, + stablepoolDetails: { reserves }, stableswapId, erc20Id, initialOption, split: initialSplit, }: AddMoneyMarketLiquidityWrapperProps) => { const { getAssetWithFallback } = useAssets() - const { papi } = useRpcProvider() + const { account } = useAccount() + const { sdk, isApiLoaded } = useRpcProvider() + const { getTransferableBalance } = useAccountBalances() + const { + swap: { + single: { swapSlippage }, + }, + } = useTradeSettings() + const { data: accountFeePaymentAssetId } = useAccountFeePaymentAssetId() const addableReserves = useAddableStablepoolTokens(stableswapId, reserves) const { data: omnipoolIds } = useOmnipoolIds() const isAddableToOmnipool = omnipoolIds?.includes(erc20Id) @@ -68,16 +77,36 @@ export const useAddMoneyMarketLiquidityWrapper = ({ reserve.asset_id.toString(), ) - const addLiquidityTx = papi.tx.Stableswap.add_assets_liquidity({ - pool_id: pool.id, - assets: addableReserves.map((reserve) => ({ - asset_id: reserve.asset_id, - amount: BigInt(scale("1", reserve.meta.decimals)), - })), - min_shares: 0n, + const { data: feeEstimationSwapTx } = useQuery({ + enabled: + isApiLoaded && !!account?.address && !isNullish(accountFeePaymentAssetId), + queryKey: [ + "addMoneyMarketLiquidityFeeEstimation", + accountFeePaymentAssetId, + ], + queryFn: async () => { + const swap = await sdk.api.router.getBestSell( + accountFeePaymentAssetId ?? 0, + Number(erc20Id), + scaleHuman( + getTransferableBalance(accountFeePaymentAssetId?.toString() ?? ""), + getAssetWithFallback(accountFeePaymentAssetId?.toString() ?? "") + .decimals, + ), + ) + + return sdk.tx + .trade(swap) + .withSlippage(swapSlippage) + .withBeneficiary(account?.address ?? "") + .build() + .then((tx) => tx.get()) + }, }) - const { getMaxBalance } = useFormMaxBalanceWithFee(addLiquidityTx, 5) + const { getMaxBalance } = useFormMaxBalanceWithFee( + feeEstimationSwapTx ?? null, + ) const defaultOption = initialOption || (isAddableToOmnipool ? "omnipool" : "stablepool") diff --git a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx index 8a864e59fc..a69033bd2b 100644 --- a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx +++ b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx @@ -103,7 +103,7 @@ export const RepayModalContent: React.FC< const maxBalanceWithFee = useMaxBalance({ tx: repayEstimationTx ?? null, assetId: repayAssetId, - feePctBuffer: 0.1, + feePctBuffer: 0.5, }) const balanceWithFee = Big(maxBalanceWithFee?.maxBalanceHuman ?? "0") diff --git a/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx b/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx index b75dda9aa6..b38c3d0c68 100644 --- a/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx +++ b/packages/money-market/src/components/transactions/supply/SupplyModalContent.tsx @@ -74,10 +74,11 @@ export const SupplyModalContent = React.memo( amount: walletBalance, decimals: poolReserve.decimals, }) + const maxBalanceWithFee = useMaxBalance({ tx: supplyEstimationTx ?? null, assetId: supplyAssetId, - feePctBuffer: 0.1, + feePctBuffer: 0.5, }) const walletBalanceWithFee = maxBalanceWithFee?.maxBalanceHuman ?? walletBalance diff --git a/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx b/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx index f15aeaaee8..d8b4f1f0b1 100644 --- a/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx +++ b/packages/money-market/src/components/transactions/withdraw/WithdrawModalContent.tsx @@ -61,7 +61,7 @@ export const WithdrawModalContent: React.FC = ({ const maxBalance = useMaxBalance({ tx: withdrawEstimationTx ?? null, assetId: relatedATokenId, - feePctBuffer: 0.1, + feePctBuffer: 0.5, }) const maxAmountToWithdrawWithFee = maxBalance?.maxBalanceHuman ?? maxAmountToWithdraw.toString() From 2afb3116f548282b06882ec8e81d4ada41137745 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Thu, 2 Jul 2026 16:24:43 +0200 Subject: [PATCH 14/18] fix repay estimation tx --- .../src/components/transactions/repay/RepayModalContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx index a69033bd2b..ab27330127 100644 --- a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx +++ b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx @@ -92,7 +92,7 @@ export const RepayModalContent: React.FC< : (tokenToRepayWith.address ?? ""), debtType, repayWithATokens, - amount: safeAmountToRepayAll.toString(), + amount: tokenBalance, decimals: poolReserve.decimals, enabled: !repayWithATokens, }) From 7c32b679c6487436962a9e342c1fe814b73bbae9 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Fri, 3 Jul 2026 14:22:12 +0200 Subject: [PATCH 15/18] add max baalnce for supply isolated asset --- .../SupplyIsolatedLiquidity.tsx | 7 +- .../SupplyIsolatedLiquidity.utils.ts | 121 ++++++++++++++---- 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.tsx b/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.tsx index 435a69cca3..452ad4fe34 100644 --- a/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.tsx +++ b/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.tsx @@ -113,6 +113,7 @@ const SupplyIsolatedLiquidityBody = ({ const { t } = useTranslation(["common", "liquidity", "borrow"]) const { form, + maxBalance, onSubmit, collateralType, healthFactor, @@ -126,6 +127,7 @@ const SupplyIsolatedLiquidityBody = ({ apys, spotPriceData, isPriceLoading, + isTradeLoading, isAaveSupply, } = useSupplyIsolatedLiquidity({ initialAsset, @@ -150,6 +152,7 @@ const SupplyIsolatedLiquidityBody = ({ amountFieldName="amount" sortedAssets={selectabledAssets} assets={[]} + maxBalance={maxBalance} sx={{ pt: 0 }} /> @@ -248,7 +251,9 @@ const SupplyIsolatedLiquidityBody = ({ type="submit" size="large" width="100%" - disabled={isBlockedSupply || !form.formState.isValid} + disabled={ + isBlockedSupply || !form.formState.isValid || isTradeLoading + } > {t("borrow:supply")} diff --git a/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts b/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts index 48c2a10308..111932d441 100644 --- a/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts +++ b/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts @@ -17,7 +17,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" import Big from "big.js" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" -import { first } from "remeda" +import { first, isNullish } from "remeda" import { useDebounce } from "use-debounce" import z from "zod" @@ -30,15 +30,21 @@ import { userBorrowSummaryQueryKey, useSetUsageAsCollateralTx, } from "@/api/borrow" +import { useAccountFeePaymentAssetId } from "@/api/payments" import { spotPriceQuery } from "@/api/spotPrice" import { bestSellWithTxQuery } from "@/api/trade" import i18n from "@/i18n" import { useMinimumTradeAmount } from "@/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.utils" import { useCreateBatchTx } from "@/modules/transactions/hooks/useBatchTx" +import { + GetMaxBalanceWithFee, + useFormMaxBalanceWithFee, +} from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" +import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { useAccountBalances } from "@/states/account" import { useTradeSettings } from "@/states/tradeSettings" -import { scaleHuman, toDecimal } from "@/utils/formatting" +import { scaleHuman } from "@/utils/formatting" import { validateMaxBalance } from "@/utils/validators" export type TSupplyIsolatedLiquidityFormValues = { @@ -63,7 +69,11 @@ export const useSupplyIsolatedLiquidity = ({ }) => { const { t } = useTranslation("common") const rpc = useRpcProvider() + const { sdk, isApiLoaded } = rpc const { account } = useAccount() + const { getAssetWithFallback } = useAssets() + const { getTransferableBalance } = useAccountBalances() + const { data: accountFeePaymentAssetId } = useAccountFeePaymentAssetId() const createBatch = useCreateBatchTx() const { swap: { @@ -71,7 +81,65 @@ export const useSupplyIsolatedLiquidity = ({ }, } = useTradeSettings() const queryClient = useQueryClient() - const form = useSupplyIsolatedLiquidityForm({ asset: initialAsset }) + const { usageAsCollateralEnabledOnUser, reserve, underlyingAsset } = + userReserve + + const { data: feeEstimationSwapTx } = useQuery({ + enabled: + isApiLoaded && !!account?.address && !isNullish(accountFeePaymentAssetId), + queryKey: [ + "supplyIsolatedLiquidityFeeEstimation", + accountFeePaymentAssetId, + aToken.id, + ], + queryFn: async () => { + const swap = await sdk.api.router.getBestSell( + accountFeePaymentAssetId ?? 0, + Number(aToken.id), + scaleHuman( + getTransferableBalance(accountFeePaymentAssetId?.toString() ?? ""), + getAssetWithFallback(accountFeePaymentAssetId?.toString() ?? "") + .decimals, + ), + ) + + const swapTx = await sdk.tx + .trade(swap) + .withSlippage(swapSlippage) + .withBeneficiary(account?.address ?? "") + .build() + .then((tx) => tx.get()) + + const txs = [swapTx] + + if (!usageAsCollateralEnabledOnUser) { + const disableCollateralsTxs = await getDisableCollateralsTxs() + + txs.push(...(disableCollateralsTxs ?? [])) + + const enableCollateralTx = await getSetUsageAsCollateralTx( + underlyingAsset, + true, + ) + txs.push(enableCollateralTx) + } + + return txs.length > 1 + ? rpc.papi.tx.Utility.batch_all({ + calls: txs.map((tx) => tx.decodedCall), + }) + : swapTx + }, + }) + + const { getMaxBalance } = useFormMaxBalanceWithFee( + feeEstimationSwapTx ?? null, + ) + + const form = useSupplyIsolatedLiquidityForm({ + asset: initialAsset, + getMaxBalance, + }) const getMinimumTradeAmount = useMinimumTradeAmount() const getDisableCollateralsTxs = useBorrowDisableCollateralTxs() const getSetUsageAsCollateralTx = useSetUsageAsCollateralTx() @@ -82,12 +150,12 @@ export const useSupplyIsolatedLiquidity = ({ apy: apyData.supplyApy, apyType: apyData.apyType, })) ?? [] - const { usageAsCollateralEnabledOnUser, reserve, underlyingAsset } = - userReserve + const assetCap = getAssetCapData(reserve) const [amountIn, assetIn] = form.watch(["amount", "asset"]) + const maxBalance = getMaxBalance(assetIn) const isAaveSupply = aToken.underlyingAssetId === assetIn.id const { data: spotPriceData, isLoading: isPriceLoading } = useQuery({ @@ -137,7 +205,7 @@ export const useSupplyIsolatedLiquidity = ({ const isBlockedSupply = isBlockedByBorrowedAssets || !!supplyCapWarning || !!debtCeilingWarning - const { data: trade } = useQuery( + const { data: trade, isLoading: isTradeLoading } = useQuery( bestSellWithTxQuery(rpc, { assetIn: assetIn.id, assetOut: aToken.id, @@ -206,6 +274,7 @@ export const useSupplyIsolatedLiquidity = ({ return { form, + maxBalance, onSubmit, collateralType, healthFactor, @@ -220,12 +289,17 @@ export const useSupplyIsolatedLiquidity = ({ spotPriceData, isPriceLoading, isAaveSupply, + isTradeLoading, } } -const useSupplyIsolatedLiquidityForm = ({ asset }: { asset: TAssetData }) => { - const { getTransferableBalance } = useAccountBalances() - +const useSupplyIsolatedLiquidityForm = ({ + asset, + getMaxBalance, +}: { + asset: TAssetData + getMaxBalance: GetMaxBalanceWithFee +}) => { return useForm({ mode: "onChange", defaultValues: { @@ -233,21 +307,22 @@ const useSupplyIsolatedLiquidityForm = ({ asset }: { asset: TAssetData }) => { asset, }, resolver: standardSchemaResolver( - z.object({ amount: z.string(), asset: z.custom() }).refine( - (values) => { - return validateMaxBalance( - toDecimal( - getTransferableBalance(values.asset.id), - values.asset.decimals, + z + .object({ + amount: z.string(), + asset: z.custom(), + }) + .refine( + (formValues) => + validateMaxBalance( + getMaxBalance(formValues.asset), + formValues.amount, ), - values.amount, - ) - }, - { - message: i18n.t("error.maxBalance"), - path: ["amount"], - }, - ), + { + message: i18n.t("error.maxBalance"), + path: ["amount"], + }, + ), ), }) } From 32912e7add4088b51cf8f8da9daf588554b50341 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Fri, 3 Jul 2026 17:04:27 +0200 Subject: [PATCH 16/18] fix inner evm call fee estimation --- .../SupplyIsolatedLiquidity.utils.ts | 24 +++------ .../transactions/hooks/useEstimateFee.ts | 9 ++-- .../review/ReviewTransactionSubmitButton.tsx | 4 +- .../modules/transactions/utils/permitFee.ts | 20 ++++++-- .../main/src/modules/transactions/utils/tx.ts | 49 ++++++++++++++++++- 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts b/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts index 111932d441..3db76d433a 100644 --- a/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts +++ b/apps/main/src/modules/liquidity/components/SupplyIsolatedLiquidity/SupplyIsolatedLiquidity.utils.ts @@ -33,19 +33,17 @@ import { import { useAccountFeePaymentAssetId } from "@/api/payments" import { spotPriceQuery } from "@/api/spotPrice" import { bestSellWithTxQuery } from "@/api/trade" -import i18n from "@/i18n" import { useMinimumTradeAmount } from "@/modules/liquidity/components/RemoveLiquidity/RemoveMoneyMarketLiquidity.utils" import { useCreateBatchTx } from "@/modules/transactions/hooks/useBatchTx" import { - GetMaxBalanceWithFee, useFormMaxBalanceWithFee, + ValidateFormMaxBalanceWithFee, } from "@/modules/transactions/hooks/useFormMaxBalanceWithFee" import { useAssets } from "@/providers/assetsProvider" import { useRpcProvider } from "@/providers/rpcProvider" import { useAccountBalances } from "@/states/account" import { useTradeSettings } from "@/states/tradeSettings" import { scaleHuman } from "@/utils/formatting" -import { validateMaxBalance } from "@/utils/validators" export type TSupplyIsolatedLiquidityFormValues = { amount: string @@ -132,13 +130,13 @@ export const useSupplyIsolatedLiquidity = ({ }, }) - const { getMaxBalance } = useFormMaxBalanceWithFee( + const { getMaxBalance, validateBalance } = useFormMaxBalanceWithFee( feeEstimationSwapTx ?? null, ) const form = useSupplyIsolatedLiquidityForm({ asset: initialAsset, - getMaxBalance, + validateBalance, }) const getMinimumTradeAmount = useMinimumTradeAmount() const getDisableCollateralsTxs = useBorrowDisableCollateralTxs() @@ -295,10 +293,10 @@ export const useSupplyIsolatedLiquidity = ({ const useSupplyIsolatedLiquidityForm = ({ asset, - getMaxBalance, + validateBalance, }: { asset: TAssetData - getMaxBalance: GetMaxBalanceWithFee + validateBalance: ValidateFormMaxBalanceWithFee }) => { return useForm({ mode: "onChange", @@ -312,17 +310,7 @@ const useSupplyIsolatedLiquidityForm = ({ amount: z.string(), asset: z.custom(), }) - .refine( - (formValues) => - validateMaxBalance( - getMaxBalance(formValues.asset), - formValues.amount, - ), - { - message: i18n.t("error.maxBalance"), - path: ["amount"], - }, - ), + .check(validateBalance("amount", (form) => [form.asset, form.amount])), ), }) } diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 0494165c1c..7f7a27e9f7 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -21,6 +21,7 @@ import { } from "@/modules/transactions/utils/permitFee" import { isPapiTransaction } from "@/modules/transactions/utils/polkadot" import { + containsEvmCall, getExtraTxFeeByWeight, transformAnyToPapiTx, } from "@/modules/transactions/utils/tx" @@ -109,7 +110,7 @@ export const useEstimateFee = ( } if (!tx) throw new Error("Invalid transaction") - const isEvmTx = isEvmCall(anyTx) + const hasEvmCall = isEvmCall(anyTx) || containsEvmCall(anyTx) const getSpotPriceFn = getSpotPrice( sdk.api.router, @@ -120,12 +121,12 @@ export const useEstimateFee = ( const [fees, spot, extraFee] = await Promise.all([ tx.getEstimatedFees(address), getSpotPriceFn(), - isEvmTx ? getExtraTxFeeByWeight(rpc, address, tx, native.id) : null, + hasEvmCall ? getExtraTxFeeByWeight(rpc, address, tx, native.id) : null, ]) const feeEstimateNativeBase = scaleHuman(fees, native.decimals) const feeEstimateNative = isValidBigSource(extraFee) - ? Big(extraFee).add(feeEstimateNativeBase).toString() + ? Big(extraFee).add(feeEstimateNativeBase).toFixed(native.decimals) : feeEstimateNativeBase if (spot?.spotPrice) { @@ -157,7 +158,7 @@ export const useEstimateFee = ( const feeEstimate = Big(1) .div(assetPaymentValueAdjusted) .mul(feeEstimateNative) - .toString() + .toFixed(feeAsset.decimals) return { feeEstimateNative, diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx b/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx index 5aff320d7a..6190dca266 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx @@ -30,7 +30,7 @@ export const ReviewTransactionSubmitButton = ({ meta, isSigning, signAndSubmit, - isLoadingFeeEstimate, + isLoading: isTransactionLoading, isChangingFeePaymentAsset, setFeePaymentModalOpen, } = useTransaction() @@ -74,7 +74,7 @@ export const ReviewTransactionSubmitButton = ({ } const isLoading = - isSigning || isLoadingFeeEstimate || isChangingFeePaymentAsset + isSigning || isTransactionLoading || isChangingFeePaymentAsset const isDisabled = disabled || isSigningBlocked || hasAlerts || isLoading return ( diff --git a/apps/main/src/modules/transactions/utils/permitFee.ts b/apps/main/src/modules/transactions/utils/permitFee.ts index b9dabb22ab..6960b81064 100644 --- a/apps/main/src/modules/transactions/utils/permitFee.ts +++ b/apps/main/src/modules/transactions/utils/permitFee.ts @@ -12,7 +12,11 @@ import { EstimateGasParameters, formatEther, Hex } from "viem" import { getSpotPrice, spotPriceQuery } from "@/api/spotPrice" import { paymentInfoQuery } from "@/api/transaction" import { AnyTransaction } from "@/modules/transactions/types" -import { transformAnyToPapiTx } from "@/modules/transactions/utils/tx" +import { + containsEvmCall, + getNestedEvmCallsFeeWei, + transformAnyToPapiTx, +} from "@/modules/transactions/utils/tx" import { isEvmCall } from "@/modules/transactions/utils/xcm" import { TAsset } from "@/providers/assetsProvider" import { TProviderContext } from "@/providers/rpcProvider" @@ -42,7 +46,12 @@ const getEvmGasFeeWei = async ( // Wallets validate balance against gasLimit × gasPrice before signing. // On-chain, only gas_used × gas_price is charged. - return gasLimit * gasPrice + const dispatchFeeWei = gasLimit * gasPrice + const nestedEvmFeeWei = containsEvmCall(anyTx) + ? getNestedEvmCallsFeeWei(anyTx) + : 0n + + return dispatchFeeWei + nestedEvmFeeWei } const getPermitWeight = async ( @@ -130,7 +139,7 @@ const convertNativeFeeToFeeAsset = async ( const spot = await getSpotPrice(rpc.sdk.api.router, native.id, feeAsset.id)() if (spot?.spotPrice) { - return Big(feeEstimateNative).mul(spot.spotPrice).toString() + return Big(feeEstimateNative).mul(spot.spotPrice).toFixed(feeAsset.decimals) } const assetPaymentValue = @@ -147,7 +156,10 @@ const convertNativeFeeToFeeAsset = async ( feeAsset.decimals, ) - return Big(1).div(assetPaymentValueAdjusted).mul(feeEstimateNative).toString() + return Big(1) + .div(assetPaymentValueAdjusted) + .mul(feeEstimateNative) + .toFixed(feeAsset.decimals) } export const isPermitFeeEstimation = (feeAssetId: string) => diff --git a/apps/main/src/modules/transactions/utils/tx.ts b/apps/main/src/modules/transactions/utils/tx.ts index 0f4a54ddd5..b6135d9a1b 100644 --- a/apps/main/src/modules/transactions/utils/tx.ts +++ b/apps/main/src/modules/transactions/utils/tx.ts @@ -2,7 +2,7 @@ import { ExtendedEvmCall } from "@galacticcouncil/money-market/types" import { HYDRATION_CHAIN_KEY } from "@galacticcouncil/utils" import { PermitResult } from "@galacticcouncil/web3-connect/src/signers/EthereumSigner" import { Binary, SizedHex } from "polkadot-api" -import { first, isObjectType } from "remeda" +import { first, isBigInt, isObjectType } from "remeda" import { weightToEvmFeeQuery } from "@/api/evm" import { paymentInfoQuery } from "@/api/transaction" @@ -10,6 +10,7 @@ import { decodeTx } from "@/modules/transactions/review/ReviewTransactionJsonVie import { AnyPapiTx, AnyTransaction, + DecodedCallEnum, TxOptions, } from "@/modules/transactions/types" import { @@ -88,6 +89,52 @@ export function containsEvmCall(tx: AnyTransaction): boolean { return false } +const getEvmCallFeeFromDecoded = (decoded: DecodedCallEnum): bigint => { + if (!isDecodedCallEnum(decoded) || decoded.type !== "EVM") return 0n + + const evmVariant = decoded.value + if ( + !isObjectType(evmVariant) || + !("type" in evmVariant) || + evmVariant.type !== "call" || + !("value" in evmVariant) || + !isObjectType(evmVariant.value) + ) { + return 0n + } + + const callValue = evmVariant.value as Record + const gasLimit = callValue.gas_limit + const maxFeePerGas = callValue.max_fee_per_gas + + if (!isBigInt(gasLimit) || !Array.isArray(maxFeePerGas)) return 0n + + return gasLimit * BigInt(maxFeePerGas[0]) +} + +const collectNestedEvmCallFees = (decoded: DecodedCallEnum): bigint => { + if (!isDecodedCallEnum(decoded)) return 0n + + if (decoded.type === "EVM") { + return getEvmCallFeeFromDecoded(decoded) + } + + if (decoded.type === "Utility" && isBatchDecodedCallValue(decoded.value)) { + return decoded.value.value.calls.reduce( + (sum, call) => sum + collectNestedEvmCallFees(call), + 0n, + ) + } + + return 0n +} + +export const getNestedEvmCallsFeeWei = (tx: AnyTransaction): bigint => { + if (!isPapiTransaction(tx)) return 0n + + return collectNestedEvmCallFees(tx.decodedCall) +} + export function prependEvmBindingTx(papi: Papi, tx: AnyPapiTx): AnyPapiTx { const bindTx = papi.tx.EVMAccounts.bind_evm_address() const decoded = tx.decodedCall From 98620f889f53dcff28976bf20f0f2a1e7f67a9d6 Mon Sep 17 00:00:00 2001 From: vkulinich Date: Wed, 22 Jul 2026 14:05:04 +0200 Subject: [PATCH 17/18] Address PR comments --- .../src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts | 2 +- .../trade/swap/sections/Market/lib/useMaxSellAmount.ts | 2 +- .../src/components/transactions/repay/RepayModalContent.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts b/apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts index 9e322fd21d..5d04049e8a 100644 --- a/apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts +++ b/apps/main/src/modules/trade/swap/sections/DCA/useMaxOrderBalance.ts @@ -33,7 +33,7 @@ export const useMaxOrderBalance = ({ const { data: tx } = useQuery({ enabled, - queryKey: ["maxOrderAmount", assetIn, assetOut], + queryKey: ["maxOrderAmount", assetIn, assetOut, slippage], queryFn: async () => { const minAmount = await rpc.queryClient.ensureQueryData( minimumOrderBudgetQuery(rpc, meta.id, meta.decimals), diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts index 2dd7827ed1..6b5ac1f8a1 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useMaxSellAmount.ts @@ -32,7 +32,7 @@ export const useMaxSellAmount = ({ const { data: tx } = useQuery({ enabled, - queryKey: ["maxSellAmount", assetIn, assetOut], + queryKey: ["maxSellAmount", assetIn, assetOut, swapSlippage], queryFn: async () => { const swap = await rpc.sdk.api.router.getBestSell( Number(assetIn), diff --git a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx index ab27330127..eef424008f 100644 --- a/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx +++ b/packages/money-market/src/components/transactions/repay/RepayModalContent.tsx @@ -107,10 +107,10 @@ export const RepayModalContent: React.FC< }) const balanceWithFee = Big(maxBalanceWithFee?.maxBalanceHuman ?? "0") - .round(poolReserve.decimals, BigNumber.ROUND_UP) + .round(poolReserve.decimals, BigNumber.ROUND_DOWN) .toString() - // calculate max amount abailable to repay + // calculate max amount available to repay let maxAmountToRepay: BigNumber let balance: string if (repayWithATokens) { From d33edafdea64de1e90650b43209a081a05618efa Mon Sep 17 00:00:00 2001 From: vkulinich Date: Fri, 24 Jul 2026 15:02:44 +0200 Subject: [PATCH 18/18] Adjust fee payment asset balance --- apps/main/src/api/payments.ts | 8 +++++++- .../transactions/hooks/useEstimateFee.ts | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/main/src/api/payments.ts b/apps/main/src/api/payments.ts index 0ad49ce853..85cabfab95 100644 --- a/apps/main/src/api/payments.ts +++ b/apps/main/src/api/payments.ts @@ -126,7 +126,13 @@ export const useAccountFeePaymentAssets = () => { ) return [] - const assetIds = Object.keys(balances) + const assetIds: string[] = [] + + Object.entries(balances).forEach(([assetId, balance]) => { + if (balance.free - balance.frozen > 0) { + assetIds.push(assetId) + } + }) return unique([...assetIds, accountFeePaymentAssetId.toString()]) }, [ accountFeePaymentAssetId, diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 7f7a27e9f7..e219259fde 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -48,7 +48,7 @@ export const useEstimateFee = ( enabled: !feePaymentAssetIdOverride, }) - const { isBalanceLoading, getTransferableBalance } = useAccountBalances() + const { isBalanceLoading, getBalance } = useAccountBalances() const address = account?.address ?? "" @@ -60,9 +60,19 @@ export const useEstimateFee = ( const isEthereumWallet = isEthereumSigner(wallet?.signer) - const feeAssetBalance = feeAsset - ? scaleHuman(getTransferableBalance(feeAsset.id), feeAsset.decimals) - : "0" + const assetBalance = feeAsset ? getBalance(feeAsset.id) : null + const feeAssetBalance = + assetBalance && feeAsset + ? scaleHuman( + Big.max( + Big(assetBalance.free.toString()).minus( + assetBalance.frozen.toString(), + ), + 0, + ).toString(), + feeAsset.decimals, + ) + : "0" return useQuery({ placeholderData: keepPreviousData,