diff --git a/apps/main/src/api/xcm.ts b/apps/main/src/api/xcm.ts index 3c11d64dcf..de808008b4 100644 --- a/apps/main/src/api/xcm.ts +++ b/apps/main/src/api/xcm.ts @@ -1,7 +1,7 @@ import { formatSourceChainAddress } from "@galacticcouncil/utils" import { createXcContext } from "@galacticcouncil/xc" import { chainsMap } from "@galacticcouncil/xc-cfg" -import { AnyChain, AssetAmount } from "@galacticcouncil/xc-core" +import { AnyChain, AssetAmount, ConfigBuilder } from "@galacticcouncil/xc-core" import { Transfer, TransferBuilder, Wallet } from "@galacticcouncil/xc-sdk" import { keepPreviousData, @@ -14,6 +14,7 @@ import { import { secondsToMilliseconds } from "date-fns" import { useEffect, useRef, useState } from "react" +import { getSupplementalBridgeRoutes } from "@/modules/xcm/transfer/utils/bridge-routes" import { TProviderContext, useRpcProvider } from "@/providers/rpcProvider" export const useCrossChainConfig = () => { @@ -135,6 +136,7 @@ export type XcmTransferArgs = { readonly destAddress: string readonly destAsset: string readonly destChain: string + readonly bridgeTag?: string } export const xcmTransferQuery = ( @@ -146,6 +148,7 @@ export const xcmTransferQuery = ( destAddress, destChain, destAsset, + bridgeTag, }: XcmTransferArgs, options?: UseQueryOptions, ) => { @@ -162,17 +165,46 @@ export const xcmTransferQuery = ( destAsset, srcChain, destChain, + bridgeTag, ], - queryFn: () => - TransferBuilder(wallet) + queryFn: async () => { + const builder = TransferBuilder(wallet) .withAsset(srcAsset) .withSource(srcChain) .withDestination(destChain) - .build({ - srcAddress: srcAddress, - dstAddress: destAddress, - dstAsset: destAsset, - }), + + if (bridgeTag) { + const selectedRoute = + builder.routes.find((r) => r.tags?.includes(bridgeTag)) ?? + getSupplementalBridgeRoutes(srcChain, destChain, srcAsset).find((r) => + r.tags?.includes(bridgeTag), + ) + + if (selectedRoute) { + const configs = ConfigBuilder(wallet.config) + .assets() + .asset(srcAsset) + .source(srcChain) + .destination(destChain) + .build(destAsset) + + return wallet.getTransferData( + { + origin: { chain: configs.origin.chain, route: selectedRoute }, + reverse: configs.reverse, + }, + srcAddress, + destAddress, + ) + } + } + + return builder.build({ + srcAddress: srcAddress, + dstAddress: destAddress, + dstAsset: destAsset, + }) + }, enabled: !!srcAddress && !!destAddress && diff --git a/apps/main/src/i18n/locales/en/xcm.json b/apps/main/src/i18n/locales/en/xcm.json index e5061e480a..a50831a0ac 100644 --- a/apps/main/src/i18n/locales/en/xcm.json +++ b/apps/main/src/i18n/locales/en/xcm.json @@ -4,8 +4,12 @@ "approve.title": "Approve spending cap", "approve.toast.submitted": "Approving {{ amount, number }} {{ symbol }} spending cap on {{ srcChain }}", "approve.toast.success": "Approved {{ amount, number }} {{ symbol }} spending cap on {{ srcChain }}", + "approve.pending.title": "Approval Pending", + "approve.pending.description": "Your approval transaction is being confirmed on the blockchain.", "bridge.wormhole": "Wormhole", "bridge.snowbridge": "Snowbridge", + "bridge.basejump": "Basejump", + "bridge.selector.label": "Via", "chainAssetSelect.button.selectAssetChain": "Select asset & chain", "chainAssetSelect.emptyState.noAssets": "No assets found", "chainAssetSelect.modal.title": "Chain & asset", @@ -45,6 +49,8 @@ "report.destFee.insufficientBalance": "You need to have at least {{ amount, number }} {{ symbol }} on {{ chain }}", "report.asset.frozen": "Your account on {{ chain }} has frozen balance for {{ symbol }}", "report.account.insufficientDeposit": "You need to have {{ amount, number }} {{ symbol }} on {{ chain }} for existential deposit", + "journey.fastDelivery": "fast delivery", + "journey.delivered": "Delivered", "journey.status.sent": "In Progress", "journey.status.pending": "In Progress", "journey.status.received": "Completed", 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/ReviewTransactionSummary.tsx b/apps/main/src/modules/transactions/review/ReviewTransactionSummary.tsx index 7ee747e400..514517b2ce 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionSummary.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionSummary.tsx @@ -4,7 +4,7 @@ import { Stack, SummaryRow, } from "@galacticcouncil/ui/components" -import { HYDRATION_CHAIN_KEY } from "@galacticcouncil/utils" +import { HYDRATION_CHAIN_KEY, isValidBigSource } from "@galacticcouncil/utils" import { chainsMap } from "@galacticcouncil/xc-cfg" import { ChainEcosystem } from "@galacticcouncil/xc-core" import Big from "big.js" @@ -70,19 +70,26 @@ const XcmSummary = () => { const srcChain = chainsMap.get(meta.srcChainKey) const isPolkadotEcosystem = srcChain?.ecosystem === ChainEcosystem.Polkadot + return ( } sx={{ mb: "var(--modal-content-inset)" }} > - + {!!meta.srcChainFee && ( + + )} {Big(meta.dstChainFee || "0").gt(0) && ( { {paginatedData.map((journey) => ( ))} + - - ) } diff --git a/apps/main/src/modules/xcm/history/hooks/useXcmBridgeTxStore.ts b/apps/main/src/modules/xcm/history/hooks/useXcmBridgeTxStore.ts new file mode 100644 index 0000000000..0f37193c50 --- /dev/null +++ b/apps/main/src/modules/xcm/history/hooks/useXcmBridgeTxStore.ts @@ -0,0 +1,27 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +export type XcmBridgeTxEntry = { + bridgeProvider: string + /** Intended destination chain URN (may differ from xc-scan's tracked destination) */ + destUrn?: string +} + +type XcmBridgeTxStore = { + /** Maps originTxPrimary (txHash on source chain) → entry */ + entries: Record + addEntry: (txHash: string, entry: XcmBridgeTxEntry) => void +} + +export const useXcmBridgeTxStore = create()( + persist( + (set) => ({ + entries: {}, + addEntry: (txHash, entry) => + set((state) => ({ + entries: { ...state.entries, [txHash]: entry }, + })), + }), + { name: "xcm-bridge-tx-store", version: 2 }, + ), +) diff --git a/apps/main/src/modules/xcm/history/utils/optimistic.ts b/apps/main/src/modules/xcm/history/utils/optimistic.ts index 6ba2fa53d5..7fbbb36fa4 100644 --- a/apps/main/src/modules/xcm/history/utils/optimistic.ts +++ b/apps/main/src/modules/xcm/history/utils/optimistic.ts @@ -23,7 +23,7 @@ export function isOptimisticJourney(journey: XcJourney): boolean { return journey.correlationId.startsWith(OPTIMISTIC_JOURNEY_PREFIX) } -function chainToUrn(chain: AnyChain): string { +export function chainToUrn(chain: AnyChain): string { const ecosystem = chain.ecosystem if (!ecosystem) return "" return `urn:ocn:${ecosystem.toLowerCase()}:${getChainId(chain)}` diff --git a/apps/main/src/modules/xcm/history/utils/protocols.ts b/apps/main/src/modules/xcm/history/utils/protocols.ts index bc92e8aa09..27027cf07f 100644 --- a/apps/main/src/modules/xcm/history/utils/protocols.ts +++ b/apps/main/src/modules/xcm/history/utils/protocols.ts @@ -2,6 +2,10 @@ import { ThemeToken } from "@galacticcouncil/ui/theme" const XC_SCAN_PROTOCOLS: Record = { + basejump: { + label: "Basejump 🪂", + color: "colors.skyBlue.600", + }, xcm: { label: "XCM", color: "colors.coral.400", diff --git a/apps/main/src/modules/xcm/transfer/XcmForm.tsx b/apps/main/src/modules/xcm/transfer/XcmForm.tsx index 1458b14b7b..d222b3ee28 100644 --- a/apps/main/src/modules/xcm/transfer/XcmForm.tsx +++ b/apps/main/src/modules/xcm/transfer/XcmForm.tsx @@ -16,10 +16,13 @@ import { useFormContext } from "react-hook-form" import { useTranslation } from "react-i18next" import { useCrossChainBalance } from "@/api/xcm" +import { useXcmBridgeTxStore } from "@/modules/xcm/history/hooks/useXcmBridgeTxStore" import { + chainToUrn, insertOptimisticJourney, removeOptimisticJourney, } from "@/modules/xcm/history/utils/optimistic" +import { BridgeSelector } from "@/modules/xcm/transfer/components/BridgeSelector" import { ChainAssetSelectModalSelectionChange } from "@/modules/xcm/transfer/components/ChainAssetSelect" import { ChainSwitch } from "@/modules/xcm/transfer/components/ChainSwitch" import { ConnectButton } from "@/modules/xcm/transfer/components/ConnectButton" @@ -51,6 +54,7 @@ export const XcmForm = () => { dryRunError, sourceChainAssetPairs, destChainAssetPairs, + availableBridgeRoutes, isLoading, isLoadingCall, isLoadingTransfer, @@ -81,6 +85,8 @@ export const XcmForm = () => { const queryClient = useQueryClient() + const { addEntry: addBridgeTxEntry } = useXcmBridgeTxStore() + const submit = useSubmitXcmTransfer({ onTransferSubmitted: (txHash, values, transfer) => { if (account) { @@ -92,6 +98,12 @@ export const XcmForm = () => { transfer, ) } + if (values.bridgeProvider) { + addBridgeTxEntry(txHash, { + bridgeProvider: values.bridgeProvider, + destUrn: values.destChain ? chainToUrn(values.destChain) : undefined, + }) + } resetAmounts() }, onTransferError: (txHash) => { @@ -278,6 +290,14 @@ export const XcmForm = () => { /> + {availableBridgeRoutes.length > 1 && ( + <> + + + + + + )} diff --git a/apps/main/src/modules/xcm/transfer/XcmProvider.tsx b/apps/main/src/modules/xcm/transfer/XcmProvider.tsx index cd956317a8..2d41e647cc 100644 --- a/apps/main/src/modules/xcm/transfer/XcmProvider.tsx +++ b/apps/main/src/modules/xcm/transfer/XcmProvider.tsx @@ -21,6 +21,7 @@ import { useXcmForm } from "@/modules/xcm/transfer/hooks/useXcmForm" import { XcmContext } from "@/modules/xcm/transfer/hooks/useXcmProvider" import { useXcmTransfer } from "@/modules/xcm/transfer/hooks/useXcmTransfer" import { useXcmTransferAlerts } from "@/modules/xcm/transfer/hooks/useXcmTransferAlerts" +import { getSupplementalBridgeRoutes } from "@/modules/xcm/transfer/utils/bridge-routes" import { getChainPriority, isAccountValidOnChain, @@ -28,8 +29,10 @@ import { } from "@/modules/xcm/transfer/utils/chain" import { calculateTransferDestAmount, + getPrimaryBridgeTag, getTransferStatus, } from "@/modules/xcm/transfer/utils/transfer" +import { XcmTag } from "@/states/transactions" type XcmProviderProps = { children: React.ReactNode @@ -44,15 +47,23 @@ export const XcmProvider: React.FC = ({ children }) => { const configService = useCrossChainConfigService() - const [srcChain, srcAsset, destChain, destAsset, srcAmount, destAddress] = - form.watch([ - "srcChain", - "srcAsset", - "destChain", - "destAsset", - "srcAmount", - "destAddress", - ]) + const [ + srcChain, + srcAsset, + destChain, + destAsset, + srcAmount, + destAddress, + bridgeProvider, + ] = form.watch([ + "srcChain", + "srcAsset", + "destChain", + "destAsset", + "srcAmount", + "destAddress", + "bridgeProvider", + ]) const config = useMemo( () => ConfigBuilder(configService).assets(), @@ -96,10 +107,64 @@ export const XcmProvider: React.FC = ({ children }) => { .source(srcChain) .destination(chain) - return { chain, routes, assets: routes.map((r) => r.destination.asset) } + // Deduplicate assets - multiple bridge routes may share the same destination asset + const seenKeys = new Set() + const assets = routes + .map((r) => r.destination.asset) + .filter((a) => (seenKeys.has(a.key) ? false : seenKeys.add(a.key))) + + return { chain, routes, assets } }) }, [config, srcAsset, srcChain, configService]) + const availableBridgeRoutes = useMemo(() => { + if (!srcChain || !srcAsset || !destChain || !destAsset) return [] + const destPair = destChainAssetPairs.find( + (p) => p.chain.key === destChain.key, + ) + if (!destPair) return [] + + const configRoutes = destPair.routes.filter( + (r) => + r.destination.asset.key === destAsset.key && + getPrimaryBridgeTag(r) !== null, + ) + const existingTags = new Set( + configRoutes.map((r) => getPrimaryBridgeTag(r)), + ) + const supplemental = getSupplementalBridgeRoutes( + srcChain.key, + destChain.key, + srcAsset.key, + ).filter( + (r) => + r.destination.asset.key === destAsset.key && + !existingTags.has(getPrimaryBridgeTag(r)), + ) + return [...configRoutes, ...supplemental] + }, [srcChain, srcAsset, destChain, destAsset, destChainAssetPairs]) + + useEffect(() => { + if (availableBridgeRoutes.length <= 1) { + if (bridgeProvider !== null) { + form.setValue("bridgeProvider", null) + } + return + } + + const isCurrentValid = availableBridgeRoutes.some( + (r) => getPrimaryBridgeTag(r) === bridgeProvider, + ) + if (isCurrentValid) return + + const defaultRoute = + availableBridgeRoutes.find( + (r) => getPrimaryBridgeTag(r) === XcmTag.Basejump, + ) ?? availableBridgeRoutes[0] + if (!defaultRoute) return + form.setValue("bridgeProvider", getPrimaryBridgeTag(defaultRoute)) + }, [availableBridgeRoutes, bridgeProvider, form]) + useEffect(() => { const validRoutes = pipe( destChainAssetPairs, @@ -189,6 +254,7 @@ export const XcmProvider: React.FC = ({ children }) => { isConnectedAccountValid, sourceChainAssetPairs, destChainAssetPairs, + availableBridgeRoutes, alerts, transfer, call, diff --git a/apps/main/src/modules/xcm/transfer/XcmSummary.tsx b/apps/main/src/modules/xcm/transfer/XcmSummary.tsx index fb592d1b2e..f0a6958c30 100644 --- a/apps/main/src/modules/xcm/transfer/XcmSummary.tsx +++ b/apps/main/src/modules/xcm/transfer/XcmSummary.tsx @@ -27,14 +27,21 @@ export const XcmSummary = () => { const { source, destination } = transfer || {} - const [srcAsset, destAsset, srcChain, destChain] = watch([ + const [srcAsset, destAsset, srcChain, destChain, bridgeProvider] = watch([ "srcAsset", "destAsset", "srcChain", "destChain", + "bridgeProvider", ]) - const config = useXcmTransferConfigs(srcAsset, srcChain, destChain, destAsset) + const config = useXcmTransferConfigs( + srcAsset, + srcChain, + destChain, + destAsset, + bridgeProvider, + ) const { origin } = config ?? {} const sourceFeeValue = (() => { diff --git a/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.styled.ts b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.styled.ts new file mode 100644 index 0000000000..2f24d14192 --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.styled.ts @@ -0,0 +1,66 @@ +import { css, keyframes } from "@emotion/react" +import styled from "@emotion/styled" + +const slide = keyframes` + 0% { transform: translateX(-100%); opacity: 0; } + 8% { opacity: 1; } + 88% { opacity: 1; } + 100% { transform: translateX(100vw); opacity: 0; } +` + +export const SBridgeOption = styled.button<{ active: boolean }>( + ({ theme, active }) => css` + display: flex; + justify-content: space-between; + align-items: center; + gap: ${theme.space.base}; + + position: relative; + overflow: hidden; + + border: 1px solid ${theme.buttons.outlineDark.onOutline}; + border-radius: ${theme.radii.m}; + + padding-block: ${theme.space.l}; + padding-inline: ${theme.space.m}; + + cursor: pointer; + + transition: ${theme.transitions.colors}; + + ${active + ? css` + background-color: ${theme.controls.dim.active}; + border-color: ${theme.controls.dim.active}; + ` + : css` + &:hover:not(:disabled) { + background-color: ${theme.buttons.outlineDark.rest}; + } + `} + `, +) + +export const SParticle = styled.div<{ + color: string + duration: string + delay: string + active: boolean +}>( + ({ color, duration, delay, active }) => css` + position: absolute; + top: 0; + left: 0; + width: 48px; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + ${color}28 60%, + ${color}80 100% + ); + opacity: ${active ? 1 : 0.2}; + animation: ${slide} ${duration} ${delay} linear infinite; + pointer-events: none; + `, +) diff --git a/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.tsx b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.tsx new file mode 100644 index 0000000000..0a47f383a8 --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.tsx @@ -0,0 +1,93 @@ +import { JetSki, Swimmer } from "@galacticcouncil/ui/assets/icons" +import { Flex, Icon, Text } from "@galacticcouncil/ui/components" +import { getToken } from "@galacticcouncil/ui/utils" +import { AssetRoute } from "@galacticcouncil/xc-core" +import { useFormContext } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { isNonNullish } from "remeda" + +import { XcmFormValues } from "@/modules/xcm/transfer/hooks/useXcmFormSchema" +import { getPrimaryBridgeTag } from "@/modules/xcm/transfer/utils/transfer" +import { XcmTag } from "@/states/transactions" + +import { SBridgeOption } from "./BridgeSelector.styled" + +type BridgeOption = { + id: string + label: string + time: string + icon: React.ComponentType +} + +const BRIDGE_TIME_ESTIMATES: Partial> = { + [XcmTag.Basejump]: "~1 min", + [XcmTag.Wormhole]: "~30 min", + [XcmTag.Snowbridge]: "~25 min", +} + +const BRIDGE_ICONS: Partial> = { + [XcmTag.Basejump]: JetSki, + [XcmTag.Wormhole]: Swimmer, + [XcmTag.Snowbridge]: Swimmer, +} + +type BridgeSelectorProps = { + routes: AssetRoute[] +} + +export const BridgeSelector: React.FC = ({ routes }) => { + const { t } = useTranslation(["xcm"]) + const { watch, setValue } = useFormContext() + const bridgeProvider = watch("bridgeProvider") + + const options = routes + .map((route) => { + const tag = getPrimaryBridgeTag(route) + if (!tag) return null + return { + id: tag, + label: t(`xcm:bridge.provider.${tag.toLowerCase()}`, tag), + time: BRIDGE_TIME_ESTIMATES[tag] ?? "", + icon: BRIDGE_ICONS[tag] ?? Swimmer, + } satisfies BridgeOption + }) + .filter(isNonNullish) + + if (options.length < 2) return null + + return ( + + {options.map((option) => { + const active = bridgeProvider === option.id + return ( + setValue("bridgeProvider", option.id)} + > + + {option.label} + + + + {option.time} + + {option.icon && ( + + )} + + + ) + })} + + ) +} diff --git a/apps/main/src/modules/xcm/transfer/components/BridgeSelector/index.ts b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/index.ts new file mode 100644 index 0000000000..0ace04a2f7 --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/index.ts @@ -0,0 +1 @@ +export { BridgeSelector } from "./BridgeSelector" diff --git a/apps/main/src/modules/xcm/transfer/components/PendingApproval/PendingApproval.tsx b/apps/main/src/modules/xcm/transfer/components/PendingApproval/PendingApproval.tsx new file mode 100644 index 0000000000..016113244b --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/components/PendingApproval/PendingApproval.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 PendingApproval = () => { + const { t } = useTranslation(["xcm"]) + return ( + + + + + {t("approve.pending.title")} + + + {t("approve.pending.description")} + + + + ) +} diff --git a/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts b/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts index 8084b8834c..dc6262bcdc 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts @@ -14,8 +14,10 @@ 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 { PendingApproval } from "@/modules/xcm/transfer/components/PendingApproval/PendingApproval" import { useApprovalTrackingStore } from "@/modules/xcm/transfer/hooks/useApprovalTrackingStore" import { XcmFormValues } from "@/modules/xcm/transfer/hooks/useXcmFormSchema" +import { getSupplementalBridgeRoutes } from "@/modules/xcm/transfer/utils/bridge-routes" import { buildTransferCall } from "@/modules/xcm/transfer/utils/transfer" import { useRpcProvider } from "@/providers/rpcProvider" import { @@ -55,7 +57,14 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { return useMutation({ mutationFn: async ([values, transfer]: [XcmFormValues, Transfer]) => { - const { srcAmount, srcChain, destChain, srcAsset, destAsset } = values + const { + srcAmount, + srcChain, + destChain, + srcAsset, + destAsset, + bridgeProvider, + } = values if (!account) throw new Error("Account is required") if (!destChain) throw new Error("Destination chain is required") @@ -72,7 +81,7 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { destChain: destChain.name, } - const { build } = ConfigBuilder(configService) + const { routes, build } = ConfigBuilder(configService) .assets() .asset(srcAsset) .source(srcChain) @@ -80,6 +89,20 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { const { origin } = build(destAsset) + const selectedRoute = bridgeProvider + ? (routes.find((r) => + (r.tags as string[] | undefined)?.includes(bridgeProvider), + ) ?? + getSupplementalBridgeRoutes( + srcChain.key, + destChain.key, + srcAsset.key, + ).find((r) => + (r.tags as string[] | undefined)?.includes(bridgeProvider), + ) ?? + origin.route) + : origin.route + const call = await transfer.buildCall(srcAmount) const isApprove = isEvmApproveCall(call) @@ -96,6 +119,17 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { srcChain.key === HYDRATION_CHAIN_KEY ? await papi.txFromCallData(Binary.fromHex(transferCall.data)) : await getExternalChainTx(srcChain, transferCall) + + const sourceFeeValue = (() => { + if (!source) return "" + if (source.fee.amount === 0n) + return t("xcm:summary.feeEstimationNotAvailable") + return t("common:currency", { + value: toDecimal(source.fee.amount, source.fee.decimals), + symbol: source.fee.originSymbol, + }) + })() + return { title: t("form.title"), description: t("tx.description", i18nVars), @@ -112,7 +146,7 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { meta: { type: TransactionType.Xcm, srcChainKey: srcChain.key, - srcChainFee: toDecimal(source.fee.amount, source.fee.decimals), + srcChainFee: sourceFeeValue, srcChainFeeSymbol: source.fee.symbol, dstChainKey: destChain.key, dstChainFee: toDecimal( @@ -120,7 +154,7 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { destination.fee.decimals, ), dstChainFeeSymbol: destination.fee.symbol, - tags: (origin.route.tags as XcmTags) || [], + tags: (selectedRoute.tags as XcmTags) || [], }, } } @@ -164,6 +198,7 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { }, { stepTitle: t("common:transfer"), + pendingComponent: PendingApproval, tx: buildTransferTransaction, onSubmitted: (txHash: string) => { transferTxHash = txHash diff --git a/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts b/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts index bb2a35c46e..282617fddb 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useXcmForm.ts @@ -32,6 +32,7 @@ export const useXcmForm = (transfer: Transfer | null) => { destAddress: defaults.destAddress ?? "", destAccount: defaults.destAccount ?? null, + bridgeProvider: null, }, }) diff --git a/apps/main/src/modules/xcm/transfer/hooks/useXcmFormSchema.ts b/apps/main/src/modules/xcm/transfer/hooks/useXcmFormSchema.ts index 597b1f0ddf..45edd8da1b 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useXcmFormSchema.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useXcmFormSchema.ts @@ -70,6 +70,7 @@ const createSchema = (transfer: Transfer | null) => { destAmount: z.string(), destAddress: required, destAccount: z.custom((val) => isObjectType(val)).nullable(), + bridgeProvider: z.string().nullable(), }) } diff --git a/apps/main/src/modules/xcm/transfer/hooks/useXcmProvider.ts b/apps/main/src/modules/xcm/transfer/hooks/useXcmProvider.ts index e0a8f23c6c..7b6cbd4e75 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useXcmProvider.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useXcmProvider.ts @@ -1,5 +1,5 @@ import { DryRunError } from "@galacticcouncil/utils" -import { EvmParachain } from "@galacticcouncil/xc-core" +import { AssetRoute, EvmParachain } from "@galacticcouncil/xc-core" import { Call, Transfer } from "@galacticcouncil/xc-sdk" import { createContext, useContext } from "react" @@ -22,6 +22,7 @@ type XcmContextValue = { readonly alerts: XcmAlert[] readonly sourceChainAssetPairs: ChainAssetPair[] readonly destChainAssetPairs: ChainAssetPair[] + readonly availableBridgeRoutes: AssetRoute[] readonly registryChain: EvmParachain readonly status: XcmTransferStatus } @@ -37,6 +38,7 @@ export const XcmContext = createContext({ alerts: [], sourceChainAssetPairs: [], destChainAssetPairs: [], + availableBridgeRoutes: [], registryChain: {} as EvmParachain, status: XcmTransferStatus.Default, }) diff --git a/apps/main/src/modules/xcm/transfer/hooks/useXcmTransferConfigs.ts b/apps/main/src/modules/xcm/transfer/hooks/useXcmTransferConfigs.ts index 2d85e6f493..da8b2fdf16 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useXcmTransferConfigs.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useXcmTransferConfigs.ts @@ -6,12 +6,14 @@ import { } from "@galacticcouncil/xc-core" import { useCrossChainConfigService } from "@/api/xcm" +import { getSupplementalBridgeRoutes } from "@/modules/xcm/transfer/utils/bridge-routes" export const useXcmTransferConfigs = ( srcAsset: Asset | null, srcChain: AnyChain | null, destChain: AnyChain | null, destAsset: Asset | null, + bridgeProvider?: string | null, ): TransferConfigs | null => { const configService = useCrossChainConfigService() if (!srcAsset || !srcChain || !destChain || !destAsset) return null @@ -37,5 +39,22 @@ export const useXcmTransferConfigs = ( return null } - return build(destAsset) + const configs = build(destAsset) + + if (bridgeProvider) { + const selectedRoute = + routes.find((r) => + (r.tags as string[] | undefined)?.includes(bridgeProvider), + ) ?? + getSupplementalBridgeRoutes( + srcChain.key, + destChain.key, + srcAsset.key, + ).find((r) => (r.tags as string[] | undefined)?.includes(bridgeProvider)) + if (selectedRoute) { + return { ...configs, origin: { ...configs.origin, route: selectedRoute } } + } + } + + return configs } diff --git a/apps/main/src/modules/xcm/transfer/utils/bridge-routes.ts b/apps/main/src/modules/xcm/transfer/utils/bridge-routes.ts new file mode 100644 index 0000000000..cbda745cea --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/utils/bridge-routes.ts @@ -0,0 +1,68 @@ +import { builders, chainsMap } from "@galacticcouncil/xc-cfg" +import { AssetRoute, EvmParachain } from "@galacticcouncil/xc-core" + +const { ContractBuilder, BalanceBuilder } = builders + +/** + * Supplemental bridge routes for asset pairs where the configService only stores + * one route due to ChainRoutes Map deduplication (keyed by srcAsset-destChain-destAsset). + * + * These are the "lost" routes that are defined in xc-cfg source but overwritten + * at runtime. We reconstruct them using the public contract/balance builders. + * + * Key format: `${srcChainKey}-${destChainKey}-${srcAssetKey}` + */ +const buildSupplementalRoutes = (): Map => { + const base = chainsMap.get("base") + const hydration = chainsMap.get("hydration") + const moonbeam = chainsMap.get("moonbeam") + + if (!base || !hydration || !moonbeam) return new Map() + + const eurc = base.assetsData.get("eurc")?.asset + const eurcMwh = hydration.assetsData.get("eurc_mwh")?.asset + const eth = base.assetsData.get("eth")?.asset + + if (!eurc || !eurcMwh || !eth) return new Map() + + // Base → Hydration EURC via Wormhole+MRL + // This route is overwritten in ChainRoutes by the Basejump route (same map key) + const wormholeRoute = new AssetRoute({ + source: { + asset: eurc, + balance: BalanceBuilder().evm().erc20(), + fee: { asset: eth, balance: BalanceBuilder().evm().native() }, + destinationFee: { asset: eurc, balance: BalanceBuilder().evm().erc20() }, + }, + destination: { + chain: hydration, + asset: eurcMwh, + fee: { amount: 0, asset: eurcMwh }, + }, + contract: ContractBuilder() + .Wormhole() + .TokenBridge() + .transferTokensWithPayload() + .viaMrl({ moonchain: moonbeam as EvmParachain }), + tags: ["Mrl", "Wormhole"], + }) + + return new Map([["base-hydration-eurc", [wormholeRoute]]]) +} + +const SUPPLEMENTAL_ROUTES = buildSupplementalRoutes() + +/** + * Returns bridge routes for a given asset pair that are NOT present in the + * configService (because ChainRoutes overwrites them with a later duplicate key). + */ +export const getSupplementalBridgeRoutes = ( + srcChainKey: string, + destChainKey: string, + srcAssetKey: string, +): AssetRoute[] => { + return ( + SUPPLEMENTAL_ROUTES.get(`${srcChainKey}-${destChainKey}-${srcAssetKey}`) ?? + [] + ) +} diff --git a/apps/main/src/modules/xcm/transfer/utils/chain.ts b/apps/main/src/modules/xcm/transfer/utils/chain.ts index 32daef58e5..a65cef65af 100644 --- a/apps/main/src/modules/xcm/transfer/utils/chain.ts +++ b/apps/main/src/modules/xcm/transfer/utils/chain.ts @@ -98,6 +98,7 @@ export const getXcmFormDefaults = (account: Account | null): XcmFormValues => { destAmount: "", destAddress: destAccount?.rawAddress ?? "", destAccount: destAccount, + bridgeProvider: null, } } diff --git a/apps/main/src/modules/xcm/transfer/utils/transfer.ts b/apps/main/src/modules/xcm/transfer/utils/transfer.ts index c88fda197f..1b3d61130b 100644 --- a/apps/main/src/modules/xcm/transfer/utils/transfer.ts +++ b/apps/main/src/modules/xcm/transfer/utils/transfer.ts @@ -16,9 +16,17 @@ 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 { XCM_BRIDGE_TAGS, XcmTags } from "@/states/transactions" +import { BRIDGE_PROVIDER_TAGS, XcmTags } from "@/states/transactions" import { toDecimal } from "@/utils/formatting" +/** + * Returns the primary bridge provider tag for a given route. + */ +export const getPrimaryBridgeTag = (route: AssetRoute): string | null => { + const tags = (route.tags ?? []) as string[] + return BRIDGE_PROVIDER_TAGS.find((tag) => tags.includes(tag)) ?? null +} + export enum XcmTransferStatus { Default = "DEFAULT", TransferValid = "TRANSFER_VALID", @@ -71,14 +79,21 @@ export const calculateTransferDestAmount = ( export const isBridgeAssetRoute = (route: AssetRoute | null): boolean => { const tags = (route?.tags ?? []) as XcmTags - return tags.some((tag) => XCM_BRIDGE_TAGS.includes(tag)) + return tags.some((tag) => BRIDGE_PROVIDER_TAGS.includes(tag)) } export const getXcmTransferArgs = ( account: Account | null, values: XcmFormValues, ): XcmTransferArgs => { - const { srcChain, srcAsset, destChain, destAsset, destAddress } = values + const { + srcChain, + srcAsset, + destChain, + destAsset, + destAddress, + bridgeProvider, + } = values const isValidPair = srcChain && srcAsset ? srcChain.assetsData.values().some((a) => a.asset.key === srcAsset.key) @@ -98,6 +113,7 @@ export const getXcmTransferArgs = ( : "", destAsset: isValidAsset ? destAsset.key : "", destChain: destChain?.key ?? "", + bridgeTag: bridgeProvider ?? undefined, } } diff --git a/apps/main/src/states/transactions.ts b/apps/main/src/states/transactions.ts index 8bdc9ac72d..e15a98c93c 100644 --- a/apps/main/src/states/transactions.ts +++ b/apps/main/src/states/transactions.ts @@ -3,6 +3,7 @@ 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 { ComponentType } from "react" import { TransactionReceipt } from "viem" import { create } from "zustand" @@ -15,7 +16,11 @@ import { export const XcmTag = tags.Tag export type XcmTags = Array -export const XCM_BRIDGE_TAGS: XcmTags = [XcmTag.Wormhole, XcmTag.Snowbridge] +export const BRIDGE_PROVIDER_TAGS: XcmTags = [ + XcmTag.Basejump, + XcmTag.Wormhole, + XcmTag.Snowbridge, +] export enum TransactionType { Onchain = "Onchain", @@ -57,6 +62,7 @@ type MultiTransactionConfig = ( | SingleTransactionInputDynamic ) & { stepTitle: string + pendingComponent?: ComponentType //@TODO consider separate all transaction actions per tx onSubmitted?: (txHash: string) => void } @@ -169,7 +175,7 @@ export const isSubstrateTxResult = ( export const isBridgeTransaction = (meta: TransactionMeta) => { return ( meta.type === TransactionType.Xcm && - meta.tags.some((tag) => XCM_BRIDGE_TAGS.includes(tag)) + meta.tags.some((tag) => BRIDGE_PROVIDER_TAGS.includes(tag)) ) } diff --git a/package.json b/package.json index b9cb902f85..f064e6b75c 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "@galacticcouncil/descriptors": "^1.15.0", "@galacticcouncil/sdk-next": "^0.40.0", "@galacticcouncil/xc": "^0.5.0", - "@galacticcouncil/xc-cfg": "^0.18.2", - "@galacticcouncil/xc-core": "^0.13.0", + "@galacticcouncil/xc-cfg": "0.19.0-pr304-f6c6ae3", + "@galacticcouncil/xc-core": "0.14.0-pr304-f6c6ae3", "@galacticcouncil/xc-scan": "^0.4.0", - "@galacticcouncil/xc-sdk": "^0.9.1", + "@galacticcouncil/xc-sdk": "0.9.2-pr304-f6c6ae3", "big.js": "^6.2.2", "date-fns": "^4.1.0", "immer": "^10.0.3", diff --git a/packages/ui/src/assets/icons/JetSki.svg b/packages/ui/src/assets/icons/JetSki.svg new file mode 100644 index 0000000000..b89a09bc0c --- /dev/null +++ b/packages/ui/src/assets/icons/JetSki.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/Swimmer.svg b/packages/ui/src/assets/icons/Swimmer.svg new file mode 100644 index 0000000000..90fe917f92 --- /dev/null +++ b/packages/ui/src/assets/icons/Swimmer.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 e02a530c94..e8d3c02831 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -13,6 +13,7 @@ export { default as Farm } from "./Farm.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 JetSki } from "./JetSki.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" @@ -28,6 +29,7 @@ export { default as StylizedAdd } from "./StylizedAdd.svg?react" export { default as SubScan } from "./SubScan.svg?react" export { default as SubSquare } from "./SubSquare.svg?react" export { default as SuppliedLiquidityIcon } from "./SuppliedLiquidityIcon.svg?react" +export { default as Swimmer } from "./Swimmer.svg?react" export { default as TriangleAlert } from "./TriangleAlert.svg?react" export { default as TwoColorClock } from "./TwoColorClock.svg?react" export { default as Union } from "./Union.svg?react" diff --git a/yarn.lock b/yarn.lock index ba07749d17..8725aa69a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2122,17 +2122,17 @@ "@thi.ng/memoize" "^4.0.2" big.js "^6.2.1" -"@galacticcouncil/xc-cfg@^0.18.2": - version "0.18.2" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-cfg/-/xc-cfg-0.18.2.tgz#6ded8d12a9d4491de77c0de68e2c33c5fdc695d3" - integrity sha512-vFd4Tpw3y/4NNiPRy+GpemirfOH9oEsGKrWJJi3eDGksBNPRq+yoCd0w/W4fQkIYJnln3PZWztWvNrl8KZ0/ug== +"@galacticcouncil/xc-cfg@0.19.0-pr304-f6c6ae3": + version "0.19.0-pr304-f6c6ae3" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-cfg/-/xc-cfg-0.19.0-pr304-f6c6ae3.tgz#0cc192549df835707a1afe28386cfc8b5546d105" + integrity sha512-zIWXLjfiFoSHWuNmGGxhGa3HeaI3+uI2PA8yhEztWNmD4XvWtqyQpN+0D5jXwIffJVIKXK/DX6YOwtFC4na03w== dependencies: - "@galacticcouncil/xc-core" "^0.13.0" + "@galacticcouncil/xc-core" "0.14.0-pr304-f6c6ae3" -"@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== +"@galacticcouncil/xc-core@0.14.0-pr304-f6c6ae3": + version "0.14.0-pr304-f6c6ae3" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-core/-/xc-core-0.14.0-pr304-f6c6ae3.tgz#886bea240c9a2b730eb28f0b546ae988148e5c39" + integrity sha512-FLOkdtDdxbRcf5ZhzfKi6a18eqBw3+IDBoCZTUM7nEyx43tno+Rs8AQhPeH2fsK+64dSNfj+fK3YOF2h+TYQdA== dependencies: "@noble/hashes" "^1.6.1" "@wormhole-foundation/sdk-base" "3.2.0" @@ -2153,12 +2153,12 @@ 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.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== +"@galacticcouncil/xc-sdk@0.9.2-pr304-f6c6ae3": + version "0.9.2-pr304-f6c6ae3" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-sdk/-/xc-sdk-0.9.2-pr304-f6c6ae3.tgz#4b57fe7239f1236ae2998584da36966f3cf6f199" + integrity sha512-n5HlAcrgOf/nh1XBzTpyAXuqU3wHPMIMLZaOqTUkl1iHVMhi5I1aSEYGMg8Eaw55+3fiQHq+Ff6u522M9dSBug== dependencies: - "@galacticcouncil/xc-core" "^0.13.0" + "@galacticcouncil/xc-core" "0.14.0-pr304-f6c6ae3" "@galacticcouncil/xc@^0.5.0": version "0.5.0"