diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..6c40ea4110 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: Lint +on: + - push +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + cache: yarn + node-version: '20.x' + - run: yarn install --frozen-lockfile + - run: yarn run build + - run: yarn run lint diff --git a/apps/main/.env.development b/apps/main/.env.development index edf060c06d..d140119f27 100644 --- a/apps/main/.env.development +++ b/apps/main/.env.development @@ -1,6 +1,6 @@ VITE_PROVIDER_URL=wss://rpc.nice.hydration.cloud VITE_INDEXER_URL=https://archive.nice.hydration.cloud/graphql -VITE_SQUID_URL=https://galacticcouncil.squids.live/hydration-pools:orca-prod/api/graphql +VITE_SQUID_URL=https://orca-main-aggr-indx.indexer.hydration.cloud/graphql VITE_SNOWBRIDGE_URL="https://snowbridge.squids.live/snowbridge-subsquid-polkadot@v1/api/graphql" VITE_GRAFANA_URL=https://grafana-api.play.hydration.cloud/api/ds/query VITE_GRAFANA_DSN=11 diff --git a/apps/main/.env.production b/apps/main/.env.production index 4d71b435a3..30df85e236 100644 --- a/apps/main/.env.production +++ b/apps/main/.env.production @@ -1,6 +1,6 @@ VITE_PROVIDER_URL=wss://hydration-rpc.n.dwellir.com VITE_INDEXER_URL=https://explorer.hydradx.cloud/graphql -VITE_SQUID_URL=https://galacticcouncil.squids.live/hydration-pools:orca-prod/api/graphql +VITE_SQUID_URL=https://orca-main-aggr-indx.indexer.hydration.cloud/graphql VITE_SNOWBRIDGE_URL="https://snowbridge.squids.live/snowbridge-subsquid-polkadot@v1/api/graphql" VITE_GRAFANA_URL=https://grafana.hydradx.cloud/api/ds/query VITE_GRAFANA_DSN=10 diff --git a/apps/main/src/api/external/assethub.ts b/apps/main/src/api/external/assethub.ts new file mode 100644 index 0000000000..b108b0bcd8 --- /dev/null +++ b/apps/main/src/api/external/assethub.ts @@ -0,0 +1,61 @@ +import { XcmV3Junction, XcmV3Junctions } from "@galacticcouncil/descriptors" +import { XcmV3Multilocation } from "@galacticcouncil/sdk-next" +import { invariant } from "@galacticcouncil/utils" +import { chainsMap } from "@galacticcouncil/xc-cfg" +import { + Asset, + AssetAmount, + multiloc, + Parachain, +} from "@galacticcouncil/xc-core" + +import { AnyPapiTx } from "@/modules/transactions/types" + +export const assethub = chainsMap.get("assethub") as Parachain + +export async function calculateAssethubFee( + tx: AnyPapiTx, + address: string, + asset: Asset, +): Promise { + const assethub = chainsMap.get("assethub") + invariant(assethub, "Assethub chain not found") + + const native = assethub.assetsData.get("dot") + invariant(native, "Assethub native asset not found") + + const paymentInfo = await tx.getPaymentInfo(address) + + const feeAmount = (paymentInfo.partial_fee * 30n) / 10n // add 30% margin + + if (native.asset.isEqual(asset)) { + return feeAmount + } + + const feeQuote = await assethub.dex.getQuote( + asset, + native.asset, + AssetAmount.fromAsset(native.asset, { + amount: feeAmount, + decimals: native.decimals!, + }), + ) + return feeQuote.amount +} + +export const getAssetHubFeeAssetLocaction = (asset: Asset) => { + const location = assethub.getAssetXcmLocation(asset) + + if (location && location.interior !== XcmV3Junctions.Here().type) { + const pallet = multiloc.findPalletInstance(location) + const index = multiloc.findGeneralIndex(location) + + return { + parents: 0, + interior: XcmV3Junctions.X2([ + XcmV3Junction.PalletInstance(Number(pallet)), + XcmV3Junction.GeneralIndex(BigInt(index)), + ]), + } as XcmV3Multilocation + } +} diff --git a/apps/main/src/api/external/common.ts b/apps/main/src/api/external/common.ts new file mode 100644 index 0000000000..3044890776 --- /dev/null +++ b/apps/main/src/api/external/common.ts @@ -0,0 +1,12 @@ +import { Asset } from "@galacticcouncil/xc-core" + +import { getAssetHubFeeAssetLocaction } from "@/api/external/assethub" + +export function getParachainFeeAssetLocation(chainKey: string, asset: Asset) { + switch (chainKey) { + case "assethub": + return getAssetHubFeeAssetLocaction(asset) + default: + return null + } +} diff --git a/apps/main/src/api/external/defillama.ts b/apps/main/src/api/external/defillama.ts index 1b3681d21f..11a28d425b 100644 --- a/apps/main/src/api/external/defillama.ts +++ b/apps/main/src/api/external/defillama.ts @@ -33,7 +33,7 @@ export const ASSET_ID_TO_DEFILLAMA_ID: Record = { } const DEFILLAMA_APY_ENDPOINT = - "https://galacticcouncil.squids.live/hydration-pools:orca-prod/api/proxy/defillama/yields/chart" + "https://orca-main-aggr-indx.indexer.hydration.cloud/proxy/defillama/yields/chart" const fetchDefillamaLatestApy = async (id: string): Promise => { const res = await fetch(`${DEFILLAMA_APY_ENDPOINT}/${id}`) diff --git a/apps/main/src/api/external/kamino.ts b/apps/main/src/api/external/kamino.ts index 6b37a79c9c..b2a87654ce 100644 --- a/apps/main/src/api/external/kamino.ts +++ b/apps/main/src/api/external/kamino.ts @@ -13,7 +13,7 @@ import { z } from "zod" import { GC_TIME, STALE_TIME } from "@/utils/consts" const getKaminoEndpoint = (yieldSource: string) => - `https://galacticcouncil.squids.live/hydration-pools:orca-prod/api/proxy/kamino/yields/${yieldSource}/history` + `https://orca-main-aggr-indx.indexer.hydration.cloud/proxy/kamino/yields/${yieldSource}/history` export const ASSET_ID_TO_KAMINO_ID: Record = { [PRIME_ASSET_ID]: "3b8X44fLF9ooXaUm3hhSgjpmVs6rZZ3pPoGnGahc3Uu7", diff --git a/apps/main/src/api/external/pendulum.ts b/apps/main/src/api/external/pendulum.ts new file mode 100644 index 0000000000..6f6c289c82 --- /dev/null +++ b/apps/main/src/api/external/pendulum.ts @@ -0,0 +1,4 @@ +import { chainsMap } from "@galacticcouncil/xc-cfg" +import { Parachain } from "@galacticcouncil/xc-core/build/types/chain" + +export const pendulum = chainsMap.get("pendulum") as Parachain diff --git a/apps/main/src/api/omnipool.ts b/apps/main/src/api/omnipool.ts index 3c60be793a..bba5d515d5 100644 --- a/apps/main/src/api/omnipool.ts +++ b/apps/main/src/api/omnipool.ts @@ -1,3 +1,4 @@ +import { platformTotalQuery } from "@galacticcouncil/indexer/squid" import { fixed_from_rational } from "@galacticcouncil/math-liquidity-mining" import { useQuery, useQueryClient } from "@tanstack/react-query" import Big from "big.js" @@ -5,6 +6,7 @@ import { millisecondsInHour } from "date-fns/constants" import { Binary, Enum } from "polkadot-api" import { useMemo } from "react" +import { useSquidClient } from "@/api/provider" import { useRpcProvider } from "@/providers/rpcProvider" import { hubTokenQuery, omnipoolTokensQuery } from "./pools" @@ -158,3 +160,12 @@ export const useOraclePrice = ( : () => null, }) } + +export const useTotalOmnipoolLiquidity = () => { + const squidClient = useSquidClient() + + return useQuery({ + ...platformTotalQuery(squidClient), + select: (data) => data.omnipoolTvlNorm, + }) +} diff --git a/apps/main/src/api/provider.ts b/apps/main/src/api/provider.ts index f8c43d1f13..478958e0fd 100644 --- a/apps/main/src/api/provider.ts +++ b/apps/main/src/api/provider.ts @@ -118,6 +118,7 @@ const getProviderData = async ( ), metadata.fetchAssets(), metadata.fetchChains(), + metadata.fetchMetadata(), ], ) diff --git a/apps/main/src/components/TransactionItem/TransactionItemMobile.tsx b/apps/main/src/components/TransactionItem/TransactionItemMobile.tsx index 1b8c8cf607..33f1a0bce8 100644 --- a/apps/main/src/components/TransactionItem/TransactionItemMobile.tsx +++ b/apps/main/src/components/TransactionItem/TransactionItemMobile.tsx @@ -8,6 +8,7 @@ import { Text, } from "@galacticcouncil/ui/components" import { getToken } from "@galacticcouncil/ui/utils" +import { replaceAaveWithBorrow } from "@galacticcouncil/utils" import { FC, ReactNode } from "react" import { useTranslation } from "react-i18next" @@ -50,7 +51,6 @@ export const TransactionItemMobile: FC = ({ statusProps.status === TransactionStatusVariant.Success ? [statusProps.sent, statusProps.received] : [null, null] - return ( @@ -75,7 +75,7 @@ export const TransactionItemMobile: FC = ({ variant={statusProps.status} sx={{ maxWidth: "200px", textAlign: "end" }} > - {message} + {replaceAaveWithBorrow(message)} )} @@ -84,7 +84,11 @@ export const TransactionItemMobile: FC = ({ {link && ( - + )} diff --git a/apps/main/src/config/navigation.ts b/apps/main/src/config/navigation.ts index 68f3cc2bdd..2fef40de97 100644 --- a/apps/main/src/config/navigation.ts +++ b/apps/main/src/config/navigation.ts @@ -50,6 +50,8 @@ export const LINKS = { statsHollar: "/stats/hollar", statsFees: "/stats/fees", statsAmm: "/stats/amm", + deposit: "/deposit", + withdraw: "/withdraw", // memepad: "/memepad", submitTransaction: "/submit-transaction", } satisfies Record @@ -300,6 +302,14 @@ export const getMenuTranslations = (t: TFunction) => title: t("navigation.statsFees.title"), description: "", }, + deposit: { + title: t("navigation.deposit.title"), + description: t("navigation.deposit.description"), + }, + withdraw: { + title: t("navigation.withdraw.title"), + description: t("navigation.withdraw.description"), + }, // memepad: { // title: t("navigation.memepad.title"), // description: "", diff --git a/apps/main/src/config/rpc.ts b/apps/main/src/config/rpc.ts index 609ac6752e..05dabb070b 100644 --- a/apps/main/src/config/rpc.ts +++ b/apps/main/src/config/rpc.ts @@ -17,7 +17,7 @@ export type IndexerProps = { const MAINNET_INDEXER_URL = "https://explorer.hydradx.cloud/graphql" const MAINNET_SQUID_URL = - "https://galacticcouncil.squids.live/hydration-pools:unified-prod/api/graphql" + "https://unified-main-aggr-indx.indexer.hydration.cloud/graphql" export const createProvider = ( name: string, @@ -38,7 +38,7 @@ export const createProvider = ( export const SQUID_URLS_CONFIG = [ { name: "Orca Prod", - url: "https://galacticcouncil.squids.live/hydration-pools:orca-prod/api", + url: "https://orca-main-aggr-indx.indexer.hydration.cloud", }, { name: "Orca Prod 01 indx", @@ -92,7 +92,7 @@ export const PROVIDERS: ProviderProps[] = [ "Testnet", "wss://rpc.nice.hydration.cloud", "https://archive.nice.hydration.cloud/graphql", - "https://galacticcouncil.squids.live/hydration-pools:unified-prod/api/graphql", + "https://unified-main-aggr-indx.indexer.hydration.cloud/graphql", ["development"], "testnet", ), diff --git a/apps/main/src/i18n/index.ts b/apps/main/src/i18n/index.ts index 93d5800c33..19d3b5338b 100644 --- a/apps/main/src/i18n/index.ts +++ b/apps/main/src/i18n/index.ts @@ -5,6 +5,7 @@ import { initReactI18next } from "react-i18next" import borrow from "@/i18n/locales/en/borrow.json" import common from "@/i18n/locales/en/common.json" import liquidity from "@/i18n/locales/en/liquidity.json" +import onramp from "@/i18n/locales/en/onramp.json" import staking from "@/i18n/locales/en/staking.json" import stats from "@/i18n/locales/en/stats.json" import trade from "@/i18n/locales/en/trade.json" @@ -13,7 +14,7 @@ import xcm from "@/i18n/locales/en/xcm.json" export const defaultNS = "common" export const resources = { - en: { common, liquidity, trade, wallet, borrow, staking, xcm, stats }, + en: { common, liquidity, trade, wallet, borrow, staking, xcm, stats, onramp }, } as const const i18n = i18next.createInstance() diff --git a/apps/main/src/i18n/locales/en/common.json b/apps/main/src/i18n/locales/en/common.json index 0d83dd6007..324132d716 100644 --- a/apps/main/src/i18n/locales/en/common.json +++ b/apps/main/src/i18n/locales/en/common.json @@ -119,6 +119,8 @@ "currentValue": "Current value", "withdraw": "Withdraw", "deposit": "Deposit", + "deposit.pending_one": "{{ count }} deposit pending", + "deposit.pending_other": "{{ count }} deposits pending", "transfer": "Transfer", "balances": "Balances", "amount": "Amount", @@ -210,6 +212,10 @@ "navigation.statsHollar.title": "Hollar", "navigation.statsAmm.title": "AMM", "navigation.statsFees.title": "Fees", + "navigation.deposit.title": "Deposit", + "navigation.deposit.description": "Deposit your asssets to Hydration", + "navigation.withdraw.title": "Withdraw", + "navigation.withdraw.description": "Withdraw your asssets from Hydration", "remove": "Remove", "rpc.change.modal.autoMode.desc": "Enabling this will automatically switch RPCs and Indexers when one of the active ones is unavailable.", "rpc.change.modal.autoMode.title": "Auto-connect to working RPC and Indexer", @@ -314,6 +320,8 @@ "transaction.jsonview.copy.calldata": "Copy call data", "transaction.alert.insufficientFeeBalance": "Not enough fee payment asset balance.", "transaction.alert.pendingDispatchPermit": "Permit transaction is pending. Please wait.", + "transaction.alert.sellAll": "You are selling your entire balance of {{ value, currency }}.", + "transaction.alert.acceptRisk": "I accept the risk involved.", "transaction.batch.warning": "Your transaction would exhaust block limit, we need to split it into multiple transactions", "transaction.batch.step.label": "Transaction {{ index }}", "transaction.error.unsupportedTransaction": "Unsupported transaction or signer type", diff --git a/apps/main/src/i18n/locales/en/onramp.json b/apps/main/src/i18n/locales/en/onramp.json new file mode 100644 index 0000000000..4c6b3921dd --- /dev/null +++ b/apps/main/src/i18n/locales/en/onramp.json @@ -0,0 +1,113 @@ +{ + "selectAsset": "Select asset", + "deposit.bank.banxa.cta": "Go to Banxa", + "deposit.bank.banxa.description": "Deposit DOT (via Polkadot), USDC or USDT (via Polkadot AssetHub).", + "deposit.bank.title": "Bank / Credit Card", + "deposit.cex.select.title": "Deposit from CEX", + "deposit.cex.exchange.title": "Exchange:", + "deposit.cex.asset.title": "Deposit to Hydration", + "deposit.cex.asset.alert": "It is recommended that you keep this tab open until the deposit is complete.", + "deposit.cex.asset.select.label": "From <0> <1>{{ name }}", + "deposit.cex.account.evmError": "EVM account not allowed for depositing {{ symbol }}", + "deposit.cex.account.depositTo": "Deposit to", + "deposit.cex.amount.min.title": "Minimal deposit amount", + "deposit.cex.awaiting.title": "Awaiting deposit", + "deposit.cex.transfer.title": "Deposit to Hydration", + "deposit.cex.transfer.button": "Confirm deposit", + "deposit.cex.transfer.destination": "Destination account", + "deposit.cex.transfer.ongoing.status": "Ongoing", + "deposit.cex.transfer.ongoing.title": "Ongoing deposits", + "deposit.cex.transfer.finish": "Finish", + "deposit.method.title": "Deposit from", + "deposit.method.cex.title": "Centralized exchange", + "deposit.method.cex.description": "Deposit from Binance, Kraken, Coinbase, and others.", + "deposit.method.onchain.title": "Another chain", + "deposit.method.onchain.description": "Deposit from Polkadot, Ethereum, Solana or SUI.", + "deposit.method.bank.title": "Bank / Credit card", + "deposit.method.bank.description": "Deposit funds directly from your bank account or using a credit card.", + "deposit.success.title": "Deposit successful!", + "deposit.success.description": "Congrats, you’ve succesfully deposit your <0>{{ value, bignumber(type: 'token') }} {{symbol}} to Hydration. Wondering what you can do now?", + "deposit.success.cta.staking.title": "Stake HDX", + "deposit.success.cta.staking.description": "Stake Your HDX and earn HDX rewards", + "deposit.success.cta.borrow.title": "Supply & borow", + "deposit.success.cta.borrow.description": "Supply your asset as collateral, earn APR and borrow against it", + "deposit.success.cta.liquidity.title": "Liquidity mining", + "deposit.success.cta.liquidity.description": "Provide liquidity and earn HDX rewards", + "deposit.success.cta.trade.title": "Trade, DCA & have fun", + "deposit.success.cta.trade.description": "DCA on Hydration to buy other assets", + "deposit.success.cta.wallet.title": "Wallet", + "deposit.success.cta.wallet.description": "Check your wallet balance, transfer assets and more", + "deposit.success.cta.depositMore": "Deposit more funds", + "guide.title": "How to deposit funds from {{ cex }}", + "guide.binance.steps": [ + "On Binance, go to Assets / Withdraw section.", + "Select which asset to transfer (currently supported: DOT, USDT, USDC).", + "Enter your deposit address on Hydration.", + "Select the correct network (it should be preselected automatically).", + "For DOT, the network should be Polkadot or Asset Hub.", + "For USDT and USDC, the network should be Statemint (Asset Hub).", + "Enter the amount to deposit.", + "Submit." + ], + "guide.kucoin.steps": [ + "On KuCoin, go to Assets / Funding Account / Withdraw.", + "Select which asset to transfer (currently supported: DOT, USDT, USDC).", + "Enter your deposit address on Hydration.", + "Select the correct network.", + "For DOT, the network should be Polkadot.", + "For USDT and USDC, the network should be Asset Hub.", + "Enter the amount to deposit.", + "Submit." + ], + "guide.gateio.steps": [ + "On Gate.io, go to Assets / Funds Management / Withdraw / Onchain.", + "Select which asset to transfer (currently supported: DOT, USDT, USDC).", + "Enter your deposit address on Hydration.", + "Select the correct network.", + "For DOT, the network should be Polkadot.", + "For USDT and USDC, the network should be Polkadot/DOTSM.", + "Submit." + ], + "guide.kraken.steps": [ + "On Kraken, go to Transfers / Withdraw.", + "Select which asset to transfer (currently supported: DOT or HDX).", + "Enter your deposit address on Hydration.", + "Enter the amount to deposit.", + "Click on Withdraw button." + ], + "guide.coinbase.steps": [ + "On Coinbase, go to Trading / Wallet Balance / Withdraw.", + "Select which asset to transfer (currently supported: DOT).", + "Enter your deposit address on Hydration.", + "Enter the amount to deposit.", + "Submit." + ], + "withdraw.bank.title": "Bank", + "withdraw.bank.vortex.cta": "Go to Vortex", + "withdraw.bank.vortex.description": "Withdraw USDC (via Polkadot AssetHub), sell for EUR, ARS or BRL and deposit to your bank account.", + "withdraw.method.title": "Withdraw to", + "withdraw.method.cex.title": "Centralized exchange", + "withdraw.method.cex.description": "Withdraw to Binance, Kraken, Coinbase, and others.", + "withdraw.method.onchain.title": "Another chain", + "withdraw.method.onchain.description": "Withdraw to Polkadot, Ethereum, Solana or SUI.", + "withdraw.method.bank.title": "Bank", + "withdraw.method.bank.description": "Withdraw funds directly to your bank account.", + "withdraw.disclaimer.cex.title": "Confirm that this is the correct {{ symbol }} deposit address on {{ cex }}", + "withdraw.disclaimer.cex.description": "I verified that the destination address is the correct address used by this exchange.", + "withdraw.cex.select.title": "Withdraw to CEX", + "withdraw.cex.account.evmError": "EVM accounts cannot be used to withdraw {{ symbol }} from a centralized exchange.", + "withdraw.transfer.button": "Confirm withdrawal", + "withdraw.cex.transfer.title": "Withdraw to {{ cex }}", + "withdraw.transfer.destination.placeholder": "Paste CEX address here...", + "withdraw.success.title": "Withdrawal successful", + "withdraw.success.description": "You have successfully withdrawn <0>{{ amount, currency }} to <0>{{ cex }}<0>.", + "withdraw.success.cta.withdrawtMore": "Withdraw more funds", + "withdraw.tx.transfer.description": "Transfer {{ amount, currency }} to {{ destChain }}", + "withdraw.tx.cex.description": "Withdraw {{ amount, currency }} to {{ cex }}", + "withdraw.tx.transfer.toast.submitted": "Transferring {{ amount, currency }} to {{ destChain }}", + "withdraw.tx.transfer.toast.success": "Transferred {{ amount, currency }} to {{ destChain }}", + "withdraw.tx.cex.toast.submitted": "Withdrawing {{ amount, currency }} to {{ cex }}", + "withdraw.tx.cex.toast.success": "Withdrew {{ amount, currency }} to {{ cex }}", + "withdraw.tx.pending.title": "Waiting for funds", + "withdraw.tx.pending.description": "Your funds are being transferred to the destination chain. Please wait." +} diff --git a/apps/main/src/modules/layout/components/DepositButton/DepositButton.tsx b/apps/main/src/modules/layout/components/DepositButton/DepositButton.tsx new file mode 100644 index 0000000000..2d8e0fcca6 --- /dev/null +++ b/apps/main/src/modules/layout/components/DepositButton/DepositButton.tsx @@ -0,0 +1,31 @@ +import { Button } from "@galacticcouncil/ui/components" +import { useAccount } from "@galacticcouncil/web3-connect" +import { Link } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" +import { useShallow } from "zustand/shallow" + +import { LINKS } from "@/config/navigation" +import { + selectPendingDepositsByAccount, + useOnrampStore, +} from "@/modules/onramp/store/useOnrampStore" + +export const DepositButton = () => { + const { t } = useTranslation(["common"]) + + const { account } = useAccount() + + const pendingDeposits = useOnrampStore( + useShallow(selectPendingDepositsByAccount(account?.address)), + ) + + const count = pendingDeposits.length + + return ( + + ) +} diff --git a/apps/main/src/modules/layout/components/HeaderToolbar.tsx b/apps/main/src/modules/layout/components/HeaderToolbar.tsx index 9dd3e17cb1..d6a2a5c55e 100644 --- a/apps/main/src/modules/layout/components/HeaderToolbar.tsx +++ b/apps/main/src/modules/layout/components/HeaderToolbar.tsx @@ -3,6 +3,7 @@ import { ButtonIcon, ExternalLink, Icon } from "@galacticcouncil/ui/components" import { FC, lazy } from "react" import { HYDRATION_DOCS_LINK } from "@/config/links" +import { DepositButton } from "@/modules/layout/components/DepositButton/DepositButton" import { SHeaderToolbar } from "@/modules/layout/components/HeaderToolbar.styled" import { HeaderWeb3ConnectButton } from "@/modules/layout/components/HeaderWeb3ConnectButton" import { NotificationCenter } from "@/modules/layout/components/NotificationCenter/NotificationCenter" @@ -28,6 +29,7 @@ export const HeaderToolbar: FC = () => { )} {hasTopNavbar && } + {hasTopNavbar && } ) diff --git a/apps/main/src/modules/liquidity/Liquidity.utils.tsx b/apps/main/src/modules/liquidity/Liquidity.utils.tsx index 7dfca16a8b..7f70d0ddf6 100644 --- a/apps/main/src/modules/liquidity/Liquidity.utils.tsx +++ b/apps/main/src/modules/liquidity/Liquidity.utils.tsx @@ -51,11 +51,7 @@ import { useAccountPositions, } from "@/states/account" import { useAssetsPrice } from "@/states/displayAsset" -import { - setOmnipoolAssets, - setXYKPools, - useOmnipoolStablepoolAssets, -} from "@/states/liquidity" +import { setOmnipoolAssets, setXYKPools } from "@/states/liquidity" import { useTradeSettings } from "@/states/tradeSettings" import { scaleHuman } from "@/utils/formatting" @@ -746,30 +742,6 @@ export const useStablepoolsReserves = (poolIds?: string[]) => { return { data, isLoading: isPoolsLoading || isPriceLoading } } -export const useOmnipoolShare = (id: string) => { - const { - data: omnipoolAssets = [], - getOmnipoolAsset, - isLoading: isOmnipoolAssetsLoading, - } = useOmnipoolStablepoolAssets() - - const balance = getOmnipoolAsset(id)?.tvlDisplay - - const omnipoolShare = balance - ? Big(balance) - .div( - omnipoolAssets.reduce( - (acc, asset) => acc.plus(asset.tvlDisplay ?? 0), - Big(0), - ), - ) - .times(100) - .toString() - : undefined - - return { omnipoolShare, isLoading: isOmnipoolAssetsLoading } -} - export const isValidStablepoolToken = (token: PoolToken) => { return token.type === AssetType.TOKEN || token.type === AssetType.ERC20 } diff --git a/apps/main/src/modules/liquidity/components/PoolDetailsValues/PoolDetailsValues.tsx b/apps/main/src/modules/liquidity/components/PoolDetailsValues/PoolDetailsValues.tsx index 838868acb4..6aa49dd35e 100644 --- a/apps/main/src/modules/liquidity/components/PoolDetailsValues/PoolDetailsValues.tsx +++ b/apps/main/src/modules/liquidity/components/PoolDetailsValues/PoolDetailsValues.tsx @@ -9,6 +9,7 @@ import { getToken } from "@galacticcouncil/ui/utils" import Big from "big.js" import { useTranslation } from "react-i18next" +import { useTotalOmnipoolLiquidity } from "@/api/omnipool" import { PoolToken } from "@/api/pools" import { useXYKConsts } from "@/api/xyk" import { AssetLogo } from "@/components/AssetLogo" @@ -17,7 +18,6 @@ import { isIsolatedPool, IsolatedPoolTable, OmnipoolAssetTable, - useOmnipoolShare, } from "@/modules/liquidity/Liquidity.utils" import { useAssets } from "@/providers/assetsProvider" import { useAssetPrice } from "@/states/displayAsset" @@ -98,23 +98,30 @@ const OmnipoolValues = ({ data }: { data: OmnipoolAssetTable }) => { {displayOmnipoolShare && ( <> - + )} ) } -const OmnipoolShare = ({ id }: { id: string }) => { +const OmnipoolShare = ({ tvl }: { tvl: string | undefined }) => { const { t } = useTranslation(["common", "liquidity"]) - const { omnipoolShare, isLoading: isOmnipoolShareLoading } = - useOmnipoolShare(id) + const { + data: totalOmnipoolLiquidity, + isLoading: isTotalOmnipoolLiquidityLoading, + } = useTotalOmnipoolLiquidity() + + const percent = + tvl && totalOmnipoolLiquidity + ? Big(tvl).div(totalOmnipoolLiquidity).times(100).toString() + : undefined return ( ) diff --git a/apps/main/src/modules/onramp/components/AccountBox.tsx b/apps/main/src/modules/onramp/components/AccountBox.tsx new file mode 100644 index 0000000000..0593f3b8ed --- /dev/null +++ b/apps/main/src/modules/onramp/components/AccountBox.tsx @@ -0,0 +1,107 @@ +import { + ChevronDown, + Copy, + ExternalLink, +} from "@galacticcouncil/ui/assets/icons" +import { + AccountAvatar, + Button, + Flex, + Icon, + Paper, + Stack, + Text, +} from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { safeConvertAddressSS58, useCopy } from "@galacticcouncil/utils" +import { Account } from "@galacticcouncil/web3-connect" +import { useTranslation } from "react-i18next" + +import { + createCexWithdrawalUrl, + getCexConfigById, +} from "@/modules/onramp/config/cex" +import { AssetConfig } from "@/modules/onramp/types" + +export type AccountBoxProps = Account & { + ss58Format: number + error?: string + cexId: string + asset: AssetConfig | null + onToggleWeb3Modal: () => void +} + +export const AccountBox: React.FC = ({ + name, + address, + displayAddress, + ss58Format, + error, + cexId, + asset, + onToggleWeb3Modal, +}) => { + const { t } = useTranslation(["onramp", "common"]) + const { copied, copy } = useCopy(5000) + + const formattedAddress = safeConvertAddressSS58(address, ss58Format) ?? "" + + const cex = getCexConfigById(cexId) + const cexUrl = asset + ? createCexWithdrawalUrl(cexId, asset.data.asset.originSymbol) + : "" + + return ( + + + + + {error ? ( + + {error} + + ) : ( + + {formattedAddress} + + )} + + + + {cexUrl && ( + + )} + + + + ) +} diff --git a/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelect.tsx b/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelect.tsx new file mode 100644 index 0000000000..81160e08b9 --- /dev/null +++ b/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelect.tsx @@ -0,0 +1,71 @@ +import { + Grid, + GridProps, + VirtualizedList, +} from "@galacticcouncil/ui/components" + +import { CexAssetSelectRow } from "@/modules/onramp/components/CexAssetSelect/CexAssetSelectRow" +import { + CEX_ROW_HEIGHT, + CexSelectRow, +} from "@/modules/onramp/components/CexAssetSelect/CexSelectRow" +import { CEX_CONFIG, getCexConfigById } from "@/modules/onramp/config/cex" +import { AssetConfig } from "@/modules/onramp/types" + +const ASSET_ROW_HEIGHT = 60 +const MAX_VISIBLE = 10 + +export type CexAssetSelectProps = GridProps & { + activeCexId: string + onAssetSelect: (asset: AssetConfig) => void + onCexSelect: (id: string) => void +} + +export const CexAssetSelect: React.FC = ({ + activeCexId, + onCexSelect, + onAssetSelect, + ...props +}) => { + const cex = getCexConfigById(activeCexId) + + if (!cex) return null + + return ( + + ( + onCexSelect(cex.id)} + /> + )} + /> + ( + onAssetSelect(item)} + assetId={item.assetId} + /> + )} + /> + + ) +} diff --git a/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelectRow.styled.ts b/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelectRow.styled.ts new file mode 100644 index 0000000000..a2ef604053 --- /dev/null +++ b/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelectRow.styled.ts @@ -0,0 +1,20 @@ +import { ButtonTransparent } from "@galacticcouncil/ui/components" +import { css, styled } from "@galacticcouncil/ui/utils" + +export const SRow = styled(ButtonTransparent)( + ({ theme }) => css` + display: flex; + align-items: center; + justify-content: flex-start; + + width: 100%; + height: 100%; + + padding-inline: ${theme.space.m}; + gap: ${theme.space.m}; + + &:hover { + background: ${theme.details.separators}; + } + `, +) diff --git a/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelectRow.tsx b/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelectRow.tsx new file mode 100644 index 0000000000..4f85ea8d18 --- /dev/null +++ b/apps/main/src/modules/onramp/components/CexAssetSelect/CexAssetSelectRow.tsx @@ -0,0 +1,24 @@ +import { AssetLabel } from "@galacticcouncil/ui/components" + +import { AssetLogo } from "@/components/AssetLogo" +import { useAssets } from "@/providers/assetsProvider" + +import { SRow } from "./CexAssetSelectRow.styled" + +type CexAssetSelectRowProps = { + onClick: () => void + assetId: string +} +export const CexAssetSelectRow: React.FC = ({ + assetId, + ...props +}) => { + const { getAssetWithFallback } = useAssets() + const asset = getAssetWithFallback(assetId) + return ( + + + + + ) +} diff --git a/apps/main/src/modules/onramp/components/CexAssetSelect/CexSelectRow.styled.ts b/apps/main/src/modules/onramp/components/CexAssetSelect/CexSelectRow.styled.ts new file mode 100644 index 0000000000..7300e99197 --- /dev/null +++ b/apps/main/src/modules/onramp/components/CexAssetSelect/CexSelectRow.styled.ts @@ -0,0 +1,11 @@ +import { Button } from "@galacticcouncil/ui/components" +import { css, styled } from "@galacticcouncil/ui/utils" + +export const SButton = styled(Button)<{ isActive: boolean }>( + ({ theme, isActive }) => css` + width: 100%; + justify-content: flex-start; + color: ${isActive ? theme.text.high : theme.text.medium}; + padding-inline: ${theme.space.base}; + `, +) diff --git a/apps/main/src/modules/onramp/components/CexAssetSelect/CexSelectRow.tsx b/apps/main/src/modules/onramp/components/CexAssetSelect/CexSelectRow.tsx new file mode 100644 index 0000000000..98102af0a6 --- /dev/null +++ b/apps/main/src/modules/onramp/components/CexAssetSelect/CexSelectRow.tsx @@ -0,0 +1,37 @@ +import { Flex, Icon, Text } from "@galacticcouncil/ui/components" + +import { SButton } from "./CexSelectRow.styled" + +export const CEX_ROW_HEIGHT = 36 + +type CexSelectRowProps = { + title: string + logo: React.ComponentType + isActive: boolean + onClick: () => void +} + +export const CexSelectRow: React.FC = ({ + title, + logo, + isActive, + onClick, +}) => { + return ( + + + + + + {title} + + + + + ) +} diff --git a/apps/main/src/modules/onramp/components/CexDepositGuide.tsx b/apps/main/src/modules/onramp/components/CexDepositGuide.tsx new file mode 100644 index 0000000000..8d3e647d6e --- /dev/null +++ b/apps/main/src/modules/onramp/components/CexDepositGuide.tsx @@ -0,0 +1,50 @@ +import { Stack, Text } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { useTranslation } from "react-i18next" + +import { getCexConfigById } from "@/modules/onramp/config/cex" + +import { HowToSteps } from "./HowToSteps" + +export type CexDepositGuideProps = { cexId: string } + +export const CexDepositGuide: React.FC = ({ cexId }) => { + const { t } = useTranslation(["onramp"]) + + const steps = (() => { + switch (cexId) { + case "binance": + return t("guide.binance.steps", { returnObjects: true }) + case "kucoin": + return t("guide.kucoin.steps", { returnObjects: true }) + case "gateio": + return t("guide.gateio.steps", { returnObjects: true }) + case "kraken": + return t("guide.kraken.steps", { returnObjects: true }) + case "coinbase": + return t("guide.coinbase.steps", { returnObjects: true }) + default: + return [] + } + })() + + if (!steps.length) { + return null + } + + const cex = getCexConfigById(cexId)! + + return ( + + + {t("onramp:guide.title", { cex: cex.title })} + + + + ) +} diff --git a/apps/main/src/modules/onramp/components/HowToSteps.tsx b/apps/main/src/modules/onramp/components/HowToSteps.tsx new file mode 100644 index 0000000000..67d50ee507 --- /dev/null +++ b/apps/main/src/modules/onramp/components/HowToSteps.tsx @@ -0,0 +1,25 @@ +import { Points, Stack, Text } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { FC } from "react" + +export type HowToStepsProps = { + steps: string[] +} + +export const HowToSteps: FC = ({ steps }) => { + return ( + + {steps.map((title, index) => ( + + {title} + + } + /> + ))} + + ) +} diff --git a/apps/main/src/modules/onramp/components/PendingDeposit.tsx b/apps/main/src/modules/onramp/components/PendingDeposit.tsx new file mode 100644 index 0000000000..0e0310186a --- /dev/null +++ b/apps/main/src/modules/onramp/components/PendingDeposit.tsx @@ -0,0 +1,53 @@ +import { Box, Button, Flex, Stack, Text } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { bigShift } from "@galacticcouncil/utils" +import { useTranslation } from "react-i18next" +import { pick } from "remeda" +import { useShallow } from "zustand/shallow" + +import { AssetLogo } from "@/components/AssetLogo/AssetLogo" +import { useOnrampStore } from "@/modules/onramp/store/useOnrampStore" +import { DepositConfig, OnrampScreen } from "@/modules/onramp/types" +import { useAssets } from "@/providers/assetsProvider" + +export const PendingDeposit: React.FC = ({ asset, amount }) => { + const { t } = useTranslation(["onramp", "common"]) + const { getAsset } = useAssets() + const { setAsset, paginateTo, setAmount } = useOnrampStore( + useShallow(pick(["setAsset", "paginateTo", "setAmount"])), + ) + + const assetMeta = getAsset(asset.assetId) + + if (!assetMeta) return null + + const handleContinue = () => { + setAsset(asset) + paginateTo(OnrampScreen.DepositTransfer) + setAmount(amount) + } + + return ( + + + + + + + {assetMeta.symbol} + + + {t("common:currency", { + value: bigShift(amount, -assetMeta.decimals).toString(), + symbol: assetMeta.symbol, + })} + + + + + + + ) +} diff --git a/apps/main/src/modules/onramp/components/StepButton.styled.tsx b/apps/main/src/modules/onramp/components/StepButton.styled.tsx new file mode 100644 index 0000000000..73555b7afd --- /dev/null +++ b/apps/main/src/modules/onramp/components/StepButton.styled.tsx @@ -0,0 +1,20 @@ +import { ButtonTransparent } from "@galacticcouncil/ui/components" +import { css, styled } from "@galacticcouncil/ui/utils" + +export const SStepButton = styled(ButtonTransparent)( + ({ theme }) => css` + padding-inline: ${theme.space.xl}; + padding-block: ${theme.space.l}; + + justify-content: flex-start; + + border-radius: ${theme.radii.l}; + + transition: ${theme.transitions.colors}; + background: ${theme.buttons.secondary.low.rest}; + + &:hover { + background: ${theme.buttons.secondary.low.hover}; + } + `, +) diff --git a/apps/main/src/modules/onramp/components/StepButton.tsx b/apps/main/src/modules/onramp/components/StepButton.tsx new file mode 100644 index 0000000000..06d5b3f347 --- /dev/null +++ b/apps/main/src/modules/onramp/components/StepButton.tsx @@ -0,0 +1,47 @@ +import { ArrowRight } from "@galacticcouncil/ui/assets/icons" +import { Icon, Stack, Text } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { ComponentType } from "react" + +import { SStepButton } from "@/modules/onramp/components/StepButton.styled" + +export type StepButtonProps = { + title: string + description: string + icon?: ComponentType + onClick: () => void +} + +export const StepButton: React.FC = ({ + title, + description, + icon: IconComponent, + onClick, +}) => { + return ( + + {IconComponent && ( + + )} + + + {title} + + + {description} + + + + + ) +} diff --git a/apps/main/src/modules/onramp/components/WaitingForBalanceUpdate.tsx b/apps/main/src/modules/onramp/components/WaitingForBalanceUpdate.tsx new file mode 100644 index 0000000000..5d73e90a22 --- /dev/null +++ b/apps/main/src/modules/onramp/components/WaitingForBalanceUpdate.tsx @@ -0,0 +1,20 @@ +import { Flex, Spinner, Stack, Text } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { useTranslation } from "react-i18next" + +export const WaitingForBalanceUpdate = () => { + const { t } = useTranslation(["onramp"]) + return ( + + + + + {t("withdraw.tx.pending.title")} + + + {t("withdraw.tx.pending.description")} + + + + ) +} diff --git a/apps/main/src/modules/onramp/config/cex.tsx b/apps/main/src/modules/onramp/config/cex.tsx new file mode 100644 index 0000000000..2d73993d6e --- /dev/null +++ b/apps/main/src/modules/onramp/config/cex.tsx @@ -0,0 +1,166 @@ +import { + BinanceLogo, + CoinbaseLogo, + GateioLogo, + KrakenLogo, + KucoinLogo, +} from "@galacticcouncil/ui/assets/icons" +import { chainsMap } from "@galacticcouncil/xc-cfg" +import { EvmParachain } from "@galacticcouncil/xc-core" + +const hydration = chainsMap.get("hydration") as EvmParachain + +export const CEX_CONFIG = [ + { + id: "kraken", + title: "Kraken", + logo: KrakenLogo, + assets: [ + { + assetId: "5", + minDeposit: 2, + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("dot")!, + }, + { + assetId: "0", + withdrawalChain: "hydration", + depositChain: "hydration", + data: hydration.assetsData.get("hdx")!, + }, + ], + }, + { + id: "binance", + title: "Binance", + logo: BinanceLogo, + assets: [ + { + assetId: "5", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("dot")!, + }, + { + assetId: "10", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("usdt")!, + }, + { + assetId: "22", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("usdc")!, + }, + ], + }, + { + id: "kucoin", + title: "KuCoin", + logo: KucoinLogo, + assets: [ + { + assetId: "5", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("dot")!, + }, + { + assetId: "10", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("usdt")!, + }, + { + assetId: "22", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("usdc")!, + }, + ], + }, + { + id: "coinbase", + title: "Coinbase", + logo: CoinbaseLogo, + assets: [ + { + assetId: "5", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("dot")!, + }, + ], + }, + { + id: "gateio", + title: "Gate.io", + logo: GateioLogo, + assets: [ + { + assetId: "5", + withdrawalChain: "assethub", + depositChain: "polkadot", + data: hydration.assetsData.get("dot")!, + }, + { + assetId: "10", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("usdt")!, + }, + { + assetId: "22", + withdrawalChain: "assethub", + depositChain: "assethub", + data: hydration.assetsData.get("usdc")!, + }, + ], + }, +] + +export const CEX_DEPOSIT_LIMITS: Record = { + "5": 2.5, + "0": 5, + "10": 4, + "22": 4, +} + +export const CEX_WITHDRAW_LIMITS: Record = { + "5": 2.5, + "0": 5, + "10": 4, + "22": 4, +} + +export const createCexWithdrawalUrl = (cexId: string, assetSymbol: string) => { + const symbol = assetSymbol.toUpperCase() + const network = symbol === "DOT" ? "Polkadot" : "Hydration" + const method = symbol === "DOT" ? "Polkadot" : "HydraDX Network" + switch (cexId) { + case "kraken": + return encodeURI( + `https://www.kraken.com/c/funding/withdraw?asset=${symbol}&network=${network}&method=${method}`, + ) + case "binance": + return encodeURI( + `https://www.binance.com/en/my/wallet/account/main/withdrawal/crypto/${symbol}`, + ) + case "kucoin": + return encodeURI(`https://www.kucoin.com/assets/withdraw/${symbol}`) + case "coinbase": + return `https://www.coinbase.com` + case "gateio": + return encodeURI(`https://www.gate.io/myaccount/withdraw/${symbol}`) + default: + return "" + } +} + +export const createDepositId = (assetId: string, address: string) => + `${assetId}-${address}` + +export const getCexConfigById = (id: string) => + CEX_CONFIG.find((c) => c.id === id) diff --git a/apps/main/src/modules/onramp/deposit/DepositManager.tsx b/apps/main/src/modules/onramp/deposit/DepositManager.tsx new file mode 100644 index 0000000000..1226570dee --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/DepositManager.tsx @@ -0,0 +1,85 @@ +import { AssetAmount } from "@galacticcouncil/xc-core" +import { differenceInMinutes } from "date-fns" +import { useCallback } from "react" +import { useInterval } from "react-use" + +import { useCrossChainBalanceSubscription } from "@/api/xcm" +import { useOnrampStore } from "@/modules/onramp/store/useOnrampStore" +import { DepositConfig, OnrampScreen } from "@/modules/onramp/types" + +export type DepositSubscriptionProps = DepositConfig & { + onDepositDetected: (deposit: DepositConfig) => void + onDepositExpired: () => void +} + +const DepositSubscription: React.FC = ({ + address, + asset, + createdAt, + onDepositDetected, + balanceSnapshot, + onDepositExpired, +}) => { + useInterval(() => { + const diffMinutes = differenceInMinutes(Date.now(), createdAt) + // if the deposit is older than 10 minutes, expire it + if (diffMinutes >= 10) { + onDepositExpired() + } + }, 1000 * 60) + + useCrossChainBalanceSubscription( + address, + asset.depositChain, + useCallback( + (balances: AssetAmount[]) => { + const assetKey = asset.data.asset.key + const balance = + balances?.find((a) => a.key === assetKey)?.amount ?? null + + if (!balanceSnapshot || !balance) return + + const amount = balance - BigInt(balanceSnapshot) + const isMultiStepTransfer = asset.depositChain !== "hydration" + + if (amount > 0n) { + if (isMultiStepTransfer) { + onDepositDetected({ + ...useOnrampStore.getState().currentDeposit!, + amount: amount.toString(), + }) + } + } + }, + [ + asset.data.asset.key, + asset.depositChain, + balanceSnapshot, + onDepositDetected, + ], + ), + ) + + return null +} + +export const DepositManager = () => { + const { currentDeposit, setCurrentDeposit, setPendingDeposit, paginateTo } = + useOnrampStore() + + if (!currentDeposit) return null + + return ( + { + setCurrentDeposit(null) + paginateTo(OnrampScreen.MethodSelect) + }} + onDepositDetected={(deposit) => { + setCurrentDeposit(null) + setPendingDeposit(deposit) + }} + /> + ) +} diff --git a/apps/main/src/modules/onramp/deposit/DepositPage.tsx b/apps/main/src/modules/onramp/deposit/DepositPage.tsx new file mode 100644 index 0000000000..ee9a3ffcd0 --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/DepositPage.tsx @@ -0,0 +1,103 @@ +import { + Box, + Flex, + ModalContainer, + Paper, + Separator, + Stack, + Text, +} from "@galacticcouncil/ui/components" +import { HYDRATION_CHAIN_KEY } from "@galacticcouncil/utils" +import { useAccount } from "@galacticcouncil/web3-connect" +import { useTranslation } from "react-i18next" +import { useUnmount } from "react-use" +import { useShallow } from "zustand/shallow" + +import { PendingDeposit } from "@/modules/onramp/components/PendingDeposit" +import { useDeposit } from "@/modules/onramp/deposit/hooks/useDeposit" +import { DepositAsset } from "@/modules/onramp/deposit/steps/DepositAsset" +import { DepositBank } from "@/modules/onramp/deposit/steps/DepositBank" +import { DepositCexSelect } from "@/modules/onramp/deposit/steps/DepositCexSelect" +import { DepositMethodSelect } from "@/modules/onramp/deposit/steps/DepositMethodSelect" +import { DepositSuccess } from "@/modules/onramp/deposit/steps/DepositSuccess" +import { DepositTransfer } from "@/modules/onramp/deposit/steps/DepositTransfer" +import { + selectPendingDepositsByAccount, + useOnrampStore, +} from "@/modules/onramp/store/useOnrampStore" +import { OnrampScreen } from "@/modules/onramp/types" + +export const DepositPage = () => { + const { t } = useTranslation(["onramp"]) + const { account } = useAccount() + + const { asset, page, setAsset, setTransfer, setSuccess, reset, paginateTo } = + useDeposit() + + useUnmount(reset) + + const isMultiStepTransfer = asset + ? asset.depositChain !== HYDRATION_CHAIN_KEY + : false + + const pendingDeposits = useOnrampStore( + useShallow(selectPendingDepositsByAccount(account?.address)), + ) + + const showPendingDeposits = + page === OnrampScreen.MethodSelect && pendingDeposits.length > 0 + + return ( + + + {page === OnrampScreen.MethodSelect && ( + + )} + + {page === OnrampScreen.DepositAssetSelect && ( + paginateTo(OnrampScreen.MethodSelect)} + /> + )} + + {page === OnrampScreen.DepositAsset && ( + paginateTo(OnrampScreen.DepositAssetSelect)} + /> + )} + + {page === OnrampScreen.DepositTransfer && ( + paginateTo(OnrampScreen.DepositAssetSelect)} + /> + )} + + {page === OnrampScreen.DepositBank && ( + paginateTo(OnrampScreen.MethodSelect)} /> + )} + {page === OnrampScreen.DepositSuccess && ( + + )} + + + {showPendingDeposits && ( + + + + {t("deposit.cex.transfer.ongoing.title")} + + + + + {pendingDeposits.map((deposit) => ( + + ))} + + + )} + + ) +} diff --git a/apps/main/src/modules/onramp/deposit/hooks/useDeposit.ts b/apps/main/src/modules/onramp/deposit/hooks/useDeposit.ts new file mode 100644 index 0000000000..eef4991c6f --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/hooks/useDeposit.ts @@ -0,0 +1,32 @@ +import { useOnrampStore } from "@/modules/onramp/store/useOnrampStore" +import { AssetConfig, OnrampScreen } from "@/modules/onramp/types" + +export const useDeposit = () => { + const state = useOnrampStore() + + const setAsset = (asset: AssetConfig) => { + state.setAsset(asset) + state.paginateTo(OnrampScreen.DepositAsset) + } + + const setTransfer = () => { + state.paginateTo(OnrampScreen.DepositTransfer) + } + + const setSuccess = () => { + state.paginateTo(OnrampScreen.DepositSuccess) + } + + const reset = () => { + state.reset() + state.paginateTo(OnrampScreen.MethodSelect) + } + + return { + ...state, + reset, + setAsset, + setTransfer, + setSuccess, + } +} diff --git a/apps/main/src/modules/onramp/deposit/steps/DepositAsset.tsx b/apps/main/src/modules/onramp/deposit/steps/DepositAsset.tsx new file mode 100644 index 0000000000..a10280ed6f --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/steps/DepositAsset.tsx @@ -0,0 +1,262 @@ +import { ChevronDown } from "@galacticcouncil/ui/assets/icons" +import { + Alert, + Button, + Flex, + Icon, + ModalBody, + ModalHeader, + Spinner, + Stack, + Text, +} from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { isH160Address } from "@galacticcouncil/utils" +import { + useAccount, + useWeb3ConnectModal, + Web3ConnectButton, +} from "@galacticcouncil/web3-connect" +import { chainsMap } from "@galacticcouncil/xc-cfg" +import { AssetAmount } from "@galacticcouncil/xc-core" +import React, { useCallback, useEffect, useRef } from "react" +import { Trans, useTranslation } from "react-i18next" +import { useCustomCompareEffect, usePrevious } from "react-use" +import { isBigInt } from "remeda" + +import { + useCrossChainBalance, + useCrossChainBalanceSubscription, +} from "@/api/xcm" +import { AssetLogo } from "@/components/AssetLogo/AssetLogo" +import { AccountBox } from "@/modules/onramp/components/AccountBox" +import { CexDepositGuide } from "@/modules/onramp/components/CexDepositGuide" +import { + CEX_DEPOSIT_LIMITS, + getCexConfigById, +} from "@/modules/onramp/config/cex" +import { useDeposit } from "@/modules/onramp/deposit/hooks/useDeposit" +import { AssetConfig } from "@/modules/onramp/types" +import { useAssets } from "@/providers/assetsProvider" + +export type DepositAssetProps = { + onDepositSuccess: (asset: AssetConfig) => void + onBack: () => void +} + +export const DepositAsset: React.FC = ({ + onDepositSuccess, + onBack, +}) => { + const { t } = useTranslation(["onramp", "common"]) + const { account } = useAccount() + const { toggle: toggleWeb3Modal } = useWeb3ConnectModal() + const { getAsset } = useAssets() + + const { + asset, + cexId, + currentDeposit, + setAmount: setDepositedAmount, + setCurrentDeposit, + } = useDeposit() + + const activeCex = getCexConfigById(cexId) + + const address = account?.address ?? "" + const dstChain = asset?.depositChain ?? "" + const assetKey = asset?.data.asset.key + + const balanceSnapshot = useRef(null) + + const prevAddress = usePrevious(address) + + useEffect(() => { + if (prevAddress && prevAddress !== address) { + balanceSnapshot.current = null + } + }, [address, prevAddress]) + + const setBalanceSnapshot = useCallback( + (balances: AssetAmount[]) => { + const balance = balances?.find((a) => a.key === assetKey)?.amount ?? null + if (balanceSnapshot.current === null && balance !== null) { + balanceSnapshot.current = balance + if (asset) { + setCurrentDeposit({ + cexId, + asset, + address, + amount: "", + balanceSnapshot: balance.toString(), + }) + } + } + }, + [address, asset, assetKey, cexId, setCurrentDeposit], + ) + + useCrossChainBalanceSubscription(address, dstChain, setBalanceSnapshot) + + const { data: balances } = useCrossChainBalance(address, dstChain) + + const balance = balances?.get(asset?.data.asset.key ?? "")?.amount + + useCustomCompareEffect( + () => { + console.log({ balance, currentSnapshow: balanceSnapshot.current }) + + if (!asset || !isBigInt(balance) || !isBigInt(balanceSnapshot.current)) { + return + } + if (balance > balanceSnapshot.current) { + const amount = balance - balanceSnapshot.current + onDepositSuccess(asset) + setDepositedAmount(amount.toString()) + if (currentDeposit) + setCurrentDeposit({ ...currentDeposit, amount: amount.toString() }) + } + }, + [balance], + (_, next) => { + if (!balanceSnapshot.current) return false + const nextBalance = next[0] ?? 0n + return nextBalance > balanceSnapshot.current + }, + ) + + const chain = chainsMap.get(dstChain) + + const isAccountAllowed = isH160Address(address) + ? (chain?.isEvmParachain() ?? false) + : true + + const assetDetails = asset ? getAsset(asset.assetId) : null + + const minDeposit = asset ? (CEX_DEPOSIT_LIMITS[asset.assetId] ?? 0) : 0 + + const getAddressPrefix = (chainKey: string) => { + // Get SS58 format - keep old hydration prefix for CEX compatibility + if (chainKey === "hydration") { + return 63 + } + return 0 + } + + if (!activeCex) return null + + return ( + <> + + + + + + + + + + + + + {!account && ( + + )} + + {account && assetDetails && ( + <> + toggleWeb3Modal()} + /> + + {isAccountAllowed && asset && minDeposit > 0 && ( + + + {t("deposit.cex.amount.min.title")}: + + + {minDeposit} {asset.data.asset.originSymbol} + + + )} + + {isAccountAllowed && ( + <> + + + + + + )} + + )} + + + + + + + ) +} diff --git a/apps/main/src/modules/onramp/deposit/steps/DepositBank.tsx b/apps/main/src/modules/onramp/deposit/steps/DepositBank.tsx new file mode 100644 index 0000000000..ec0fb5c235 --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/steps/DepositBank.tsx @@ -0,0 +1,32 @@ +import { ModalBody, ModalHeader, Stack } from "@galacticcouncil/ui/components" +import { useTranslation } from "react-i18next" + +import { StepButton } from "@/modules/onramp/components/StepButton" + +export type DepositBankProps = { + onBack: () => void +} + +export const DepositBank: React.FC = ({ onBack }) => { + const { t } = useTranslation(["onramp"]) + + return ( + <> + + + + window.open("https://banxa.com", "_blank")} + title="Banxa" + description={t("deposit.bank.banxa.description")} + /> + + + + ) +} diff --git a/apps/main/src/modules/onramp/deposit/steps/DepositCexSelect.tsx b/apps/main/src/modules/onramp/deposit/steps/DepositCexSelect.tsx new file mode 100644 index 0000000000..5864ce921f --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/steps/DepositCexSelect.tsx @@ -0,0 +1,45 @@ +import { ModalBody, ModalHeader } from "@galacticcouncil/ui/components" +import { useTranslation } from "react-i18next" +import { useMount } from "react-use" + +import { CexAssetSelect } from "@/modules/onramp/components/CexAssetSelect/CexAssetSelect" +import { getCexConfigById } from "@/modules/onramp/config/cex" +import { useOnrampStore } from "@/modules/onramp/store/useOnrampStore" +import { AssetConfig } from "@/modules/onramp/types" + +export type DepositCexSelectProps = { + onAssetSelect: (asset: AssetConfig) => void + onBack: () => void +} + +export const DepositCexSelect: React.FC = ({ + onAssetSelect, + onBack, +}) => { + const { t } = useTranslation(["onramp"]) + const { setCexId, cexId, setCurrentDeposit } = useOnrampStore() + + useMount(() => setCurrentDeposit(null)) + + const cex = getCexConfigById(cexId) + + if (!cex) return null + + return ( + <> + + + + + + ) +} diff --git a/apps/main/src/modules/onramp/deposit/steps/DepositMethodSelect.tsx b/apps/main/src/modules/onramp/deposit/steps/DepositMethodSelect.tsx new file mode 100644 index 0000000000..cf582e3610 --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/steps/DepositMethodSelect.tsx @@ -0,0 +1,54 @@ +import { + BriefcaseIcon, + CreditCardIcon, + LinkIcon, +} from "@galacticcouncil/ui/assets/icons" +import { ModalBody, ModalHeader, Stack } from "@galacticcouncil/ui/components" +import { useNavigate } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" + +import { StepButton } from "@/modules/onramp/components/StepButton" +import { OnrampScreen } from "@/modules/onramp/types" + +export type DepositMethodSelectProps = { + onSelect: (page: OnrampScreen) => void +} + +export const DepositMethodSelect: React.FC = ({ + onSelect, +}) => { + const { t } = useTranslation(["onramp"]) + const navigate = useNavigate() + + return ( + <> + + + + onSelect(OnrampScreen.DepositAssetSelect)} + title={t("deposit.method.cex.title")} + description={t("deposit.method.cex.description")} + /> + navigate({ to: "/cross-chain" })} + title={t("deposit.method.onchain.title")} + description={t("deposit.method.onchain.description")} + /> + onSelect(OnrampScreen.DepositBank)} + title={t("deposit.method.bank.title")} + description={t("deposit.method.bank.description")} + /> + + + + ) +} diff --git a/apps/main/src/modules/onramp/deposit/steps/DepositSuccess.tsx b/apps/main/src/modules/onramp/deposit/steps/DepositSuccess.tsx new file mode 100644 index 0000000000..73f67033c7 --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/steps/DepositSuccess.tsx @@ -0,0 +1,93 @@ +import { CircleCheck } from "@galacticcouncil/ui/assets/icons" +import { + Button, + Icon, + ModalBody, + Stack, + Text, +} from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { bigShift } from "@galacticcouncil/utils" +import { useNavigate } from "@tanstack/react-router" +import { Trans, useTranslation } from "react-i18next" + +import { LINKS } from "@/config/navigation" +import { StepButton } from "@/modules/onramp/components/StepButton" +import { useDeposit } from "@/modules/onramp/deposit/hooks/useDeposit" +import { useAssets } from "@/providers/assetsProvider" + +export type DepositSuccessProps = { + onConfirm: () => void +} + +export const DepositSuccess: React.FC = ({ + onConfirm, +}) => { + const { t } = useTranslation(["onramp"]) + const navigate = useNavigate() + const { getAsset } = useAssets() + const { amount, asset } = useDeposit() + + const assetDetails = asset?.assetId ? getAsset(asset.assetId) : null + + return ( + + + + + {t("deposit.success.title")} + + {assetDetails && ( + + + + + + )} + + + navigate({ to: LINKS.staking })} + /> + navigate({ to: LINKS.borrow })} + /> + navigate({ to: LINKS.liquidity })} + /> + navigate({ to: LINKS.swap })} + /> + navigate({ to: LINKS.walletAssets })} + /> + + + + + ) +} diff --git a/apps/main/src/modules/onramp/deposit/steps/DepositTransfer.tsx b/apps/main/src/modules/onramp/deposit/steps/DepositTransfer.tsx new file mode 100644 index 0000000000..4e83d3a40a --- /dev/null +++ b/apps/main/src/modules/onramp/deposit/steps/DepositTransfer.tsx @@ -0,0 +1,196 @@ +import { + AccountTile, + AssetInput, + FormLabel, + LoadingButton, + ModalBody, + ModalContentDivider, + ModalHeader, + Stack, +} from "@galacticcouncil/ui/components" +import { HYDRATION_CHAIN_KEY } from "@galacticcouncil/utils" +import { useAccount } from "@galacticcouncil/web3-connect" +import { chainsMap } from "@galacticcouncil/xc-cfg" +import { useQuery } from "@tanstack/react-query" +import Big from "big.js" +import { useEffect, useMemo } from "react" +import { Controller, FormProvider } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { useCrossChainWallet, xcmTransferQuery } from "@/api/xcm" +import { AssetLogo } from "@/components/AssetLogo" +import { createDepositId } from "@/modules/onramp/config/cex" +import { useDeposit } from "@/modules/onramp/deposit/hooks/useDeposit" +import { useSubmitXcmTransfer } from "@/modules/xcm/transfer/hooks/useSubmitXcmTransfer" +import { useXcmForm } from "@/modules/xcm/transfer/hooks/useXcmForm" +import { useAssets } from "@/providers/assetsProvider" +import { toBigInt, toDecimal } from "@/utils/formatting" + +export type DepositTransferProps = { + onTransferSuccess: () => void + onBack: () => void +} + +export const DepositTransfer: React.FC = ({ + onTransferSuccess, + onBack, +}) => { + const { t } = useTranslation(["onramp", "common", "xcm"]) + const { account } = useAccount() + const { + asset, + amount: depositAmount, + setAmount: setDepositedAmount, + setFinishedDeposit, + } = useDeposit() + const { getAsset } = useAssets() + + const address = account?.address ?? "" + const srcChainKey = asset?.depositChain ?? "" + const assetKey = asset?.data?.asset?.key ?? "" + const assetMeta = asset ? getAsset(asset.assetId) : null + + const wallet = useCrossChainWallet() + const { data: transfer, isLoading: isLoadingTransfer } = useQuery( + xcmTransferQuery(wallet, { + srcAddress: address, + srcAsset: assetKey, + srcChain: srcChainKey, + destAddress: address, + destAsset: assetKey, + destChain: HYDRATION_CHAIN_KEY, + }), + ) + + const transferData = useMemo(() => { + if (!transfer) + return { + balance: 0n, + min: 0n, + max: 0n, + symbol: "", + decimals: 0, + } + + const { balance, min, max } = transfer.source + + return { + symbol: balance.symbol, + decimals: balance.decimals, + balance: balance.amount, + min: min.amount, + max: max.amount, + } + }, [transfer]) + + const form = useXcmForm(transfer ?? null) + const amount = form.watch("srcAmount") + + useEffect(() => { + if (asset && transfer && depositAmount && !amount) { + const amountToSet = Big.min( + depositAmount, + transfer.source.max.amount.toString(), + ) + + form.reset({ + srcChain: chainsMap.get(srcChainKey), + srcAsset: asset.data.asset, + + destChain: chainsMap.get(HYDRATION_CHAIN_KEY), + destAsset: asset.data.asset, + + srcAmount: toDecimal(amountToSet, transfer.source.balance.decimals), + destAmount: toDecimal(amountToSet, transfer.source.balance.decimals), + + destAddress: address, + destAccount: account, + }) + form.trigger() + } + }, [ + account, + address, + amount, + asset, + depositAmount, + form, + srcChainKey, + transfer, + ]) + + const { mutate: submitTx, isPending: isSubmitting } = useSubmitXcmTransfer({ + onSuccess: () => { + const values = form.getValues() + if (!assetMeta || !asset) return + const amount = toBigInt(values.srcAmount, assetMeta.decimals) + setDepositedAmount(amount.toString()) + setFinishedDeposit(createDepositId(assetMeta.id, address)) + onTransferSuccess() + }, + }) + + return ( + + + +
transfer && submitTx([values, transfer]), + )} + > + ( + } + amountError={fieldState.error?.message} + onChange={field.onChange} + loading={isLoadingTransfer} + maxButtonBalance={toDecimal( + transferData.max, + transferData.decimals, + )} + maxBalance={toDecimal( + transferData.balance, + transferData.decimals, + )} + /> + )} + /> + + + {t("deposit.cex.transfer.destination")} + + + + + {t("deposit.cex.transfer.button")} + + +
+
+ ) +} diff --git a/apps/main/src/modules/onramp/store/useOnrampStore.ts b/apps/main/src/modules/onramp/store/useOnrampStore.ts new file mode 100644 index 0000000000..e2b2239068 --- /dev/null +++ b/apps/main/src/modules/onramp/store/useOnrampStore.ts @@ -0,0 +1,107 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +import { createDepositId } from "@/modules/onramp/config/cex" +import { + AssetConfig, + DepositConfig, + OnrampScreen, +} from "@/modules/onramp/types" + +const DEFAULT_CEX_ID = "kraken" + +type TCreateDepositEntry = Omit + +type OnrampStore = { + page: OnrampScreen + asset: AssetConfig | null + cexId: string + amount: string + currentDeposit: DepositConfig | null + pendingDeposits: DepositConfig[] + setAsset: (asset: AssetConfig) => void + setCexId: (cexId: string) => void + setAmount: (amount: string) => void + setCurrentDeposit: (deposit: TCreateDepositEntry | null) => void + setPendingDeposit: (deposit: TCreateDepositEntry) => void + setFinishedDeposit: (id: string) => void + paginateTo: (page: OnrampScreen) => void + reset: () => void +} + +const initialState = { + page: OnrampScreen.MethodSelect, + asset: null, + cexId: DEFAULT_CEX_ID, + amount: "", + currentDeposit: null, + pendingDeposits: [], +} + +export const useOnrampStore = create( + persist( + (set) => ({ + ...initialState, + setAsset: (asset) => set({ asset }), + setCexId: (cexId) => set({ cexId }), + setAmount: (amount) => set({ amount }), + setCurrentDeposit: (deposit) => + set({ + currentDeposit: deposit + ? { + ...deposit, + createdAt: Date.now(), + id: createDepositId(deposit.asset.assetId, deposit.address), + } + : null, + }), + setPendingDeposit: (deposit) => + set((state) => { + // remove previous deposit with the same id + const filteredPendingDeposits = state.pendingDeposits.filter( + ({ id }) => + id !== createDepositId(deposit.asset.assetId, deposit.address), + ) + + return { + pendingDeposits: [ + ...filteredPendingDeposits, + { + ...deposit, + createdAt: Date.now(), + id: createDepositId(deposit.asset.assetId, deposit.address), + }, + ], + } + }), + setFinishedDeposit: (id) => + set((state) => ({ + pendingDeposits: state.pendingDeposits.filter( + (deposit) => deposit.id !== id, + ), + })), + paginateTo: (page) => set({ page }), + reset: () => + set((state) => ({ + ...initialState, + currentDeposit: state.currentDeposit, + pendingDeposits: state.pendingDeposits, + })), + }), + { + name: "onramp", + version: 0.2, + partialize: (state) => ({ + ...state, + ...initialState, + pendingDeposits: state.pendingDeposits, + }), + }, + ), +) + +export const selectPendingDepositsByAccount = + (address?: string) => (state: OnrampStore) => + address + ? state.pendingDeposits.filter((deposit) => deposit.address === address) + : [] diff --git a/apps/main/src/modules/onramp/types/index.ts b/apps/main/src/modules/onramp/types/index.ts new file mode 100644 index 0000000000..ab948e200c --- /dev/null +++ b/apps/main/src/modules/onramp/types/index.ts @@ -0,0 +1,51 @@ +import { ChainAssetData } from "@galacticcouncil/xc-core" + +export enum DepositScreen2 { + Select = "Select", + Method = "Method", + DepositAsset = "DepositAsset", + Transfer = "Transfer", + Success = "Success", +} + +export enum DepositMethod2 { + DepositCex = "DepositCex", + DepositOnchain = "DepositOnchain", + DepositBank = "DepositBank", + WithdrawCex = "WithdrawCex", + WithdrawCrypto = "WithdrawCrypto", + WithdrawBank = "WithdrawBank", +} + +export enum OnrampScreen { + MethodSelect = "MethodSelect", + + DepositAssetSelect = "DepositAssetSelect", + DepositAsset = "DepositAsset", + DepositTransfer = "DepositTransfer", + DepositBank = "DepositBank", + DepositSuccess = "DepositSuccess", + + WithdrawAssetSelect = "WithdrawAssetSelect", + WithdrawAsset = "WithdrawAsset", + WithdrawTransfer = "WithdrawTransfer", + WithdrawBank = "WithdrawBank", + WithdrawSuccess = "WithdrawSuccess", +} + +export type AssetConfig = { + assetId: string + withdrawalChain: string + depositChain: string + data: ChainAssetData +} + +export type DepositConfig = { + id: string + cexId: string + asset: AssetConfig + address: string + createdAt: number + amount: string + balanceSnapshot: string +} diff --git a/apps/main/src/modules/onramp/withdraw/WithdrawPage.tsx b/apps/main/src/modules/onramp/withdraw/WithdrawPage.tsx new file mode 100644 index 0000000000..02ff076b9d --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/WithdrawPage.tsx @@ -0,0 +1,61 @@ +import { Flex, ModalContainer } from "@galacticcouncil/ui/components" +import { HYDRATION_CHAIN_KEY } from "@galacticcouncil/utils" +import { useUnmount } from "react-use" + +import { OnrampScreen } from "@/modules/onramp/types" +import { useWithdraw } from "@/modules/onramp/withdraw/hooks/useWithdraw" +import { WithdrawBank } from "@/modules/onramp/withdraw/steps/WithdrawBank" +import { WithdrawCexSelect } from "@/modules/onramp/withdraw/steps/WithdrawCexSelect" +import { WithdrawMethodSelect } from "@/modules/onramp/withdraw/steps/WithdrawMethodSelect" +import { WithdrawSuccess } from "@/modules/onramp/withdraw/steps/WithdrawSuccess" +import { WithdrawTransfer } from "@/modules/onramp/withdraw/steps/WithdrawTransfer" +import { WithdrawTransferOnchain } from "@/modules/onramp/withdraw/steps/WithdrawTransferOnchain" + +export const WithdrawPage = () => { + const { page, asset, reset, setAsset, setSuccess, paginateTo } = useWithdraw() + + useUnmount(reset) + + const isOnchain = asset?.withdrawalChain === HYDRATION_CHAIN_KEY + + return ( + + + {page === OnrampScreen.MethodSelect && ( + + )} + + {page === OnrampScreen.WithdrawAssetSelect && ( + paginateTo(OnrampScreen.MethodSelect)} + /> + )} + + {page === OnrampScreen.WithdrawTransfer && ( + <> + {isOnchain ? ( + paginateTo(OnrampScreen.WithdrawAssetSelect)} + /> + ) : ( + paginateTo(OnrampScreen.WithdrawAssetSelect)} + /> + )} + + )} + + {page === OnrampScreen.WithdrawBank && ( + paginateTo(OnrampScreen.MethodSelect)} /> + )} + + {page === OnrampScreen.WithdrawSuccess && ( + + )} + + + ) +} diff --git a/apps/main/src/modules/onramp/withdraw/hooks/useSubmitCexWithdraw.ts b/apps/main/src/modules/onramp/withdraw/hooks/useSubmitCexWithdraw.ts new file mode 100644 index 0000000000..c58bb2efb0 --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/hooks/useSubmitCexWithdraw.ts @@ -0,0 +1,189 @@ +import { hub, MultiAddress } from "@galacticcouncil/descriptors" +import { invariant, isParachain } from "@galacticcouncil/utils" +import { useAccount } from "@galacticcouncil/web3-connect" +import { Asset } from "@galacticcouncil/xc-core" +import { Transfer, Wallet } from "@galacticcouncil/xc-sdk" +import { useMutation } from "@tanstack/react-query" +import { useTranslation } from "react-i18next" + +import { calculateAssethubFee } from "@/api/external/assethub" +import { useCrossChainWallet } from "@/api/xcm" +import { WaitingForBalanceUpdate } from "@/modules/onramp/components/WaitingForBalanceUpdate" +import { getCexConfigById } from "@/modules/onramp/config/cex" +import { XcmFormValues } from "@/modules/xcm/transfer/hooks/useXcmFormSchema" +import { + assertTransferValues, + buildXcmTx, +} from "@/modules/xcm/transfer/utils/transfer" +import { useAssets } from "@/providers/assetsProvider" +import { useRpcProvider } from "@/providers/rpcProvider" +import { + TransactionActions, + TransactionType, + useTransactionsStore, +} from "@/states/transactions" +import { toBigInt, toDecimal } from "@/utils/formatting" + +export type CexWithdrawOptions = TransactionActions & { + asset: Asset | null + cexId: string +} + +export const useSubmitCexWithdraw = (options: CexWithdrawOptions) => { + const { t } = useTranslation(["onramp", "common"]) + const { createTransaction } = useTransactionsStore() + const { getAsset } = useAssets() + const { account } = useAccount() + const { papi } = useRpcProvider() + + const { cexId, asset } = options + + const wallet = useCrossChainWallet() + + return useMutation({ + mutationFn: async ([values, transfer]: [XcmFormValues, Transfer]) => { + invariant(account, "Account is required") + + const { srcAmount, srcChain, destChain } = assertTransferValues(values) + + const assetId = asset ? srcChain.getAssetId(asset) : "" + const assetMeta = getAsset(assetId.toString()) + + invariant(asset && assetMeta, "Invalid asset") + invariant(isParachain(destChain), "Destination chain must be a parachain") + + const { source } = transfer + + const i18nVars = { + amount: srcAmount, + symbol: source.balance.originSymbol, + destChain: destChain.name, + cex: getCexConfigById(cexId)?.title ?? "", + } + + const tx = await buildXcmTx(srcChain, transfer, srcAmount, papi) + + return createTransaction( + { + tx: [ + { + stepTitle: t("common:transfer"), + tx, + title: t("common:transfer"), + description: t("withdraw.tx.transfer.description", i18nVars), + invalidateQueries: [["xcm", "transfer"]], + fee: { + feeAmount: toDecimal(source.fee.amount, source.fee.decimals), + feeSymbol: source.fee.symbol, + }, + toasts: { + submitted: t("withdraw.tx.transfer.toast.submitted", i18nVars), + success: t("withdraw.tx.transfer.toast.success", i18nVars), + }, + meta: { + type: TransactionType.Xcm, + srcChainKey: srcChain.key, + srcChainFee: toDecimal(source.fee.amount, source.fee.decimals), + srcChainFeeSymbol: source.fee.symbol, + dstChainKey: destChain.key, + tags: [], + }, + }, + { + stepTitle: t("common:withdraw"), + pendingComponent: WaitingForBalanceUpdate, + tx: async () => { + await waitForBalanceChange( + wallet, + destChain.key, + asset, + account.address, + ) + + const amount = toBigInt(srcAmount, assetMeta.decimals) + const api = destChain.client.getTypedApi(hub) + + const destAssetId = destChain.getAssetId(asset) + + const [fee, assetInfo] = await Promise.all([ + calculateAssethubFee( + api.tx.Assets.transfer({ + id: Number(destAssetId), + target: MultiAddress.Id(values.destAddress), + amount, + }), + account.address, + asset, + ), + api.query.Assets.Asset.getValue(Number(destAssetId)), + ]) + + const halfMinBalance = assetInfo + ? assetInfo.min_balance / 2n + : 0n + + const adjustedAmount = amount - fee - halfMinBalance + + invariant(adjustedAmount > 0n, "Insufficient balance") + + return { + title: t("common:withdraw"), + description: t("withdraw.tx.cex.description", i18nVars), + signerFeeAsset: asset, + toasts: { + submitted: t("withdraw.tx.cex.toast.submitted", i18nVars), + success: t("withdraw.tx.cex.toast.success", i18nVars), + }, + tx: api.tx.Assets.transfer({ + id: Number(destAssetId), + target: MultiAddress.Id(values.destAddress), + amount: adjustedAmount, + }), + fee: { + feeAmount: toDecimal(fee, assetMeta.decimals), + feeSymbol: assetMeta.symbol, + }, + meta: { + type: TransactionType.Onchain, + srcChainKey: destChain.key, + }, + } + }, + }, + ], + }, + options, + ) + }, + }) +} + +async function waitForBalanceChange( + wallet: Wallet, + chainKey: string, + asset: Asset, + address: string, +): Promise { + const { promise, resolve, reject } = Promise.withResolvers() + let prevBalance: bigint | undefined + + const sub = await wallet.subscribeBalance(address, chainKey, (balances) => { + const balance = balances.find((b) => b.key === asset.key) + + if (!balance) { + sub.unsubscribe() + return reject(new Error("Asset not found")) + } + + if (!prevBalance) { + prevBalance = balance.amount + } + + if (balance.amount > prevBalance) { + sub.unsubscribe() + resolve(balance.amount) + } + }) + + return promise +} diff --git a/apps/main/src/modules/onramp/withdraw/hooks/useWithdraw.ts b/apps/main/src/modules/onramp/withdraw/hooks/useWithdraw.ts new file mode 100644 index 0000000000..b043c20316 --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/hooks/useWithdraw.ts @@ -0,0 +1,32 @@ +import { useOnrampStore } from "@/modules/onramp/store/useOnrampStore" +import { AssetConfig, OnrampScreen } from "@/modules/onramp/types" + +export const useWithdraw = () => { + const state = useOnrampStore() + + const setAsset = (asset: AssetConfig) => { + state.setAsset(asset) + state.paginateTo(OnrampScreen.WithdrawTransfer) + } + + const setTransfer = () => { + state.paginateTo(OnrampScreen.WithdrawTransfer) + } + + const setSuccess = () => { + state.paginateTo(OnrampScreen.WithdrawSuccess) + } + + const reset = () => { + state.reset() + state.paginateTo(OnrampScreen.MethodSelect) + } + + return { + ...state, + reset, + setAsset, + setTransfer, + setSuccess, + } +} diff --git a/apps/main/src/modules/onramp/withdraw/steps/WithdrawBank.tsx b/apps/main/src/modules/onramp/withdraw/steps/WithdrawBank.tsx new file mode 100644 index 0000000000..7d35b0ed97 --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/steps/WithdrawBank.tsx @@ -0,0 +1,34 @@ +import { ModalBody, ModalHeader, Stack } from "@galacticcouncil/ui/components" +import { useTranslation } from "react-i18next" + +import { StepButton } from "@/modules/onramp/components/StepButton" + +export type WithdrawBankProps = { + onBack: () => void +} + +export const WithdrawBank: React.FC = ({ onBack }) => { + const { t } = useTranslation(["onramp"]) + + return ( + <> + + + + + window.open("https://app.vortexfinance.co", "_blank") + } + title="Vortex" + description={t("withdraw.bank.vortex.description")} + /> + + + + ) +} diff --git a/apps/main/src/modules/onramp/withdraw/steps/WithdrawCexSelect.tsx b/apps/main/src/modules/onramp/withdraw/steps/WithdrawCexSelect.tsx new file mode 100644 index 0000000000..b56971a161 --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/steps/WithdrawCexSelect.tsx @@ -0,0 +1,45 @@ +import { ModalBody, ModalHeader } from "@galacticcouncil/ui/components" +import { useTranslation } from "react-i18next" +import { useMount } from "react-use" + +import { CexAssetSelect } from "@/modules/onramp/components/CexAssetSelect/CexAssetSelect" +import { getCexConfigById } from "@/modules/onramp/config/cex" +import { useOnrampStore } from "@/modules/onramp/store/useOnrampStore" +import { AssetConfig } from "@/modules/onramp/types" + +export type WithdrawCexSelectProps = { + onAssetSelect: (asset: AssetConfig) => void + onBack: () => void +} + +export const WithdrawCexSelect: React.FC = ({ + onAssetSelect, + onBack, +}) => { + const { t } = useTranslation(["onramp"]) + const { setCexId, cexId, setCurrentDeposit } = useOnrampStore() + + useMount(() => setCurrentDeposit(null)) + + const cex = getCexConfigById(cexId) + + if (!cex) return null + + return ( + <> + + + + + + ) +} diff --git a/apps/main/src/modules/onramp/withdraw/steps/WithdrawMethodSelect.tsx b/apps/main/src/modules/onramp/withdraw/steps/WithdrawMethodSelect.tsx new file mode 100644 index 0000000000..f2303312a0 --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/steps/WithdrawMethodSelect.tsx @@ -0,0 +1,54 @@ +import { + BriefcaseIcon, + CreditCardIcon, + LinkIcon, +} from "@galacticcouncil/ui/assets/icons" +import { ModalBody, ModalHeader, Stack } from "@galacticcouncil/ui/components" +import { useNavigate } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" + +import { StepButton } from "@/modules/onramp/components/StepButton" +import { OnrampScreen } from "@/modules/onramp/types" + +type WithdrawMethodSelectProps = { + onSelect: (method: OnrampScreen) => void +} + +export const WithdrawMethodSelect: React.FC = ({ + onSelect, +}) => { + const { t } = useTranslation(["onramp"]) + const navigate = useNavigate() + + return ( + <> + + + + onSelect(OnrampScreen.WithdrawAssetSelect)} + title={t("withdraw.method.cex.title")} + description={t("withdraw.method.cex.description")} + /> + navigate({ to: "/cross-chain" })} + title={t("withdraw.method.onchain.title")} + description={t("withdraw.method.onchain.description")} + /> + onSelect(OnrampScreen.WithdrawBank)} + title={t("withdraw.method.bank.title")} + description={t("withdraw.method.bank.description")} + /> + + + + ) +} diff --git a/apps/main/src/modules/onramp/withdraw/steps/WithdrawSuccess.tsx b/apps/main/src/modules/onramp/withdraw/steps/WithdrawSuccess.tsx new file mode 100644 index 0000000000..48a469c95d --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/steps/WithdrawSuccess.tsx @@ -0,0 +1,66 @@ +import { CircleCheck } from "@galacticcouncil/ui/assets/icons" +import { + Button, + Icon, + ModalBody, + Stack, + Text, +} from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { Trans, useTranslation } from "react-i18next" + +import { getCexConfigById } from "@/modules/onramp/config/cex" +import { useDeposit } from "@/modules/onramp/deposit/hooks/useDeposit" +import { useAssets } from "@/providers/assetsProvider" + +export type WithdrawSuccessProps = { + onConfirm: () => void +} + +export const WithdrawSuccess: React.FC = ({ + onConfirm, +}) => { + const { t } = useTranslation(["onramp"]) + const { getAsset } = useAssets() + const { asset, amount, cexId } = useDeposit() + const assetDetails = asset ? getAsset(asset.assetId) : null + + const cex = getCexConfigById(cexId) + + return ( + + + + + {t("withdraw.success.title")} + + {assetDetails && ( + + + + + + )} + + + + + + + ) +} diff --git a/apps/main/src/modules/onramp/withdraw/steps/WithdrawTransfer.tsx b/apps/main/src/modules/onramp/withdraw/steps/WithdrawTransfer.tsx new file mode 100644 index 0000000000..b7b5083f01 --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/steps/WithdrawTransfer.tsx @@ -0,0 +1,229 @@ +import { + AccountInput, + Alert, + AssetInput, + Flex, + FormError, + FormLabel, + LoadingButton, + ModalBody, + ModalContentDivider, + ModalHeader, + Text, + Toggle, +} from "@galacticcouncil/ui/components" +import { + HYDRATION_CHAIN_KEY, + isEvmParachain, + isEvmParachainAccount, +} from "@galacticcouncil/utils" +import { useAccount } from "@galacticcouncil/web3-connect" +import { chainsMap } from "@galacticcouncil/xc-cfg" +import { useQuery } from "@tanstack/react-query" +import { useMemo, useState } from "react" +import { Controller, FormProvider } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { useCrossChainWallet, xcmTransferQuery } from "@/api/xcm" +import { AssetLogo } from "@/components/AssetLogo" +import { getCexConfigById } from "@/modules/onramp/config/cex" +import { useSubmitCexWithdraw } from "@/modules/onramp/withdraw/hooks/useSubmitCexWithdraw" +import { useWithdraw } from "@/modules/onramp/withdraw/hooks/useWithdraw" +import { useXcmForm } from "@/modules/xcm/transfer/hooks/useXcmForm" +import { useAssets } from "@/providers/assetsProvider" +import { toDecimal } from "@/utils/formatting" + +export type WithdrawTransferProps = { + onTransferSuccess: () => void + onBack: () => void +} + +export const WithdrawTransfer: React.FC = ({ + onTransferSuccess, + onBack, +}) => { + const { t } = useTranslation(["onramp", "common", "xcm"]) + const { account } = useAccount() + const { asset, cexId, setAmount: setWithdrawnAmount } = useWithdraw() + const { getAsset } = useAssets() + const [disclaimerAccepted, setDisclaimerAccepted] = useState(false) + + const activeCex = getCexConfigById(cexId) + const address = account?.address ?? "" + const destChainKey = asset?.withdrawalChain ?? "" + const assetKey = asset?.data?.asset?.key ?? "" + const assetMeta = asset ? getAsset(asset.assetId) : null + + const srcChain = chainsMap.get(HYDRATION_CHAIN_KEY) + const destChain = chainsMap.get(destChainKey) + + const wallet = useCrossChainWallet() + const { data: transfer, isLoading: isLoadingTransfer } = useQuery( + xcmTransferQuery(wallet, { + srcChain: HYDRATION_CHAIN_KEY, + srcAsset: assetKey, + destChain: destChainKey, + destAsset: assetKey, + srcAddress: address, + destAddress: address, + }), + ) + + const transferData = useMemo(() => { + if (!transfer) + return { + balance: 0n, + min: 0n, + max: 0n, + symbol: "", + decimals: 0, + } + + const { balance, min, max } = transfer.source + + return { + symbol: balance.symbol, + decimals: balance.decimals, + balance: balance.amount, + min: min.amount, + max: max.amount, + } + }, [transfer]) + + const form = useXcmForm(transfer ?? null, { + syncWithQueryParams: false, + defaultValues: { + srcChain: srcChain ?? null, + destChain: destChain ?? null, + srcAsset: asset?.data.asset ?? null, + destAsset: asset?.data.asset ?? null, + }, + }) + + const { mutate: submitTx, isPending } = useSubmitCexWithdraw({ + asset: asset?.data.asset ?? null, + cexId, + onSuccess: () => { + setWithdrawnAmount(form.getValues("srcAmount")) + onTransferSuccess() + }, + }) + + const isAccountAllowed = isEvmParachainAccount(address) + ? !!destChain && isEvmParachain(destChain) + : true + + return ( + <> + + + +
transfer && submitTx([values, transfer]), + )} + > + ( + } + onChange={field.onChange} + loading={isLoadingTransfer} + amountError={fieldState.error?.message} + maxButtonBalance={toDecimal( + transferData.max, + transferData.decimals, + )} + maxBalance={toDecimal( + transferData.balance, + transferData.decimals, + )} + /> + )} + /> + + ( + + + + + + {fieldState.error && ( + {fieldState.error?.message} + )} + + )} + /> + + {isAccountAllowed ? ( + + + + {t("withdraw.disclaimer.cex.description")} + + + } + /> + ) : ( + + )} + + + {t("withdraw.transfer.button")} + + +
+
+ + ) +} diff --git a/apps/main/src/modules/onramp/withdraw/steps/WithdrawTransferOnchain.tsx b/apps/main/src/modules/onramp/withdraw/steps/WithdrawTransferOnchain.tsx new file mode 100644 index 0000000000..be51dacf8b --- /dev/null +++ b/apps/main/src/modules/onramp/withdraw/steps/WithdrawTransferOnchain.tsx @@ -0,0 +1,142 @@ +import { + AccountInput, + Alert, + AssetInput, + Flex, + FormLabel, + LoadingButton, + ModalBody, + ModalContentDivider, + ModalHeader, + Text, + Toggle, +} from "@galacticcouncil/ui/components" +import { useState } from "react" +import { Controller, FormProvider } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { AssetLogo } from "@/components/AssetLogo" +import { getCexConfigById } from "@/modules/onramp/config/cex" +import { useWithdraw } from "@/modules/onramp/withdraw/hooks/useWithdraw" +import { useTransferPositionForm } from "@/modules/wallet/assets/Transfer/TransferPosition.form" +import { useSubmitTransferPosition } from "@/modules/wallet/assets/Transfer/TransferPositionModal.submit" +import { useAssets } from "@/providers/assetsProvider" +import { useAccountBalances } from "@/states/account" +import { toDecimal } from "@/utils/formatting" + +export type WithdrawTransferOnchainProps = { + onTransferSuccess: () => void + onBack: () => void +} + +export const WithdrawTransferOnchain: React.FC< + WithdrawTransferOnchainProps +> = ({ onTransferSuccess, onBack }) => { + const { t } = useTranslation(["onramp", "common", "xcm"]) + const { asset, cexId, setAmount: setWithdrawnAmount } = useWithdraw() + const { getAsset } = useAssets() + const [disclaimerAccepted, setDisclaimerAccepted] = useState(false) + const { getTransferableBalance } = useAccountBalances() + + const activeCex = getCexConfigById(cexId) + const assetMeta = asset ? getAsset(asset.assetId) : null + + const form = useTransferPositionForm({ assetId: assetMeta?.id }) + const { mutate: transfer, isPending: isSubmitting } = + useSubmitTransferPosition({ + onClose: () => {}, + onSuccess: () => { + setWithdrawnAmount(form.getValues("amount")) + onTransferSuccess() + }, + }) + + const maxBalance = assetMeta + ? toDecimal(getTransferableBalance(assetMeta.id), assetMeta.decimals) + : 0n + + return ( + + + +
{ + return transfer(values) + })} + > + ( + } + onChange={field.onChange} + maxBalance={maxBalance.toString()} + amountError={fieldState.error?.message} + /> + )} + /> + + ( + + + + + + + )} + /> + + + + {t("withdraw.disclaimer.cex.description")} + + } + /> + + + + {t("withdraw.transfer.button")} + + +
+
+ ) +} diff --git a/apps/main/src/modules/trade/swap/sections/Market/Market.BuyData.ts b/apps/main/src/modules/trade/swap/sections/Market/Market.BuyData.ts index c880e9b237..e8fd3ccf87 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/Market.BuyData.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/Market.BuyData.ts @@ -3,12 +3,11 @@ import { useQueries, useQuery } from "@tanstack/react-query" import { UseFormReturn } from "react-hook-form" import { healthFactorQuery } from "@/api/aave" -import { bestBuyTwapWithTxQuery, bestBuyWithTxQuery } from "@/api/trade" +import { bestBuyQuery, bestBuyTwapQuery } from "@/api/trade" import { isTwapEnabled } from "@/modules/trade/swap/sections/Market/lib/isTwapEnabled" import { TradeProviderProps } from "@/modules/trade/swap/sections/Market/lib/tradeProvider" import { MarketFormValues } from "@/modules/trade/swap/sections/Market/lib/useMarketForm" import { useRpcProvider } from "@/providers/rpcProvider" -import { useTradeSettings } from "@/states/tradeSettings" export const useMarketBuyData = ( form: UseFormReturn, @@ -24,25 +23,15 @@ export const useMarketBuyData = ( "buyAmount", ]) - const { - swap: { - single: { swapSlippage }, - split: { twapSlippage, twapMaxRetries }, - }, - } = useTradeSettings() - const [ - { data: swapData, isLoading: isSwapLoading }, + { data: swap, isLoading: isSwapLoading }, { data: healthFactorData, isLoading: isHealthFactorLoading }, ] = useQueries({ queries: [ - bestBuyWithTxQuery(rpc, { + bestBuyQuery(rpc, { assetIn: sellAsset?.id ?? "", assetOut: buyAsset?.id ?? "", amountOut: buyAmount, - slippage: swapSlippage, - address, - dryRun: form.formState.isValid, debug: true, }), healthFactorQuery(rpc, { @@ -55,29 +44,21 @@ export const useMarketBuyData = ( ], }) - const { data: twapData, isLoading: isTwapLoading } = useQuery( - bestBuyTwapWithTxQuery( + const { data: twap, isLoading: isTwapLoading } = useQuery( + bestBuyTwapQuery( rpc, { assetIn: sellAsset?.id ?? "", assetOut: buyAsset?.id ?? "", amountOut: buyAmount, - slippage: twapSlippage, - maxRetries: twapMaxRetries, - address, - dryRun: form.formState.isValid, }, - isTwapEnabled(swapData?.swap), + isTwapEnabled(swap), ), ) return { - swap: swapData?.swap, - swapTx: swapData?.tx ?? null, - swapDryRunError: swapData?.dryRunError ?? null, - twap: twapData?.twap, - twapTx: twapData?.tx ?? null, - twapDryRunError: twapData?.dryRunError ?? null, + swap, + twap, healthFactor: healthFactorData, isSwapLoading, isTwapLoading, diff --git a/apps/main/src/modules/trade/swap/sections/Market/Market.SellData.ts b/apps/main/src/modules/trade/swap/sections/Market/Market.SellData.ts index 876bdc2386..cd30e978fa 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/Market.SellData.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/Market.SellData.ts @@ -3,12 +3,11 @@ import { useQueries, useQuery } from "@tanstack/react-query" import { UseFormReturn } from "react-hook-form" import { healthFactorQuery } from "@/api/aave" -import { bestSellTwapWithTxQuery, bestSellWithTxQuery } from "@/api/trade" +import { bestSellQuery, bestSellTwapQuery } from "@/api/trade" import { isTwapEnabled } from "@/modules/trade/swap/sections/Market/lib/isTwapEnabled" import { TradeProviderProps } from "@/modules/trade/swap/sections/Market/lib/tradeProvider" import { MarketFormValues } from "@/modules/trade/swap/sections/Market/lib/useMarketForm" import { useRpcProvider } from "@/providers/rpcProvider" -import { useTradeSettings } from "@/states/tradeSettings" export const useMarketSellData = ( form: UseFormReturn, @@ -24,25 +23,15 @@ export const useMarketSellData = ( "buyAmount", ]) - const { - swap: { - single: { swapSlippage }, - split: { twapSlippage, twapMaxRetries }, - }, - } = useTradeSettings() - const [ - { data: swapData, isLoading: isSwapLoading }, + { data: swap, isLoading: isSwapLoading }, { data: healthFactorData, isLoading: isHealthFactorLoading }, ] = useQueries({ queries: [ - bestSellWithTxQuery(rpc, { + bestSellQuery(rpc, { assetIn: sellAsset?.id ?? "", assetOut: buyAsset?.id ?? "", amountIn: sellAmount, - slippage: swapSlippage, - address, - dryRun: form.formState.isValid, debug: true, }), healthFactorQuery(rpc, { @@ -55,29 +44,21 @@ export const useMarketSellData = ( ], }) - const { data: twapData, isLoading: isTwapLoading } = useQuery( - bestSellTwapWithTxQuery( + const { data: twap, isLoading: isTwapLoading } = useQuery( + bestSellTwapQuery( rpc, { assetIn: sellAsset?.id ?? "", assetOut: buyAsset?.id ?? "", amountIn: sellAmount, - slippage: twapSlippage, - maxRetries: twapMaxRetries, - address, - dryRun: form.formState.isValid, }, - isTwapEnabled(swapData?.swap), + isTwapEnabled(swap), ), ) return { - swap: swapData?.swap, - swapTx: swapData?.tx ?? null, - swapDryRunError: swapData?.dryRunError ?? null, - twap: twapData?.twap, - twapTx: twapData?.tx ?? null, - twapDryRunError: twapData?.dryRunError ?? null, + swap, + twap, healthFactor: healthFactorData, isSwapLoading, isTwapLoading, 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 40987290cd..cd34514493 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/Market.tsx +++ b/apps/main/src/modules/trade/swap/sections/Market/Market.tsx @@ -52,11 +52,7 @@ export const Market: FC = () => { const { swap, - swapTx, - swapDryRunError, twap, - twapTx, - twapDryRunError, healthFactor, isSwapLoading, isTwapLoading, @@ -87,8 +83,8 @@ export const Market: FC = () => { sx={{ pb: isExpanded ? "xxl" : 0 }} onSubmit={form.handleSubmit((values) => isSingleTrade - ? swap && swapTx && submitSwap.mutate([values, swap, swapTx]) - : twap && twapTx && submitTwap.mutate([values, twap, twapTx]), + ? swap && submitSwap.mutate([values, swap]) + : twap && submitTwap.mutate([values, twap]), )} > @@ -96,9 +92,7 @@ export const Market: FC = () => { @@ -124,9 +118,7 @@ export const Market: FC = () => { = ({ + asset, +}) => { + const { t } = useTranslation() + + const { getTransferableBalance } = useAccountBalances() + const balance = getTransferableBalance(asset.id) + + return ( + + {t("transaction.alert.sellAll", { + value: scaleHuman(balance, asset.decimals), + symbol: asset.symbol, + })} + + ) +} diff --git a/apps/main/src/modules/trade/swap/sections/Market/MarketTradeOptions.tsx b/apps/main/src/modules/trade/swap/sections/Market/MarketTradeOptions.tsx index 921d2d992c..0aa292970d 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/MarketTradeOptions.tsx +++ b/apps/main/src/modules/trade/swap/sections/Market/MarketTradeOptions.tsx @@ -1,5 +1,4 @@ -import { Alert, Flex } from "@galacticcouncil/ui/components" -import { DryRunError } from "@galacticcouncil/utils" +import { Flex } from "@galacticcouncil/ui/components" import { useQuery } from "@tanstack/react-query" import Big from "big.js" import { formatDistanceToNowStrict } from "date-fns" @@ -24,18 +23,14 @@ import { scaleHuman } from "@/utils/formatting" type Props = { readonly swap: Trade | undefined - readonly swapDryRunError: DryRunError | null readonly twap: TradeOrder | undefined - readonly twapDryRunError: DryRunError | null readonly isSwapLoading: boolean readonly isTwapLoading: boolean } export const MarketTradeOptions: FC = ({ swap, - swapDryRunError, twap, - twapDryRunError, isSwapLoading, isTwapLoading, }) => { @@ -147,22 +142,6 @@ export const MarketTradeOptions: FC = ({ disabled={!!twap.errors.length} /> )} - {field.value && swapDryRunError && ( - - )} - {!field.value && twapDryRunError && ( - - )} )} /> diff --git a/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummary.tsx b/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummary.tsx index cde45050e7..cb2b45dfa3 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummary.tsx +++ b/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummary.tsx @@ -7,14 +7,11 @@ import { MarketSummarySkeleton } from "@/modules/trade/swap/sections/Market/Summ import { MarketSummarySwap } from "@/modules/trade/swap/sections/Market/Summary/MarketSummarySwap" import { MarketSummaryTwap } from "@/modules/trade/swap/sections/Market/Summary/MarketSummaryTwap" import { SwapSectionSeparator } from "@/modules/trade/swap/SwapPage.styled" -import { AnyTransaction } from "@/modules/transactions/types" type Props = { readonly swapType: TradeType readonly swap: Trade | undefined - readonly swapTx: AnyTransaction | null readonly twap: TradeOrder | undefined - readonly twapTx: AnyTransaction | null readonly healthFactor: HealthFactorResult | undefined readonly isLoading: boolean } @@ -22,9 +19,7 @@ type Props = { export const MarketSummary = ({ swapType, swap, - swapTx, twap, - twapTx, healthFactor, isLoading, }: Props) => { @@ -40,20 +35,14 @@ export const MarketSummary = ({ } if (isSingleTrade) { - return ( - - ) + return } if (twap) { return ( <> - + ) } diff --git a/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummarySwap.tsx b/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummarySwap.tsx index 0939748dec..7a580c9a16 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummarySwap.tsx +++ b/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummarySwap.tsx @@ -20,11 +20,10 @@ import { DynamicFee } from "@/components/DynamicFee" import { SwapSummaryRow } from "@/modules/trade/swap/components/SwapSummaryRow" import { TradeRoutes } from "@/modules/trade/swap/components/TradeRoutes/TradeRoutes" import { MarketFormValues } from "@/modules/trade/swap/sections/Market/lib/useMarketForm" +import { useSwapFee } from "@/modules/trade/swap/sections/Market/lib/useSwapFee" import { CalculatedAmountSummaryRow } from "@/modules/trade/swap/sections/Market/Summary/CalculatedAmountSummaryRow" import { PriceImpactSummaryRow } from "@/modules/trade/swap/sections/Market/Summary/PriceImpactSummaryRow" import { SwapSectionSeparator } from "@/modules/trade/swap/SwapPage.styled" -import { useEstimateFee } from "@/modules/transactions/hooks/useEstimateFee" -import { AnyTransaction } from "@/modules/transactions/types" import { useAssets } from "@/providers/assetsProvider" import { useTradeSettings } from "@/states/tradeSettings" import { scaleHuman } from "@/utils/formatting" @@ -32,15 +31,10 @@ import { getTradeFeeIntervals } from "@/utils/trade" type Props = { readonly swap: Trade - readonly swapTx: AnyTransaction | null readonly healthFactor: HealthFactorResult | undefined } -export const MarketSummarySwap: FC = ({ - swap, - swapTx, - healthFactor, -}) => { +export const MarketSummarySwap: FC = ({ swap, healthFactor }) => { const { t } = useTranslation(["common", "trade"]) const { getAssetWithFallback } = useAssets() @@ -65,7 +59,8 @@ export const MarketSummarySwap: FC = ({ const { watch } = form const [sellAsset, buyAsset] = watch(["sellAsset", "buyAsset"]) - const { data: transactionFee } = useEstimateFee(swapTx) + const { data: transactionFee, isLoading: isTransactionFeeLoading } = + useSwapFee(swap) const transactionCosts = transactionFee?.feeEstimate || "0" const isBuy = swap.type === TradeType.Buy @@ -170,6 +165,7 @@ export const MarketSummarySwap: FC = ({ /> {transactionCostsDisplay} ( diff --git a/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummaryTwap.tsx b/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummaryTwap.tsx index d1366ef4dd..d5da774e13 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummaryTwap.tsx +++ b/apps/main/src/modules/trade/swap/sections/Market/Summary/MarketSummaryTwap.tsx @@ -20,11 +20,10 @@ import { DynamicFee } from "@/components/DynamicFee" import { SwapSummaryRow } from "@/modules/trade/swap/components/SwapSummaryRow" import { TradeRoutes } from "@/modules/trade/swap/components/TradeRoutes/TradeRoutes" import { MarketFormValues } from "@/modules/trade/swap/sections/Market/lib/useMarketForm" +import { useTwapFee } from "@/modules/trade/swap/sections/Market/lib/useTwapFee" import { CalculatedAmountSummaryRow } from "@/modules/trade/swap/sections/Market/Summary/CalculatedAmountSummaryRow" import { PriceImpactSummaryRow } from "@/modules/trade/swap/sections/Market/Summary/PriceImpactSummaryRow" import { SwapSectionSeparator } from "@/modules/trade/swap/SwapPage.styled" -import { useEstimateFee } from "@/modules/transactions/hooks/useEstimateFee" -import { AnyTransaction } from "@/modules/transactions/types" import { useAssets } from "@/providers/assetsProvider" import { useTradeSettings } from "@/states/tradeSettings" import { scaleHuman } from "@/utils/formatting" @@ -33,10 +32,9 @@ import { getTradeFeeIntervals } from "@/utils/trade" type Props = { readonly swap: Trade readonly twap: TradeOrder - readonly twapTx: AnyTransaction | null } -export const MarketSummaryTwap: FC = ({ swap, twap, twapTx }) => { +export const MarketSummaryTwap: FC = ({ swap, twap }) => { const { t } = useTranslation(["common", "trade"]) const { getAssetWithFallback } = useAssets() @@ -61,7 +59,8 @@ export const MarketSummaryTwap: FC = ({ swap, twap, twapTx }) => { const { watch } = form const [sellAsset, buyAsset] = watch(["sellAsset", "buyAsset"]) - const { data: transactionFee } = useEstimateFee(twapTx) + const { data: transactionFee, isLoading: isTransactionFeeLoading } = + useTwapFee(twap) const transactionCosts = transactionFee?.feeEstimate || "0" const isBuy = twap.type === TradeOrderType.TwapBuy @@ -186,6 +185,7 @@ export const MarketSummaryTwap: FC = ({ swap, twap, twapTx }) => { /> {transactionCostsDisplay} ( diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/tradeProvider.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/tradeProvider.ts index d248308fde..d6c7d32ccb 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/tradeProvider.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/tradeProvider.ts @@ -1,16 +1,10 @@ import { HealthFactorResult } from "@galacticcouncil/money-market/utils" -import { DryRunError } from "@galacticcouncil/utils" import { Trade, TradeOrder } from "@/api/trade" -import { AnyTransaction } from "@/modules/transactions/types" export type TradeProviderProps = { readonly swap: Trade | undefined - readonly swapTx: AnyTransaction | null - readonly swapDryRunError: DryRunError | null readonly twap: TradeOrder | undefined - readonly twapTx: AnyTransaction | null - readonly twapDryRunError: DryRunError | null readonly healthFactor: HealthFactorResult | undefined readonly isSwapLoading: boolean readonly isTwapLoading: boolean diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitSwap.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitSwap.ts index 680daf1d44..26f554f84c 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitSwap.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitSwap.ts @@ -1,31 +1,45 @@ +import { useAccount } from "@galacticcouncil/web3-connect" import { useMutation } from "@tanstack/react-query" +import React from "react" import { useTranslation } from "react-i18next" import { toLowerCase } from "remeda" import { Trade, TradeType } from "@/api/trade" import { MarketFormValues } from "@/modules/trade/swap/sections/Market/lib/useMarketForm" -import { AnyTransaction } from "@/modules/transactions/types" +import { MarketSellAllAlert } from "@/modules/trade/swap/sections/Market/MarketSellAllAlert" +import { useRpcProvider } from "@/providers/rpcProvider" +import { useTradeSettings } from "@/states/tradeSettings" import { useTransactionsStore } from "@/states/transactions" import { scaleHuman } from "@/utils/formatting" export const useSubmitSwap = () => { const { t } = useTranslation(["common", "trade"]) + const { sdk } = useRpcProvider() + const { account } = useAccount() + const address = account?.address ?? "" + const { + swap: { + single: { swapSlippage }, + }, + } = useTradeSettings() const { createTransaction } = useTransactionsStore() return useMutation({ - mutationFn: async ([values, swap, tx]: [ + mutationFn: async ([values, swap]: [ MarketFormValues, Trade, - AnyTransaction, ]): Promise => { const { sellAsset, buyAsset } = values const { amountIn, amountOut, type } = swap - const sellDecimals = sellAsset?.decimals ?? 0 - const sellSymbol = sellAsset?.symbol ?? "" - const buyDecimals = buyAsset?.decimals ?? 0 - const buySymbol = buyAsset?.symbol ?? "" + if (!sellAsset) throw new Error("Invalid sell asset") + if (!buyAsset) throw new Error("Invalid buy asset") + + const sellDecimals = sellAsset.decimals + const sellSymbol = sellAsset.symbol + const buyDecimals = buyAsset.decimals + const buySymbol = buyAsset.symbol const params = type === TradeType.Sell @@ -50,8 +64,27 @@ export const useSubmitSwap = () => { }), } + const tx = await sdk.tx + .trade(swap) + .withSlippage(swapSlippage) + .withBeneficiary(address) + .build() + + const isSellAll = tx.name === "RouterSellAll" + await createTransaction({ - tx, + tx: tx.get(), + alerts: isSellAll + ? [ + { + requiresUserConsent: false, + variant: "warning", + description: React.createElement(MarketSellAllAlert, { + asset: sellAsset, + }), + }, + ] + : [], toasts: { submitted: t( `trade:market.swap.${toLowerCase(type)}.loading`, diff --git a/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitTwap.ts b/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitTwap.ts index bb902468c0..76c857cf5c 100644 --- a/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitTwap.ts +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useSubmitTwap.ts @@ -1,28 +1,36 @@ import { TradeOrder } from "@galacticcouncil/sdk-next/build/types/sor" +import { useAccount } from "@galacticcouncil/web3-connect" import { useMutation, useQuery } from "@tanstack/react-query" import { formatDistanceToNow } from "date-fns" import { useTranslation } from "react-i18next" import { blockTimeQuery } from "@/api/chain" import { MarketFormValues } from "@/modules/trade/swap/sections/Market/lib/useMarketForm" -import { AnyTransaction } from "@/modules/transactions/types" import { useRpcProvider } from "@/providers/rpcProvider" +import { useTradeSettings } from "@/states/tradeSettings" import { useTransactionsStore } from "@/states/transactions" import { scaleHuman } from "@/utils/formatting" export const useSubmitTwap = () => { const { t } = useTranslation(["common", "trade"]) const rpc = useRpcProvider() + const { sdk } = rpc + const { account } = useAccount() + const address = account?.address ?? "" + const { + swap: { + split: { twapSlippage, twapMaxRetries }, + }, + } = useTradeSettings() const { createTransaction } = useTransactionsStore() const { data: blockTime } = useQuery(blockTimeQuery(rpc)) return useMutation({ - mutationFn: async ([values, twap, tx]: [ + mutationFn: async ([values, twap]: [ MarketFormValues, TradeOrder, - AnyTransaction, ]): Promise => { const { sellAsset } = values const sellDecimals = sellAsset?.decimals ?? 0 @@ -45,8 +53,15 @@ export const useSubmitTwap = () => { }), } + const tx = await sdk.tx + .order(twap) + .withSlippage(twapSlippage) + .withMaxRetries(twapMaxRetries) + .withBeneficiary(address) + .build() + await createTransaction({ - tx, + tx: tx.get(), toasts: { submitted: t("trade:market.twap.loading", params), success: t("trade:market.twap.success", params), 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 new file mode 100644 index 0000000000..93efd645a5 --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useSwapFee.ts @@ -0,0 +1,31 @@ +import { QUERY_KEY_BLOCK_PREFIX } from "@galacticcouncil/utils" +import { useQuery } from "@tanstack/react-query" + +import { Trade } from "@/api/trade" +import { ENV } from "@/config/env" +import { useEstimateFee } from "@/modules/transactions/hooks/useEstimateFee" +import { useRpcProvider } from "@/providers/rpcProvider" + +export const useSwapFee = (swap: Trade) => { + const { sdk } = useRpcProvider() + 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) + .build() + .then((tx) => tx.get()) + }, + }) + + const { data, isPending: isTransactionFeeLoading } = useEstimateFee( + tx ?? null, + ) + + return { + data, + isLoading: isTxLoading || isTransactionFeeLoading, + } +} 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 new file mode 100644 index 0000000000..f24415155a --- /dev/null +++ b/apps/main/src/modules/trade/swap/sections/Market/lib/useTwapFee.ts @@ -0,0 +1,32 @@ +import { QUERY_KEY_BLOCK_PREFIX } from "@galacticcouncil/utils" +import { useQuery } from "@tanstack/react-query" + +import { TradeOrder } from "@/api/trade" +import { ENV } from "@/config/env" +import { useEstimateFee } from "@/modules/transactions/hooks/useEstimateFee" +import { useRpcProvider } from "@/providers/rpcProvider" + +export const useTwapFee = (twap: TradeOrder) => { + const { sdk } = useRpcProvider() + + 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) + .build() + .then((tx) => tx.get()) + }, + }) + + const { data, isPending: isTransactionFeeLoading } = useEstimateFee( + tx ?? null, + ) + + return { + data, + isLoading: isTxLoading || isTransactionFeeLoading, + } +} diff --git a/apps/main/src/modules/transactions/TransactionManager.tsx b/apps/main/src/modules/transactions/TransactionManager.tsx index 88e6fc3ca3..c6fb0525b7 100644 --- a/apps/main/src/modules/transactions/TransactionManager.tsx +++ b/apps/main/src/modules/transactions/TransactionManager.tsx @@ -2,14 +2,16 @@ import { useProcessTransactionToasts } from "@/modules/transactions/hooks/usePro import { ReviewMultiTransaction } from "@/modules/transactions/review/ReviewMultiTransaction" import { ReviewTransaction } from "@/modules/transactions/review/ReviewTransaction" import { TransactionProvider } from "@/modules/transactions/TransactionProvider" -import { useToasts } from "@/states/toasts" +import { isTransactionToast, useToasts } from "@/states/toasts" import { isMultiTransaction, useTransactionsStore } from "@/states/transactions" export const TransactionManager = () => { const { transactions } = useTransactionsStore() const { toasts } = useToasts() - useProcessTransactionToasts(toasts) + const transactionToasts = toasts.filter(isTransactionToast) + + useProcessTransactionToasts(transactionToasts) return ( <> diff --git a/apps/main/src/modules/transactions/TransactionProvider.tsx b/apps/main/src/modules/transactions/TransactionProvider.tsx index e3442bad6f..8c556e21b5 100644 --- a/apps/main/src/modules/transactions/TransactionProvider.tsx +++ b/apps/main/src/modules/transactions/TransactionProvider.tsx @@ -152,6 +152,7 @@ export const TransactionProvider: React.FC = ({ const signAndSubmit = () => { signAndSubmitMutation.mutate({ chainKey: transaction.meta.srcChainKey, + signerFeeAsset: transaction.signerFeeAsset, feeAssetId, tip, weight: paymentInfo?.weight?.ref_time, diff --git a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts index 69c7ad8ff3..91cd560d4f 100644 --- a/apps/main/src/modules/transactions/hooks/useEstimateFee.ts +++ b/apps/main/src/modules/transactions/hooks/useEstimateFee.ts @@ -4,7 +4,7 @@ import { safeStringify, } from "@galacticcouncil/utils" import { useAccount } from "@galacticcouncil/web3-connect" -import { useQuery } from "@tanstack/react-query" +import { keepPreviousData, useQuery } from "@tanstack/react-query" import Big from "big.js" import { useAccountFeePaymentAssetId } from "@/api/payments" @@ -48,6 +48,7 @@ export const useEstimateFee = ( const tx = anyTx ? transformAnyToPapiTx(papi, anyTx) : null return useQuery({ + placeholderData: keepPreviousData, enabled: isLoaded && !!tx && diff --git a/apps/main/src/modules/transactions/hooks/useProcessTransactionToasts.ts b/apps/main/src/modules/transactions/hooks/useProcessTransactionToasts.ts index b4fa07aa22..99cbe9e90e 100644 --- a/apps/main/src/modules/transactions/hooks/useProcessTransactionToasts.ts +++ b/apps/main/src/modules/transactions/hooks/useProcessTransactionToasts.ts @@ -10,7 +10,11 @@ import { prop } from "remeda" import { useTransactionToastProcessorFn } from "@/modules/transactions/hooks/useTransactionToastProcessorFn" import { useRpcProvider } from "@/providers/rpcProvider" -import { ToastData, useToasts, useToastsStore } from "@/states/toasts" +import { + TransactionToastData, + useToasts, + useToastsStore, +} from "@/states/toasts" import { isBridgeTransaction, TransactionType, @@ -19,7 +23,7 @@ import { const TOAST_STALE_AFTER_MINUTES = 60 -const isPendingOnChainToast = (toast: ToastData) => { +const isPendingOnChainToast = (toast: TransactionToastData) => { return ( toast.variant === "pending" && toast.meta.type === TransactionType.Onchain && @@ -27,15 +31,15 @@ const isPendingOnChainToast = (toast: ToastData) => { ) } -const isSubmittedBridgeToast = (toast: ToastData) => { +const isSubmittedBridgeToast = (toast: TransactionToastData) => { return toast.variant === "submitted" && isBridgeTransaction(toast.meta) } -const isValidToastForProcessing = (toast: ToastData) => { +const isValidToastForProcessing = (toast: TransactionToastData) => { return isPendingOnChainToast(toast) || isSubmittedBridgeToast(toast) } -const isStaleToast = (toast: ToastData) => { +const isStaleToast = (toast: TransactionToastData) => { return ( toast.variant === "pending" && !isValidToastForProcessing(toast) && @@ -44,7 +48,7 @@ const isStaleToast = (toast: ToastData) => { ) } -export const useProcessTransactionToasts = (toasts: ToastData[]) => { +export const useProcessTransactionToasts = (toasts: TransactionToastData[]) => { const { isLoaded } = useRpcProvider() const { edit } = useToasts() const { update } = useToastsStore() diff --git a/apps/main/src/modules/transactions/review/ReviewMultiTransaction.tsx b/apps/main/src/modules/transactions/review/ReviewMultiTransaction.tsx index 2877e7e3ce..e6ab6815fb 100644 --- a/apps/main/src/modules/transactions/review/ReviewMultiTransaction.tsx +++ b/apps/main/src/modules/transactions/review/ReviewMultiTransaction.tsx @@ -1,10 +1,11 @@ import { Modal, + ModalBody, ModalFooter, ModalHeader, Stepper, } from "@galacticcouncil/ui/components" -import { useEffect, useState } from "react" +import React, { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { isFunction, omit } from "remeda" @@ -41,6 +42,7 @@ export const ReviewMultiTransaction: React.FC = ({ const [resolvedTx, setResolvedTx] = useState(null) const [resolvedConfig, setResolvedConfig] = useState(null) + const [isPendingResolution, setIsPendingResolution] = useState(false) const [isLoading, setIsLoading] = useState(false) const [isLastSubmitted, setIsLastSubmitted] = useState(false) const [hasUserClosedModal, setHasUserClosedModal] = useState(false) @@ -63,12 +65,15 @@ export const ReviewMultiTransaction: React.FC = ({ const tx = currentBaseConfig.tx if (isFunction(tx)) { + setIsPendingResolution(true) const previousResults = transactionResults.slice(0, currentIndex) Promise.resolve(tx(previousResults)).then((resolved) => { + setIsPendingResolution(false) setResolvedTx(resolved.tx) setResolvedConfig(omit(resolved, ["tx"])) }) } else { + setIsPendingResolution(false) setResolvedTx(tx) setResolvedConfig(null) } @@ -138,6 +143,9 @@ export const ReviewMultiTransaction: React.FC = ({ const { title, description } = currentConfig + const PendingComponent = + isPendingResolution && currentBaseConfig?.pendingComponent + return ( = ({ title={title ?? t("transaction.title")} description={description ?? t("transaction.description")} /> - + {PendingComponent ? ( + + + + ) : ( + + )} diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionFooter.tsx b/apps/main/src/modules/transactions/review/ReviewTransactionFooter.tsx index 73ca91ad61..9e44eb17de 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionFooter.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionFooter.tsx @@ -1,5 +1,15 @@ -import { Alert, Button, Flex, Stack } from "@galacticcouncil/ui/components" +import { + Alert, + Button, + Flex, + ModalContentDivider, + Stack, + Text, + Toggle, +} from "@galacticcouncil/ui/components" +import { useState } from "react" import { useTranslation } from "react-i18next" +import { isString } from "remeda" import { useTransactionAlerts } from "@/modules/transactions/hooks/useTransactionAlerts" import { ReviewTransactionSubmitButton } from "@/modules/transactions/review/ReviewTransactionSubmitButton" @@ -14,18 +24,68 @@ export const ReviewTransactionFooter: React.FC< > = ({ closable = true }) => { const { t } = useTranslation() - const { onClose, isIdle, isSigning, isSubmitted } = useTransaction() + const { + onClose, + isIdle, + isSigning, + isSubmitted, + alerts: txAlerts, + } = useTransaction() - const { alerts } = useTransactionAlerts() + const { alerts: genericAlerts } = useTransactionAlerts() + + const [consented, setConsented] = useState([]) const isCloseDisabled = isSigning || isSubmitted || !closable + const isConsentPending = !!txAlerts?.some( + (alert, i) => alert.requiresUserConsent && !consented[i], + ) + + const hasGenericAlerts = genericAlerts.length > 0 + const hasTxAlerts = !!txAlerts && txAlerts.length > 0 + const hasAlerts = hasGenericAlerts || hasTxAlerts + if (isIdle) { return ( - {alerts.map(({ key, ...alert }) => ( - - ))} + {hasGenericAlerts + ? genericAlerts.map(({ key, ...alert }) => ( + + )) + : txAlerts?.map((alert, i) => ( + + { + setConsented((prev) => { + const next = [...prev] + next[i] = checked + return next + }) + }} + /> + + {isString(alert.requiresUserConsent) + ? alert.requiresUserConsent + : t("transaction.alert.acceptRisk")} + + + ) : undefined + } + /> + ))} + + {hasAlerts && } + - + ) diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.styled.ts b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.styled.ts index 0defde65a2..8c452be2f0 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.styled.ts +++ b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.styled.ts @@ -51,7 +51,7 @@ export const JsonViewTabsList = styled(TabsList)( display: flex; gap: ${theme.space.m}; - margin-bottom: -${theme.space.m}; + margin-bottom: -${theme.space.s}; padding-block: ${theme.space.base}; border-bottom: 1px solid ${theme.details.borders}; diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.tsx b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.tsx index 4da19e1f36..aca94fc4df 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/ReviewTransactionJsonView.tsx @@ -7,6 +7,7 @@ import { import { getToken } from "@galacticcouncil/ui/utils" import { HYDRATION_CHAIN_KEY } from "@galacticcouncil/utils" import { useTranslation } from "react-i18next" +import { useMeasure } from "react-use" import { usePolkadotJSExtrinsicUrl } from "@/modules/transactions/hooks/usePolkadotJSExtrinsicUrl" import { CallHashText } from "@/modules/transactions/review/ReviewTransactionJsonView/components/CallHashText" @@ -34,6 +35,8 @@ type JsonContentProps = { srcChainKey: string } +const JSON_MAX_HEIGHT = 200 + const ReviewTransactionJsonContent: React.FC< Omit > = ({ tx, srcChainKey }) => { @@ -46,22 +49,25 @@ const ReviewTransactionJsonContent: React.FC< const isValidTxCallHash = !!txCallHash + const [ref, rect] = useMeasure() + + const isJsonOverflowing = rect.height > JSON_MAX_HEIGHT + return ( <> - + {isValidTxCallHash && ( <> - + { } } -export const decodeTx = (tx: AnyTransaction): object => { +export const decodeTx = (tx: AnyTransaction): object | JsonValue => { if (isPapiTransaction(tx)) { - return safeParse(safeStringify(tx.decodedCall)) + const txJson = safeStringify(tx.decodedCall) + try { + return formatTypeValueJson(safeParse(txJson)) + } catch { + return safeParse(txJson) + } } if (isEvmCall(tx)) { diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/CallHashText.tsx b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/CallHashText.tsx index d2a6fee674..bc02e99fc7 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/CallHashText.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/CallHashText.tsx @@ -10,13 +10,14 @@ export const CallHashText: React.FC = ({ hash }) => { const chunks = hash.split(/(0{3,})/g) return ( - + {chunks.map((str, index) => ( = ({ diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.styled.ts b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.styled.ts index 9aca81fde4..85e93badf1 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.styled.ts +++ b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.styled.ts @@ -3,29 +3,34 @@ import { css, styled } from "@galacticcouncil/ui/utils" export const ExpandableContainer = styled(Box)` overflow: hidden; - padding-left: 14px; + padding-left: ${({ theme }) => theme.space.tertiary}; ` export const ExpandButton = styled.button( ({ theme }) => css` position: absolute; - font-size: 12px; + font-size: ${theme.fontSizes.p6}; + font-weight: 600; cursor: pointer; text-align: center; color: ${theme.buttons.primary.medium.rest}; text-decoration: underline; + box-sizing: content-box; + + width: 100%; + height: 2rem; + + padding-top: 2rem; bottom: 0; left: 0; right: 0; - padding-top: 50px; - background-image: linear-gradient( 180deg, transparent 0%, - ${theme.surfaces.containers.high.hover} 100% + ${theme.surfaces.containers.high.hover} 70% ); :hover { diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.tsx b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.tsx index 0e3174bca5..bb38c0a0f8 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionJsonView/components/ExpandableSection.tsx @@ -7,7 +7,7 @@ import { } from "@galacticcouncil/ui/components" import { ThemeUICSSProperties } from "@galacticcouncil/ui/types" import { getToken } from "@galacticcouncil/ui/utils" -import { useState } from "react" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { ExpandableContainer, ExpandButton } from "./ExpandableSection.styled" @@ -30,21 +30,24 @@ export const ExpandableSection: React.FC = ({ maxContentHeight === "100%", ) + useEffect(() => { + setIsContentExpanded(maxContentHeight === "100%") + }, [maxContentHeight]) + const shouldRenderExpandButton = isSectionExpanded && !isContentExpanded return ( - + setIsSectionExpanded((prev) => !prev)}> diff --git a/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx b/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx index c5028c5b10..5aff320d7a 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionSubmitButton.tsx @@ -15,7 +15,13 @@ import { import { useTransaction } from "@/modules/transactions/TransactionProvider" import { TransactionType } from "@/states/transactions" -export const ReviewTransactionSubmitButton = () => { +type ReviewTransactionSubmitButtonProps = { + disabled?: boolean +} + +export const ReviewTransactionSubmitButton = ({ + disabled, +}: ReviewTransactionSubmitButtonProps) => { const { t } = useTranslation() const { account } = useAccount() @@ -69,7 +75,7 @@ export const ReviewTransactionSubmitButton = () => { const isLoading = isSigning || isLoadingFeeEstimate || isChangingFeePaymentAsset - const isDisabled = isSigningBlocked || hasAlerts || isLoading + const isDisabled = disabled || isSigningBlocked || hasAlerts || isLoading return ( = async (tx, signer, options) => { + const signerFeeAsset = options.signerFeeAsset + ? getParachainFeeAssetLocation(options.chainKey, options.signerFeeAsset) + : null + const observer = tx .signSubmitAndWatch(signer, { nonce: options?.nonce, tip: options?.tip, mortality: { mortal: true, period: options.mortalityPeriod }, + ...(signerFeeAsset && { asset: signerFeeAsset }), }) .pipe( catchError((error) => of({ type: "error" as const, error })), diff --git a/apps/main/src/modules/transactions/utils/toasts/index.ts b/apps/main/src/modules/transactions/utils/toasts/index.ts index 7b8353afc1..e46cc8f511 100644 --- a/apps/main/src/modules/transactions/utils/toasts/index.ts +++ b/apps/main/src/modules/transactions/utils/toasts/index.ts @@ -9,7 +9,7 @@ import { processors, ToastProcessorFn, } from "@/modules/transactions/utils/toasts/processors" -import { ToastData, ToastMeta } from "@/states/toasts" +import { TransactionToastData } from "@/states/toasts" import { TransactionType, XcmTag } from "@/states/transactions" enum ToastProcessorType { @@ -28,7 +28,7 @@ const MAX_TOAST_MINUTE_AGE = { [ToastProcessorType.Unknown]: 0, } as const -const REQUIRED_TOAST_META_KEYS: (keyof ToastMeta)[] = [ +const REQUIRED_TOAST_META_KEYS: (keyof TransactionToastData["meta"])[] = [ "type", "txHash", "ecosystem", @@ -36,7 +36,7 @@ const REQUIRED_TOAST_META_KEYS: (keyof ToastMeta)[] = [ const validateToastForProcessing = ( type: ToastProcessorType, - toast: ToastData, + toast: TransactionToastData, ): boolean => { if (REQUIRED_TOAST_META_KEYS.some((key) => !toast.meta[key])) { return false @@ -50,7 +50,9 @@ const validateToastForProcessing = ( return toastAgeInMinutes < MAX_TOAST_MINUTE_AGE[type] } -const getToastProcessorType = (toast: ToastData): ToastProcessorType => { +const getToastProcessorType = ( + toast: TransactionToastData, +): ToastProcessorType => { const { type, ecosystem } = toast.meta const tags = type === TransactionType.Xcm ? (toast.meta.tags ?? []) : [] diff --git a/apps/main/src/modules/transactions/utils/toasts/processors.ts b/apps/main/src/modules/transactions/utils/toasts/processors.ts index 0840309e9e..b15bf929d4 100644 --- a/apps/main/src/modules/transactions/utils/toasts/processors.ts +++ b/apps/main/src/modules/transactions/utils/toasts/processors.ts @@ -22,7 +22,7 @@ import { first } from "remeda" import { PublicClient } from "viem" import { getWormholeHashByExtrinsicIndex } from "@/modules/transactions/utils/wormhole" -import { ToastData } from "@/states/toasts" +import { TransactionToastData } from "@/states/toasts" type ToastStatus = { processed: boolean @@ -31,7 +31,9 @@ type ToastStatus = { link?: string } -export type ToastProcessorFn = (toast: ToastData) => Promise +export type ToastProcessorFn = ( + toast: TransactionToastData, +) => Promise const invalid = (): ToastProcessorFn => async (toast) => Promise.resolve({ diff --git a/apps/main/src/modules/wallet/assets/Rewards/WalletRewardsSection.tsx b/apps/main/src/modules/wallet/assets/Rewards/WalletRewardsSection.tsx index 202c1cbe03..952a8b8579 100644 --- a/apps/main/src/modules/wallet/assets/Rewards/WalletRewardsSection.tsx +++ b/apps/main/src/modules/wallet/assets/Rewards/WalletRewardsSection.tsx @@ -84,11 +84,6 @@ export const WalletRewardsSection: FC = () => { value={referralsDisplay} isLoading={referral.loading} /> - {referral.isEmpty && !referral.loading && ( - - {t("rewards.referrals.empty")} - - )} - {/* + - */} (journey.stops) + : undefined + + if (!Array.isArray(stops)) return undefined return stops.find(isWormholeStop) } diff --git a/apps/main/src/modules/xcm/history/utils/journey.ts b/apps/main/src/modules/xcm/history/utils/journey.ts index c70a837ffa..935be8c7e5 100644 --- a/apps/main/src/modules/xcm/history/utils/journey.ts +++ b/apps/main/src/modules/xcm/history/utils/journey.ts @@ -10,6 +10,34 @@ import { XcJourney } from "@galacticcouncil/xc-scan" export type TJourneyStatus = XcJourney["status"] +export type XcJourneyWhVAAInstruction = { + type: "WormholeVAA" + value: { + raw: string + guardianSetIndex: number + isDuplicated: boolean + } +} +export type XcJourneyWhStop = { + type: "wormhole" + from: object + to: object + relay?: object + instructions: XcJourneyWhVAAInstruction + messageId?: string +} + +export type XcJourneyGenericStop = { + type: string + from: object + to: object + relay?: object + instructions: object[] + messageId?: string +} + +export type XcJourneyStop = XcJourneyGenericStop | XcJourneyWhStop + export const PENDING_STATUSES: TJourneyStatus[] = ["sent", "pending"] export const WAITING_STATUSES: TJourneyStatus[] = ["waiting"] export const SUCCESS_STATUSES: TJourneyStatus[] = [ diff --git a/apps/main/src/modules/xcm/history/utils/optimistic.ts b/apps/main/src/modules/xcm/history/utils/optimistic.ts index fa9c8e85a7..6ba2fa53d5 100644 --- a/apps/main/src/modules/xcm/history/utils/optimistic.ts +++ b/apps/main/src/modules/xcm/history/utils/optimistic.ts @@ -64,9 +64,9 @@ export function convertXcmFormValuesToOptimisticJourney( toFormatted: destAddress, sentAt: now, createdAt: now, - stops: [], - instructions: {}, - transactCalls: [], + stops: "", + instructions: "", + transactCalls: "", originTxPrimary: txHash, totalUsd: 0, assets: [ diff --git a/apps/main/src/modules/xcm/transfer/XcmForm.tsx b/apps/main/src/modules/xcm/transfer/XcmForm.tsx index 1458b14b7b..81b35429e3 100644 --- a/apps/main/src/modules/xcm/transfer/XcmForm.tsx +++ b/apps/main/src/modules/xcm/transfer/XcmForm.tsx @@ -46,6 +46,7 @@ export const XcmForm = () => { const handleChainSwitch = useChainSwitch() const { + alerts, status, transfer, dryRunError, @@ -281,13 +282,24 @@ export const XcmForm = () => { - - {dryRunError && ( - + + {(alerts.length > 0 || dryRunError) && ( + + {dryRunError && ( + + )} + {alerts.map((alert) => ( + + ))} + )} { const { t } = useTranslation(["common", "xcm"]) - const { transfer, call, alerts, isLoading } = useXcmProvider() + const { transfer, call, isLoading } = useXcmProvider() const { formState, watch } = useFormContext() @@ -79,51 +77,34 @@ export const XcmSummary = () => { }) })() - const isTransferValid = !!transfer && formState.isValid && !alerts.length - const isSummaryOpen = isTransferValid || alerts.length > 0 + const isTransferValid = !!transfer && formState.isValid return ( - + - {alerts.length > 0 && ( - <> - - - {alerts.map((alert) => ( - - ))} - - - )} - {isTransferValid && ( - } - px="xl" - withLeadingSeparator - > - {call && isEvmSourceChain && isEvmApproveCall(call) && ( - - )} + } + px="xl" + withLeadingSeparator + > + {call && isEvmSourceChain && isEvmApproveCall(call) && ( - - - )} + )} + + + ) diff --git a/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts b/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts index 8084b8834c..fb004e6858 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts @@ -1,22 +1,18 @@ -import { - HexString, - HYDRATION_CHAIN_KEY, - isEvmChain, - isParachain, -} from "@galacticcouncil/utils" +import { HexString, invariant, isEvmChain } from "@galacticcouncil/utils" import { useAccount } from "@galacticcouncil/web3-connect" -import { AnyChain, ConfigBuilder } from "@galacticcouncil/xc-core" -import { Call, Transfer } from "@galacticcouncil/xc-sdk" +import { ConfigBuilder } from "@galacticcouncil/xc-core" +import { Transfer } from "@galacticcouncil/xc-sdk" import { useMutation } from "@tanstack/react-query" -import { Binary } from "polkadot-api" import { useTranslation } from "react-i18next" import { useCrossChainConfigService } from "@/api/xcm" -import { AnyPapiTx } from "@/modules/transactions/types" import { isEvmApproveCall, isEvmCall } from "@/modules/transactions/utils/xcm" import { useApprovalTrackingStore } from "@/modules/xcm/transfer/hooks/useApprovalTrackingStore" import { XcmFormValues } from "@/modules/xcm/transfer/hooks/useXcmFormSchema" -import { buildTransferCall } from "@/modules/xcm/transfer/utils/transfer" +import { + assertTransferValues, + buildXcmTx, +} from "@/modules/xcm/transfer/utils/transfer" import { useRpcProvider } from "@/providers/rpcProvider" import { TransactionActions, @@ -55,13 +51,10 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { return useMutation({ mutationFn: async ([values, transfer]: [XcmFormValues, Transfer]) => { - const { srcAmount, srcChain, destChain, srcAsset, destAsset } = values + invariant(account, "Account is required") - if (!account) throw new Error("Account is required") - if (!destChain) throw new Error("Destination chain is required") - if (!srcChain) throw new Error("Source chain is required") - if (!srcAsset) throw new Error("Source asset is required") - if (!destAsset) throw new Error("Destination asset is required") + const { srcAmount, srcChain, destChain, srcAsset, destAsset } = + assertTransferValues(values) const { destination, source } = transfer @@ -84,18 +77,7 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { const isApprove = isEvmApproveCall(call) const buildTransferTransaction = async () => { - const call = await transfer.buildCall(srcAmount) - const transferCall = await buildTransferCall( - call, - transfer, - srcChain, - srcAmount, - ) - - const tx = - srcChain.key === HYDRATION_CHAIN_KEY - ? await papi.txFromCallData(Binary.fromHex(transferCall.data)) - : await getExternalChainTx(srcChain, transferCall) + const tx = await buildXcmTx(srcChain, transfer, srcAmount, papi) return { title: t("form.title"), description: t("tx.description", i18nVars), @@ -213,13 +195,3 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { }, }) } - -async function getExternalChainTx( - chain: AnyChain, - call: Call, -): Promise { - if (!isParachain(chain)) { - return call - } - return chain.client.getUnsafeApi().txFromCallData(Binary.fromHex(call.data)) -} diff --git a/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts b/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts index bb2a35c46e..93b1d27b0a 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts @@ -4,15 +4,28 @@ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema" import { useEffect } from "react" import { useForm } from "react-hook-form" -import { useXcmFormSchema } from "@/modules/xcm/transfer/hooks/useXcmFormSchema" +import { + useXcmFormSchema, + XcmFormValues, +} from "@/modules/xcm/transfer/hooks/useXcmFormSchema" import { useXcmQueryParams } from "@/modules/xcm/transfer/hooks/useXcmQueryParams" import { getXcmFormDefaults } from "@/modules/xcm/transfer/utils/chain" -export const useXcmForm = (transfer: Transfer | null) => { +type UseXcmFormOptions = { + syncWithQueryParams?: boolean + defaultValues?: Partial +} + +export const useXcmForm = ( + transfer: Transfer | null, + options?: UseXcmFormOptions, +) => { const { account } = useAccount() + const { syncWithQueryParams = true, defaultValues } = options ?? {} + const { parsedQueryParams, updateQueryParams } = useXcmQueryParams() - const defaults = { + const defaults = defaultValues || { ...getXcmFormDefaults(account), ...parsedQueryParams, } @@ -43,13 +56,21 @@ export const useXcmForm = (transfer: Transfer | null) => { ]) useEffect(() => { + if (!syncWithQueryParams) return updateQueryParams({ srcChain: srcChain?.key, srcAsset: srcAsset?.key, destChain: destChain?.key, destAsset: destAsset?.key, }) - }, [destAsset, destChain, srcAsset, srcChain, updateQueryParams]) + }, [ + syncWithQueryParams, + destAsset, + destChain, + srcAsset, + srcChain, + updateQueryParams, + ]) return form } diff --git a/apps/main/src/modules/xcm/transfer/utils/chain.ts b/apps/main/src/modules/xcm/transfer/utils/chain.ts index 32daef58e5..37a68d29a4 100644 --- a/apps/main/src/modules/xcm/transfer/utils/chain.ts +++ b/apps/main/src/modules/xcm/transfer/utils/chain.ts @@ -92,10 +92,13 @@ export const getXcmFormDefaults = (account: Account | null): XcmFormValues => { return { srcChain, srcAsset, - srcAmount: "", + destChain, destAsset: srcAsset, + + srcAmount: "", destAmount: "", + destAddress: destAccount?.rawAddress ?? "", destAccount: destAccount, } diff --git a/apps/main/src/modules/xcm/transfer/utils/transfer.ts b/apps/main/src/modules/xcm/transfer/utils/transfer.ts index c88fda197f..6ac7e6b15a 100644 --- a/apps/main/src/modules/xcm/transfer/utils/transfer.ts +++ b/apps/main/src/modules/xcm/transfer/utils/transfer.ts @@ -2,7 +2,10 @@ import { formatDestChainAddress, formatSourceChainAddress, HexString, + HYDRATION_CHAIN_KEY, + invariant, isEvmChain, + isParachain, } from "@galacticcouncil/utils" import { Account } from "@galacticcouncil/web3-connect" import { AnyChain, Asset, AssetRoute } from "@galacticcouncil/xc-core" @@ -10,12 +13,15 @@ import { Call, Transfer } from "@galacticcouncil/xc-sdk" import Big from "big.js" import { minutesToMilliseconds } from "date-fns" import waitFor from "p-wait-for" +import { Binary } from "polkadot-api" import { XcmTransferArgs } from "@/api/xcm" +import { AnyPapiTx } from "@/modules/transactions/types" import { isEvmApproveCall } from "@/modules/transactions/utils/xcm" import { useApprovalTrackingStore } from "@/modules/xcm/transfer/hooks/useApprovalTrackingStore" import { XcmFormValues } from "@/modules/xcm/transfer/hooks/useXcmFormSchema" import { XcmAlert } from "@/modules/xcm/transfer/hooks/useXcmProvider" +import { Papi } from "@/providers/rpcProvider" import { XCM_BRIDGE_TAGS, XcmTags } from "@/states/transactions" import { toDecimal } from "@/utils/formatting" @@ -134,3 +140,45 @@ export const buildTransferCall = async ( }, ) } + +export async function getExternalChainTx( + chain: AnyChain, + call: Call, +): Promise { + if (!isParachain(chain)) return call + return chain.client.getUnsafeApi().txFromCallData(Binary.fromHex(call.data)) +} + +export async function buildXcmTx( + srcChain: AnyChain, + transfer: Transfer, + srcAmount: string, + papi: Papi, +): Promise { + const call = await transfer.buildCall(srcAmount) + const transferCall = await buildTransferCall( + call, + transfer, + srcChain, + srcAmount, + ) + return srcChain.key === HYDRATION_CHAIN_KEY + ? await papi.txFromCallData(Binary.fromHex(transferCall.data)) + : await getExternalChainTx(srcChain, transferCall) +} + +export function assertTransferValues(values: XcmFormValues) { + const { srcAmount, srcChain, srcAsset, destChain, destAsset } = values + invariant(srcAmount, "Source amount is required") + invariant(destChain, "Destination chain is required") + invariant(srcChain, "Source chain is required") + invariant(srcAsset, "Source asset is required") + invariant(destAsset, "Destination asset is required") + return { + ...values, + srcChain, + srcAsset, + destChain, + destAsset, + } +} diff --git a/apps/main/src/routes/$referralCode.tsx b/apps/main/src/routes/$referralCode.tsx new file mode 100644 index 0000000000..7b09303872 --- /dev/null +++ b/apps/main/src/routes/$referralCode.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, Navigate, useParams } from "@tanstack/react-router" + +function ReferralEntryPoint() { + useParams({ from: "/$referralCode" }) + + return +} + +export const Route = createFileRoute("/$referralCode")({ + component: ReferralEntryPoint, +}) diff --git a/apps/main/src/routes/$referralCode/index.tsx b/apps/main/src/routes/$referralCode/index.tsx new file mode 100644 index 0000000000..8956304824 --- /dev/null +++ b/apps/main/src/routes/$referralCode/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, Navigate, useParams } from "@tanstack/react-router" + +function ReferralEntryPoint() { + useParams({ from: "/$referralCode/" }) + + return +} + +export const Route = createFileRoute("/$referralCode/")({ + component: ReferralEntryPoint, +}) diff --git a/apps/main/src/routes/deposit/index.tsx b/apps/main/src/routes/deposit/index.tsx new file mode 100644 index 0000000000..c040a13134 --- /dev/null +++ b/apps/main/src/routes/deposit/index.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { getPageMeta } from "@/config/navigation" +import { DepositManager } from "@/modules/onramp/deposit/DepositManager" +import { DepositPage } from "@/modules/onramp/deposit/DepositPage" + +export const Route = createFileRoute("/deposit/")({ + component: () => ( + <> + + + + ), + head: ({ + match: { + context: { i18n }, + }, + }) => ({ + meta: getPageMeta("deposit", i18n.t), + }), +}) diff --git a/apps/main/src/routes/deposit/route.tsx b/apps/main/src/routes/deposit/route.tsx new file mode 100644 index 0000000000..7a654b6f40 --- /dev/null +++ b/apps/main/src/routes/deposit/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { SubpageLayout } from "@/modules/layout/SubpageLayout" + +export const Route = createFileRoute("/deposit")({ + component: SubpageLayout, +}) diff --git a/apps/main/src/routes/referrals.tsx b/apps/main/src/routes/referrals.tsx deleted file mode 100644 index b91a0ab417..0000000000 --- a/apps/main/src/routes/referrals.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router" - -export const Route = createFileRoute("/referrals")({ - component: RouteComponent, -}) - -function RouteComponent() { - return
N/A
-} diff --git a/apps/main/src/routes/withdraw/index.tsx b/apps/main/src/routes/withdraw/index.tsx new file mode 100644 index 0000000000..78d9286105 --- /dev/null +++ b/apps/main/src/routes/withdraw/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { WithdrawPage } from "@/modules/onramp/withdraw/WithdrawPage" + +export const Route = createFileRoute("/withdraw/")({ + component: WithdrawPage, +}) diff --git a/apps/main/src/routes/withdraw/route.tsx b/apps/main/src/routes/withdraw/route.tsx new file mode 100644 index 0000000000..870bf887fd --- /dev/null +++ b/apps/main/src/routes/withdraw/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { SubpageLayout } from "@/modules/layout/SubpageLayout" + +export const Route = createFileRoute("/withdraw")({ + component: SubpageLayout, +}) diff --git a/apps/main/src/states/toasts.tsx b/apps/main/src/states/toasts.tsx index ab61f24274..6dd8c7e2ff 100644 --- a/apps/main/src/states/toasts.tsx +++ b/apps/main/src/states/toasts.tsx @@ -3,10 +3,11 @@ import { Notification, ToastVariant, } from "@galacticcouncil/ui/components" -import { uuid } from "@galacticcouncil/utils" +import { hasOwn, uuid } from "@galacticcouncil/utils" import { useAccount } from "@galacticcouncil/web3-connect" import { CallType } from "@galacticcouncil/xc-core" import { useCallback } from "react" +import { isObjectType } from "remeda" import { toast as toastSonner } from "sonner" import { create } from "zustand" import { persist } from "zustand/middleware" @@ -16,6 +17,10 @@ import { TransactionMeta } from "@/states/transactions" export const TOAST_MESSAGES = ["onLoading", "onSuccess", "onError"] as const +export type TransactionToastData = ToastData & { + meta: ToastMeta +} + export type ToastMeta = TransactionMeta & { txHash: string ecosystem: CallType @@ -30,7 +35,7 @@ type ToastParams = { persist?: boolean address?: string hint?: string - meta: ToastMeta + meta?: ToastMeta } export type ToastData = ToastParams & { @@ -199,3 +204,15 @@ export const useToasts = () => { unknown, } } + +export function isTransactionToast( + toast: ToastData, +): toast is TransactionToastData { + return ( + hasOwn(toast, "meta") && + isObjectType(toast.meta) && + !!toast.meta.txHash && + !!toast.meta.ecosystem && + !!toast.meta.type + ) +} diff --git a/apps/main/src/states/transactions.ts b/apps/main/src/states/transactions.ts index e40afcbd8d..a73904d1df 100644 --- a/apps/main/src/states/transactions.ts +++ b/apps/main/src/states/transactions.ts @@ -1,7 +1,10 @@ +import { AlertProps } from "@galacticcouncil/ui/components" import { HYDRATION_CHAIN_KEY, uuid } from "@galacticcouncil/utils" import { SolanaTxStatus } from "@galacticcouncil/web3-connect/src/signers/SolanaSigner" import { SuiTxStatus } from "@galacticcouncil/web3-connect/src/signers/SuiSigner" import { tags } from "@galacticcouncil/xc-cfg" +import { Asset } from "@galacticcouncil/xc-core" +import { ComponentType } from "react" import { TransactionReceipt } from "viem" import { create } from "zustand" @@ -22,15 +25,24 @@ export enum TransactionType { EvmApprove = "EvmApprove", } +export type TransactionAlert = Pick< + AlertProps, + "variant" | "title" | "description" +> & { + requiresUserConsent?: boolean | string +} + export type TransactionCommon = { title?: string description?: string fee?: TransactionFee toasts?: TransactionToasts meta?: TransactionMeta + signerFeeAsset?: Asset invalidateQueries?: string[][] withExtraGas?: boolean | bigint isUnsigned?: boolean + alerts?: TransactionAlert[] } interface SingleTransactionInput extends TransactionCommon { @@ -48,6 +60,7 @@ type MultiTransactionConfig = ( | SingleTransactionInputDynamic ) & { stepTitle: string + pendingComponent?: ComponentType //@TODO consider separate all transaction actions per tx onSubmitted?: (txHash: string) => void } diff --git a/apps/main/src/utils/externalAssets.ts b/apps/main/src/utils/externalAssets.ts index 478121332a..a9096dbfd8 100644 --- a/apps/main/src/utils/externalAssets.ts +++ b/apps/main/src/utils/externalAssets.ts @@ -6,11 +6,13 @@ import { import { Asset } from "@galacticcouncil/sdk-next" import { isAnyParachain } from "@galacticcouncil/utils" import { chainsMap } from "@galacticcouncil/xc-cfg" -import { AnyChain, AnyParachain, Parachain } from "@galacticcouncil/xc-core" +import { AnyChain, AnyParachain } from "@galacticcouncil/xc-core" import { Buffer } from "buffer" import { FixedSizeBinary } from "polkadot-api" import { TAssetData } from "@/api/assets" +import { assethub } from "@/api/external/assethub" +import { pendulum } from "@/api/external/pendulum" export const ASSETHUB_ID_BLACKLIST = [ "34", @@ -41,9 +43,6 @@ export const ASSETHUB_ID_BLACKLIST = [ "50000034", ] -export const assethub = chainsMap.get("assethub") as Parachain -export const pendulum = chainsMap.get("pendulum") as Parachain - const chains = Array.from(chainsMap.values()) export const getAssetOrigin = (asset: TAssetData): AnyChain | null => { diff --git a/package.json b/package.json index ef16d7483f..b7deeb3d96 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,14 @@ "strip-ansi": "6.0.1" }, "dependencies": { - "@galacticcouncil/common": "^0.5.1", - "@galacticcouncil/descriptors": "^1.14.0", - "@galacticcouncil/sdk-next": "^0.37.0", - "@galacticcouncil/xc": "^0.4.0", - "@galacticcouncil/xc-cfg": "^0.17.0", - "@galacticcouncil/xc-core": "^0.12.0", - "@galacticcouncil/xc-scan": "^0.3.0", - "@galacticcouncil/xc-sdk": "^0.9.0", + "@galacticcouncil/common": "^0.6.0", + "@galacticcouncil/descriptors": "^1.15.0", + "@galacticcouncil/sdk-next": "^0.38.0", + "@galacticcouncil/xc": "^0.5.0", + "@galacticcouncil/xc-cfg": "^0.18.1", + "@galacticcouncil/xc-core": "^0.13.0", + "@galacticcouncil/xc-scan": "^0.4.0", + "@galacticcouncil/xc-sdk": "^0.9.1", "big.js": "^6.2.2", "date-fns": "^4.1.0", "immer": "^10.0.3", diff --git a/packages/indexer/src/squid/codegen.ts b/packages/indexer/src/squid/codegen.ts index 91f039c060..b5bb0698cd 100644 --- a/packages/indexer/src/squid/codegen.ts +++ b/packages/indexer/src/squid/codegen.ts @@ -1,8 +1,7 @@ import { CodegenConfig } from "@graphql-codegen/cli" export default { - schema: - "https://galacticcouncil.squids.live/hydration-pools:orca-prod/api/graphql", + schema: "https://orca-main-aggr-indx.indexer.hydration.cloud/graphql", overwrite: true, config: { preResolveTypes: true, diff --git a/packages/ui/src/assets/icons/BinanceLogo.svg b/packages/ui/src/assets/icons/BinanceLogo.svg new file mode 100644 index 0000000000..3353324390 --- /dev/null +++ b/packages/ui/src/assets/icons/BinanceLogo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/CoinbaseLogo.svg b/packages/ui/src/assets/icons/CoinbaseLogo.svg new file mode 100644 index 0000000000..5a9f661adb --- /dev/null +++ b/packages/ui/src/assets/icons/CoinbaseLogo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/GateioLogo.svg b/packages/ui/src/assets/icons/GateioLogo.svg new file mode 100644 index 0000000000..3e9c176a53 --- /dev/null +++ b/packages/ui/src/assets/icons/GateioLogo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/KrakenLogo.svg b/packages/ui/src/assets/icons/KrakenLogo.svg new file mode 100644 index 0000000000..7eed00348a --- /dev/null +++ b/packages/ui/src/assets/icons/KrakenLogo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/KucoinLogo.svg b/packages/ui/src/assets/icons/KucoinLogo.svg new file mode 100644 index 0000000000..9debdc3804 --- /dev/null +++ b/packages/ui/src/assets/icons/KucoinLogo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts index 757631cb5a..4d6ed86dbc 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -1,19 +1,24 @@ export { default as ArrowRightLong } from "./ArrowRightLong.svg?react" export { default as AssetIcon } from "./AssetIcon.svg?react" +export { default as BinanceLogo } from "./BinanceLogo.svg?react" export { default as CaretDown } from "./CaretDown.svg?react" export { default as CircleAlert } from "./CircleAlert.svg?react" export { default as CircleCheck } from "./CircleCheck.svg?react" export { default as CircleClose } from "./CircleClose.svg?react" export { default as CircleInfo } from "./CircleInfo.svg?react" export { default as Close } from "./Close.svg?react" +export { default as CoinbaseLogo } from "./CoinbaseLogo.svg?react" export { default as CrosshairDot } from "./CrosshairDot.svg?react" export { default as Distribution } from "./Distribution.svg?react" export { default as Ellipse } from "./Ellipse.svg?react" export { default as ExclamationMark } from "./ExclamationMark.svg?react" export { default as Farm } from "./Farm.svg?react" +export { default as GateioLogo } from "./GateioLogo.svg?react" export { default as HydrationLogo } from "./HydrationLogo.svg?react" export { default as HydrationLogoFull } from "./HydrationLogoFull.svg?react" export { default as IconPlaceholder } from "./IconPlaceholder.svg?react" +export { default as KrakenLogo } from "./KrakenLogo.svg?react" +export { default as KucoinLogo } from "./KucoinLogo.svg?react" export { default as LiquidityIcon } from "./LiquidityIcon.svg?react" export { default as MenuSlanted } from "./MenuSlanted.svg?react" export { default as PartialFill } from "./PartialFill.svg?react" diff --git a/packages/ui/src/components/AccountTile/AccountTile.tsx b/packages/ui/src/components/AccountTile/AccountTile.tsx index 0198f3ab50..5778e02552 100644 --- a/packages/ui/src/components/AccountTile/AccountTile.tsx +++ b/packages/ui/src/components/AccountTile/AccountTile.tsx @@ -17,6 +17,7 @@ type Props = { readonly label?: string readonly className?: string readonly walletLogoSrc?: string + readonly shortenAddress?: boolean readonly onClick?: () => void } & FlexProps @@ -29,6 +30,7 @@ export const AccountTile: FC = ({ className, walletLogoSrc, onClick, + shortenAddress = true, ...props }) => { return ( @@ -50,7 +52,7 @@ export const AccountTile: FC = ({ isInteractive={!!onClick} > - + {walletLogoSrc && } = ({ - - {shortenAccountAddress(address)} + + {shortenAddress ? shortenAccountAddress(address) : address} diff --git a/packages/ui/src/components/AssetInput/AssetInput.tsx b/packages/ui/src/components/AssetInput/AssetInput.tsx index e023167ae6..ca2a7c62dc 100644 --- a/packages/ui/src/components/AssetInput/AssetInput.tsx +++ b/packages/ui/src/components/AssetInput/AssetInput.tsx @@ -3,7 +3,15 @@ import Big from "big.js" import { ChevronDown } from "lucide-react" import { ReactNode } from "react" -import { Flex, Icon, MicroButton, Skeleton, Text } from "@/components" +import { + Flex, + FormLabel, + Icon, + LogoSkeleton, + MicroButton, + Skeleton, + Text, +} from "@/components" import { FormError } from "@/components/FormError" import { getToken } from "@/utils" @@ -79,20 +87,7 @@ export const AssetInput = ({ className={className} > - {label && ( - - {label} - - )} + {label && {label}} {!ignoreBalance && ( { if (loading) return ( - -
- -
-
- -
+ + + ) diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index cc6e38dab0..b6932b30db 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -36,6 +36,17 @@ const VariantTemplate = (args: Story["args"]) => ( Button
+ + + + +
) @@ -71,6 +82,13 @@ export const Tertiary: Story = { }, } +export const Success: Story = { + render: VariantTemplate, + args: { + variant: "success", + }, +} + export const Danger: Story = { render: VariantTemplate, args: { diff --git a/packages/ui/src/components/Button/Button.styled.ts b/packages/ui/src/components/Button/Button.styled.ts index 048cfcfae3..8325649f85 100644 --- a/packages/ui/src/components/Button/Button.styled.ts +++ b/packages/ui/src/components/Button/Button.styled.ts @@ -26,6 +26,7 @@ export type SButtonProps = { variant?: ButtonVariant size?: ButtonSize outline?: boolean + glow?: boolean } const defaulStyles = createStyles( @@ -109,6 +110,12 @@ const outlineVariantStyles = ( `} ` +const blowVariantStyles = (border: string, glow: string) => css` + box-shadow: + inset 0 0 0 1px ${border}, + 0 0 10px 2px ${glow}; +` + const disabledStyles = css` &:disabled, &[aria-disabled="true"] { @@ -273,6 +280,45 @@ const outlineVariants = createVariants((theme) => ({ ), })) +const glowVariants = createVariants((theme) => ({ + primary: blowVariantStyles( + theme.buttons.primary.high.rest, + theme.buttons.primary.high.dim, + ), + secondary: blowVariantStyles( + theme.buttons.primary.medium.rest, + theme.buttons.primary.medium.outlineHover, + ), + tertiary: blowVariantStyles( + theme.buttons.secondary.low.borderRest, + theme.buttons.secondary.low.hover, + ), + danger: blowVariantStyles( + theme.buttons.secondary.danger.onRest, + theme.buttons.secondary.danger.hover, + ), + emphasis: blowVariantStyles( + theme.buttons.secondary.emphasis.onRest, + theme.buttons.secondary.emphasis.hover, + ), + accent: blowVariantStyles( + theme.buttons.secondary.accent.onRest, + theme.buttons.secondary.accent.hover, + ), + success: blowVariantStyles( + theme.accents.success.onEmphasis, + theme.accents.success.emphasis, + ), + muted: blowVariantStyles( + theme.buttons.secondary.low.borderRest, + theme.buttons.outlineDark.rest, + ), + transparent: css``, + sliderTabActive: css``, + sliderTabInactive: css``, + restSubtle: css``, +})) + const sizes = createVariants((theme) => ({ small: css` line-height: 1.2; @@ -298,9 +344,10 @@ export const SButton = styled(Box, { shouldForwardProp: (prop) => !["variant", "size", "outline"].includes(prop), })( defaulStyles, - ({ variant = "primary", size = "small", outline = false }) => [ + ({ variant = "primary", size = "small", outline = false, glow = false }) => [ sizes(size), outline ? outlineVariants(variant) : variants(variant), + glow ? glowVariants(variant) : undefined, ], disabledStyles, ) diff --git a/packages/ui/src/components/FormField/FormField.tsx b/packages/ui/src/components/FormField/FormField.tsx index 19964e15b4..3592c7b6d6 100644 --- a/packages/ui/src/components/FormField/FormField.tsx +++ b/packages/ui/src/components/FormField/FormField.tsx @@ -3,7 +3,7 @@ import { Text, TextProps } from "@/components/Text" import { getToken } from "@/utils" export const FormLabel: React.FC = (props) => ( - + ) export const FormError: React.FC = (props) => ( diff --git a/packages/ui/src/components/JsonView/JsonView.styled.ts b/packages/ui/src/components/JsonView/JsonView.styled.ts index 8f0d58dba0..6e75570815 100644 --- a/packages/ui/src/components/JsonView/JsonView.styled.ts +++ b/packages/ui/src/components/JsonView/JsonView.styled.ts @@ -9,6 +9,9 @@ export const SRoot = styled.div( min-height: 1.5rem font-family: GeistMono; font-size: inherit; + font-weight: 600; + + color: ${theme.text.low}; --json-property: ${theme.text.high}; --json-index: ${theme.text.low}; @@ -21,6 +24,19 @@ export const SRoot = styled.div( animation: ${theme.animations.fadeIn} 0.2s ease forwards; } + .json-view { + &--string { + word-break: break-all; + } + + .jv-indent { + margin-left: 0.2rem; + padding-left: 1.2rem; + border-left: 1px solid ${theme.details.separatorsOnDim}; + } + } + + pre { position: relative; color: transparent; diff --git a/packages/ui/src/components/JsonView/JsonView.tsx b/packages/ui/src/components/JsonView/JsonView.tsx index a6480425dc..7a0f3195a4 100644 --- a/packages/ui/src/components/JsonView/JsonView.tsx +++ b/packages/ui/src/components/JsonView/JsonView.tsx @@ -14,9 +14,10 @@ const ReactJsonView = lazy(() => import("react18-json-view")) export type JsonViewProps = ReactJsonViewProps & { className?: string fs?: ThemeUICSSProperties["fontSize"] + ref?: Ref } -export const JsonView: FC }> = ({ +export const JsonView: FC = ({ fs, className, ref, diff --git a/packages/ui/src/components/Points/Points.styled.ts b/packages/ui/src/components/Points/Points.styled.ts index 3aa1842662..bc3ed3147b 100644 --- a/packages/ui/src/components/Points/Points.styled.ts +++ b/packages/ui/src/components/Points/Points.styled.ts @@ -55,14 +55,14 @@ const pointsNumberSizes = createVariants((theme) => ({ font-family: ${theme.fontFamilies1.primary}; font-weight: 700; font-size: ${theme.fontSizes.p5}; - line-height: 1.3; + line-height: 1; color: ${theme.text.high}; `, l: css` font-family: ${theme.fontFamilies1.primary}; font-weight: 700; font-size: ${theme.fontSizes.base}; - line-height: 1.3; + line-height: 1; color: ${theme.text.high}; `, })) @@ -74,11 +74,13 @@ export const SPointsNumber = styled.p<{ readonly size: PointsSize }>( const pointsTextContentSizes = createVariants((theme) => ({ m: css` display: flex; + justify-content: center; flex-direction: column; gap: ${theme.space.xs}; `, l: css` display: flex; + justify-content: center; flex-direction: column; gap: ${theme.space.s}; `, diff --git a/packages/ui/src/components/Points/Points.tsx b/packages/ui/src/components/Points/Points.tsx index 0ce6a75584..976e67fa78 100644 --- a/packages/ui/src/components/Points/Points.tsx +++ b/packages/ui/src/components/Points/Points.tsx @@ -14,7 +14,7 @@ type Props = { readonly size?: PointsSize readonly number: number readonly title: ReactNode - readonly description: ReactNode + readonly description?: ReactNode readonly className?: string } @@ -32,7 +32,9 @@ export const Points = ({ {title} - {description} + {description && ( + {description} + )} ) diff --git a/packages/ui/src/components/VirtualizedList/VirtualizedList.styled.ts b/packages/ui/src/components/VirtualizedList/VirtualizedList.styled.ts index 6ae0c2169d..49889ac2ad 100644 --- a/packages/ui/src/components/VirtualizedList/VirtualizedList.styled.ts +++ b/packages/ui/src/components/VirtualizedList/VirtualizedList.styled.ts @@ -1,5 +1,5 @@ import { Box } from "@/components/Box" -import { styled } from "@/utils" +import { css, styled } from "@/utils" export const SList = styled(Box)` width: 100%; @@ -7,12 +7,26 @@ export const SList = styled(Box)` ` export const SListItem = styled("div", { - shouldForwardProp: (props) => props !== "size" && props !== "start", -})<{ size: number; start: number }>` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: ${({ size }) => size}px; - transform: translateY(${({ start }) => start}px); -` + shouldForwardProp: (prop) => + prop !== "size" && prop !== "start" && prop !== "bordered", +})<{ size: number; start: number; bordered: boolean }>( + ({ theme, size, start, bordered }) => css` + position: absolute; + box-sizing: content-box; + top: 0; + left: 0; + width: 100%; + height: ${size}px; + transform: translateY(${start}px); + border-bottom: 1px solid transparent; + + &:last-of-type { + border-bottom: none; + } + + ${bordered && + css` + border-bottom-color: ${theme.details.separators}; + `} + `, +) diff --git a/packages/ui/src/components/VirtualizedList/VirtualizedList.tsx b/packages/ui/src/components/VirtualizedList/VirtualizedList.tsx index 98798adb1a..2e72a7f7c0 100644 --- a/packages/ui/src/components/VirtualizedList/VirtualizedList.tsx +++ b/packages/ui/src/components/VirtualizedList/VirtualizedList.tsx @@ -27,6 +27,7 @@ type VirtualizedListProps = VirtualizerProps & renderItem: (item: T, virtualItem: VirtualItem) => React.ReactNode initialScrollIndex?: number maxVisibleItems?: ResponsiveStyleValue + separated?: boolean } function VirtualizedList({ @@ -37,6 +38,7 @@ function VirtualizedList({ maxVisibleItems, getItemKey, initialScrollIndex, + separated = false, ...props }: VirtualizedListProps) { const parentRef = useRef(null) @@ -93,6 +95,7 @@ function VirtualizedList({ data-virtual-index={virtualItem.index} size={virtualItem.size} start={virtualItem.start} + bordered={separated} > {renderItem(items[virtualItem.index], virtualItem)} diff --git a/packages/utils/src/constants/assets.ts b/packages/utils/src/constants/assets.ts index b73e2bfaca..aed788bb81 100644 --- a/packages/utils/src/constants/assets.ts +++ b/packages/utils/src/constants/assets.ts @@ -25,12 +25,14 @@ export const PRIME_STABLESWAP_ASSET_ID = "143" export const GSOL_ASSET_ID = "90001" export const GSOL_ERC20_ID = "9001" export const JITOSOL_ASSET_ID = "40" +export const HEURC_ASSET_ID = "10044" export const HOLLAR_ASSETS = [ HUSDC_ASSET_ID, HUSDT_ASSET_ID, HUSDS_ASSET_ID, HUSDE_ASSET_ID, + HEURC_ASSET_ID, ] export const GIGA_ASSETS = [GDOT_ASSET_ID, GETH_ASSET_ID, GSOL_ASSET_ID] diff --git a/packages/utils/src/helpers/formatting.ts b/packages/utils/src/helpers/formatting.ts index dbc5b5882e..8251d66d2c 100644 --- a/packages/utils/src/helpers/formatting.ts +++ b/packages/utils/src/helpers/formatting.ts @@ -99,3 +99,11 @@ export const formatPascalCaseToSentence = (text: string): string => .trim() .toLowerCase() .replace(/^\w/, (c) => c.toUpperCase()) + +export const replaceAaveWithBorrow = (text: string) => { + try { + return text.replace(/aave/gi, "Borrow") + } catch (error) { + return text + } +} diff --git a/packages/utils/src/helpers/index.ts b/packages/utils/src/helpers/index.ts index 4034cc9e91..c03b9fbb1a 100644 --- a/packages/utils/src/helpers/index.ts +++ b/packages/utils/src/helpers/index.ts @@ -8,7 +8,9 @@ export * from "./helpers" export * from "./html" export * from "./interpolation" export * from "./intl" +export * from "./invariant" export * from "./jitosol" +export * from "./json" export * from "./logger" export * from "./math" export * from "./meta" diff --git a/packages/utils/src/helpers/invariant.ts b/packages/utils/src/helpers/invariant.ts new file mode 100644 index 0000000000..290fc04da4 --- /dev/null +++ b/packages/utils/src/helpers/invariant.ts @@ -0,0 +1,6 @@ +export function invariant( + condition: unknown, + message: string, +): asserts condition { + if (!condition) throw new Error(message) +} diff --git a/packages/utils/src/helpers/json.ts b/packages/utils/src/helpers/json.ts new file mode 100644 index 0000000000..7501ab8801 --- /dev/null +++ b/packages/utils/src/helpers/json.ts @@ -0,0 +1,78 @@ +import { first, isArray, isBigInt, isObjectType, isTruthy } from "remeda" + +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[] + +export interface JsonObject { + [key: string]: JsonValue +} + +export interface TypeValueNode extends JsonObject { + type: string + value: JsonValue +} + +function isTypeValueNode(value: JsonValue): value is TypeValueNode { + return ( + isObjectType(value) && + "type" in value && + typeof value.type === "string" && + "value" in value + ) +} + +function collapseBigIntArray( + input: JsonValue[], + mapper: (value: JsonValue) => JsonValue, +): JsonValue { + const filtered = input.filter(isTruthy) + if (filtered.length === 0) return mapper(input[0]) + if (filtered.length === 1) { + const value = first(filtered) + if (value) return mapper(value) + } + return filtered.map(mapper) +} + +/** + * Formats a JSON value for display by collapsing `{ type, value }` nodes + * into an object keyed by type and nested type + */ +export function formatTypeValueJson(input: JsonValue): JsonValue { + if (isArray(input)) { + return input.length > 0 && input.every(isBigInt) + ? collapseBigIntArray(input, formatTypeValueJson) + : input.map(formatTypeValueJson) + } + + if (isBigInt(input)) { + return input.toString() + } + + if (!isObjectType(input)) { + return input + } + + if (isTypeValueNode(input)) { + const nestedValue = input.value + + if (isTypeValueNode(nestedValue)) { + return { + [`${input.type}.${nestedValue.type}`]: formatTypeValueJson( + nestedValue.value, + ), + } + } + + return { + [input.type]: formatTypeValueJson(nestedValue), + } + } + + return Object.fromEntries( + Object.entries(input).map(([key, value]) => [ + key, + formatTypeValueJson(value), + ]), + ) +} diff --git a/packages/utils/src/helpers/subscan.ts b/packages/utils/src/helpers/subscan.ts index 25d26139d5..814409d687 100644 --- a/packages/utils/src/helpers/subscan.ts +++ b/packages/utils/src/helpers/subscan.ts @@ -10,7 +10,7 @@ import { type SubscanLinkPath = "tx" | "account" | "block" const SUBSCAN_API_PROXY_URL = - "https://galacticcouncil.squids.live/hydration-pools:unified-prod/api/proxy/subscan" + "https://unified-main-aggr-indx.indexer.hydration.cloud/api/proxy/subscan" export const subscan = { rdns: "io.subscan", diff --git a/packages/utils/src/lib/AssetMetadataFactory.ts b/packages/utils/src/lib/AssetMetadataFactory.ts index b4142cb929..50f5ea1d91 100644 --- a/packages/utils/src/lib/AssetMetadataFactory.ts +++ b/packages/utils/src/lib/AssetMetadataFactory.ts @@ -11,6 +11,26 @@ export type TAssetResouce = { items: string[] } +export type TMetadataResource = { + assets: { + external: { + whitelist: { + [key: string]: string + } + } + xcscanAssetUrnMap: { + [key: string]: string + } + } +} + +const DEFAULT_ASSETS_METADATA: TMetadataResource["assets"] = { + external: { + whitelist: {}, + }, + xcscanAssetUrnMap: {}, +} + const BASE_URL = "https://raw.githubusercontent.com/galacticcouncil/intergalactic-asset-metadata/master" @@ -18,6 +38,7 @@ export class AssetMetadataFactory { private static _instance: AssetMetadataFactory = new AssetMetadataFactory() private assets: TAssetResouce["items"] = [] private chains: TAssetResouce["items"] = [] + private metadata: TMetadataResource | undefined = undefined private constructor() { if (AssetMetadataFactory._instance) { @@ -30,15 +51,26 @@ export class AssetMetadataFactory { return AssetMetadataFactory._instance } - private async fetchData(path: string): Promise { - const response = await fetch(BASE_URL + path) - return response.json() + private async fetchData(path: string): Promise { + try { + const response = await fetch(BASE_URL + path) + if (!response.ok) { + return null + } + return (await response.json()) as T + } catch { + return null + } } public async fetchAssets(): Promise { if (!this.assets.length) { const data = await this.fetchData("/assets-v2.json") - this.assets = data.items.map((item) => `${this.getBaseUrl(data)}/${item}`) + if (data) { + this.assets = data.items.map( + (item) => `${this.getBaseUrl(data)}/${item}`, + ) + } } return this.assets @@ -47,12 +79,25 @@ export class AssetMetadataFactory { public async fetchChains(): Promise { if (!this.chains.length) { const data = await this.fetchData("/chains-v2.json") - this.chains = data.items.map((item) => `${this.getBaseUrl(data)}/${item}`) + if (data) { + this.chains = data.items.map( + (item) => `${this.getBaseUrl(data)}/${item}`, + ) + } } return this.chains } + public async fetchMetadata(): Promise { + if (!this.metadata) { + const data = await this.fetchData("/metadata.json") + if (data) this.metadata = data + } + + return this.metadata ?? { assets: DEFAULT_ASSETS_METADATA } + } + public getBaseUrl(data: TAssetResouce): string { const { cdn, path, repository } = data return [cdn["jsDelivr"], repository + "@latest", path].join("/") @@ -78,4 +123,8 @@ export class AssetMetadataFactory { const key = [ecosystem.toLowerCase(), chainId].join("/") return this.chains.find((path) => path.includes(key + "/icon")) ?? "" } + + public getAssetsMetadata(): TMetadataResource["assets"] { + return this.metadata?.assets || DEFAULT_ASSETS_METADATA + } } diff --git a/packages/web3-connect/src/hooks/useWeb3EagerEnable.ts b/packages/web3-connect/src/hooks/useWeb3EagerEnable.ts index 3ef14fa8d0..45e6fa4c26 100644 --- a/packages/web3-connect/src/hooks/useWeb3EagerEnable.ts +++ b/packages/web3-connect/src/hooks/useWeb3EagerEnable.ts @@ -1,9 +1,5 @@ import { h160 } from "@galacticcouncil/common" -import { - isH160Address, - isSS58Address, - safeConvertSS58toH160, -} from "@galacticcouncil/utils" +import { safeConvertSS58toH160 } from "@galacticcouncil/utils" import { useEffect, useRef, useState } from "react" import { useMount, usePrevious } from "react-use" import { pick } from "remeda" @@ -103,17 +99,18 @@ export const useWeb3EagerEnable = (enabled = true) => { useEffect(() => { const params = new URLSearchParams(window.location.search) - const address = params.get("address") - - // Override connected account to ExternalWallet if address is provided in query param - if (address && (isH160Address(address) || isSS58Address(address))) { - const externalWallet = getWallet(WalletProviderType.ExternalWallet) - if (externalWallet instanceof ExternalWallet) { - externalWallet.setAccount(address) - enable(WalletProviderType.ExternalWallet).then(([account]) => { - setAccount(toStoredAccount(account)) - }) - } - } + const address = params.get("account") + + if (!address) return + + const externalWallet = getWallet(WalletProviderType.ExternalWallet) + if (!(externalWallet instanceof ExternalWallet)) return + + const isValid = externalWallet.setAccount(address) + if (!isValid) return + + enable(WalletProviderType.ExternalWallet).then(([account]) => { + setAccount(toStoredAccount(account)) + }) }, [enable, setAccount]) } diff --git a/packages/web3-connect/src/wallets/ExternalWallet/index.ts b/packages/web3-connect/src/wallets/ExternalWallet/index.ts index 04c41b17ea..40d2392ecf 100644 --- a/packages/web3-connect/src/wallets/ExternalWallet/index.ts +++ b/packages/web3-connect/src/wallets/ExternalWallet/index.ts @@ -1,6 +1,6 @@ import { - isH160Address, - isSS58Address, + safeConvertAddressH160, + safeConvertAddressSS58, updateQueryString, } from "@galacticcouncil/utils" @@ -55,17 +55,21 @@ export class ExternalWallet implements Wallet { return new Error(err.message) } - setAccount = (address: string, shouldUpdateQueryString = false) => { - if (isSS58Address(address) || isH160Address(address)) { - this.account = { - address, - name: "External Account", - provider: this.provider, - } - if (shouldUpdateQueryString) { - updateQueryString("address", address) - } + setAccount = (address: string, shouldUpdateQueryString = false): boolean => { + const normalized = + safeConvertAddressH160(address) || safeConvertAddressSS58(address) + + if (!normalized) return false + + this.account = { + address: normalized, + name: "External Account", + provider: this.provider, } + if (shouldUpdateQueryString) { + updateQueryString("account", normalized) + } + return true } getAccounts = async (): Promise => { diff --git a/yarn.lock b/yarn.lock index 618970f420..783f429bee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2024,18 +2024,18 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== -"@galacticcouncil/common@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@galacticcouncil/common/-/common-0.5.1.tgz#de575cbee176b5bdfc1ec89dc2db22aaa0196d99" - integrity sha512-3Mjk18Xh2p/s5bxZBOzu0xNa4lMln0Gx9wYA33LytGjDRuENNuAYeGYgCDNd/k/21iFaMoYbABrpFCbfyES74Q== +"@galacticcouncil/common@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/common/-/common-0.6.0.tgz#e8e96cb9f3452211783986ed9f92107309af88ff" + integrity sha512-fsCcRNHrqDcPbH9sClgyN2O3M4T7KMy+7ey1OwijJhWeg0OqEuLowdFrJlnS4GvgkqIT2GkSYNu/8sryl+uHxA== dependencies: big.js "^6.2.1" lru-cache "^11.2.2" -"@galacticcouncil/descriptors@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/descriptors/-/descriptors-1.14.0.tgz#d2060cbee7e17b6dc1ffec51fbf3741b8c37e8b9" - integrity sha512-/TyGsfWgZncFGGsQ+W6mcLEB+LG3r3CPFgELAobmkAmnrBVhT/mqL8c30znJyGb/vScp9tO3BGJoWT4fRhNGTg== +"@galacticcouncil/descriptors@^1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/descriptors/-/descriptors-1.15.0.tgz#f2662f54108253e1f165781a9e04991ca67a52d1" + integrity sha512-oDY/DhYBj0USXP6qheqebumymjOOnxmvMgYRIOjyBvEZWZY6bcO+vGmtkePzFVpINLhXl86/45Q5bi5M5plPhg== "@galacticcouncil/math-hsm@^1.2.0": version "1.2.0" @@ -2072,10 +2072,10 @@ resolved "https://registry.yarnpkg.com/@galacticcouncil/math-xyk/-/math-xyk-1.3.0.tgz#96d3b3e63a93544c7e60d3fa9e1be9a576a20efe" integrity sha512-ZYVSyI5W2pQKCqpkaRM7t1AJqtPyxQ0P04T2+KpBEEABMRjSpQz2x44s1tk6r3DdFR8dICeZJEh5BI7eQYlT6w== -"@galacticcouncil/sdk-next@^0.37.0": - version "0.37.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/sdk-next/-/sdk-next-0.37.0.tgz#0e9121d16936b71cea8265e20b3d7191e0084901" - integrity sha512-wfoRKLriLCFNNi+z+ld59TulnkiFf+u9pG6Q1Fbq76bYmpy+k2QxQv5aLaPt8WY5/tZTClzk2AHJ8zk8RYvA9g== +"@galacticcouncil/sdk-next@^0.38.0": + version "0.38.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/sdk-next/-/sdk-next-0.38.0.tgz#5faedd2319c027b342b2fe4f4ed008e28602216b" + integrity sha512-XDRLA/dSOLwV8eAFCks0urpXgmNPrUASGFBeWge/IchinT4idfVzrnmvQFEpL9IcCejqg026Qp5h232mX67tBw== dependencies: "@galacticcouncil/math-hsm" "^1.2.0" "@galacticcouncil/math-lbp" "^1.3.0" @@ -2089,17 +2089,17 @@ "@thi.ng/memoize" "^4.0.2" big.js "^6.2.1" -"@galacticcouncil/xc-cfg@^0.17.0": - version "0.17.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-cfg/-/xc-cfg-0.17.0.tgz#fac8ec0418cd37e03d1a7eb0ac1da737fae1c964" - integrity sha512-/Gr9ZuY9MrrtIdTLFb9lAOHWejMAAsLpHDyGLKeZE8u/yffBmAVkR8NX8yevGLvqxoCYEbSgiiKulmVxy4BNVQ== +"@galacticcouncil/xc-cfg@^0.18.1": + version "0.18.1" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-cfg/-/xc-cfg-0.18.1.tgz#7594707b8564204febb04aed5b2145ba6ae111b5" + integrity sha512-WMaq6IvM6xwtxWYOPt7rY4oMHGrUSsaFYVm1fNDucZn6C7ZqNJIcu9LNzWyfDFfm9wA91RO2Jy3rrrLKB7S1+g== dependencies: - "@galacticcouncil/xc-core" "^0.12.0" + "@galacticcouncil/xc-core" "^0.13.0" -"@galacticcouncil/xc-core@^0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-core/-/xc-core-0.12.0.tgz#91e19726f2bdf5469d1e87874173ce726aca6d2a" - integrity sha512-aGkTkGpHDYlzLVg29i4IwhC0ntmZkpf73+WsjiaY3h/iSjGSwRNMU48gbKoNjvni8a7pSBKD2bWMOO6Uu2vU9A== +"@galacticcouncil/xc-core@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-core/-/xc-core-0.13.0.tgz#6ffb96327c352ba5ccefea8ac634c4561dbb7188" + integrity sha512-hfA7s33C3l0vaTgDiHx0zBidvJpR6+L1dlL59ehT4sI6wEDWZH+b4rYdyO5m/CVhnfwZzZgqNQ6ZYZGrjtlL7A== dependencies: "@noble/hashes" "^1.6.1" "@wormhole-foundation/sdk-base" "3.2.0" @@ -2115,22 +2115,22 @@ bs58 "^6.0.0" buffer "^6.0.3" -"@galacticcouncil/xc-scan@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-scan/-/xc-scan-0.3.0.tgz#662bb011c53f056c230d1d1057d31b94424758f7" - integrity sha512-onH/zcGdA5JQx/tE7fcnhMUwosPnGflkJgcujgKeyuJPL15onvMPPKlDzvvNnUN8clfnefRtmEY9SNPo4md9uw== +"@galacticcouncil/xc-scan@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-scan/-/xc-scan-0.4.0.tgz#1c12eba5b8d7fd6f6614d79617f73a6f157a20a0" + integrity sha512-RilIVpyvU4Dv4+neAE3+9UtuKNxtoTkS3RDXKOAMK2mAGWbJ2+cmeX6ahZxvXLIOowB2TDssFOFDykLFIcHd0g== -"@galacticcouncil/xc-sdk@^0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-sdk/-/xc-sdk-0.9.0.tgz#1e9f08cb54f6486b260781ff9b4681d83ad75fc0" - integrity sha512-/3Bq5HFnQfBCrYiubrmW7pXuAsekaZ0zEAIFc1+1T+aZ0aOVHHHUUsR0HHRj37ayNA50tVMB+/KL+pdeHRgTng== +"@galacticcouncil/xc-sdk@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-sdk/-/xc-sdk-0.9.1.tgz#145ba1c3c2458e55b2f2c02c4f7a67c365bc671a" + integrity sha512-upRR4abs7ZrQp1Qphn+zziXbpX2V6UyBffDCn+ekj/WpOJxELfwPbkzh8tfRHlurrwUgvCU1cv9f3fhvjXWt4w== dependencies: - "@galacticcouncil/xc-core" "^0.12.0" + "@galacticcouncil/xc-core" "^0.13.0" -"@galacticcouncil/xc@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc/-/xc-0.4.0.tgz#fbed277a8786434d85eec7d8abcdcb730f6f5dc9" - integrity sha512-1hd+xIKRTNk/r07Y8pu/wRj9cSl5XmMcTa/BtaY5B+1adCGshSuxzBytj8oMaWDZUPMI5Egpqt4g5eXNoro3Zg== +"@galacticcouncil/xc@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc/-/xc-0.5.0.tgz#67dee6a85d314246a4d2ffaba4e82ffd2337c883" + integrity sha512-LQ31QormbwLE4BqSlPYVCz5E+fQ+cuS8bKAchm0GEudjbronMv/mWeMlK6bZvl6Ck227QOGbCwhprHpLN92ToQ== "@gql.tada/cli-utils@1.7.2": version "1.7.2"