diff --git a/apps/main/src/api/xcm.ts b/apps/main/src/api/xcm.ts index 3c11d64dcf..7e24135ca7 100644 --- a/apps/main/src/api/xcm.ts +++ b/apps/main/src/api/xcm.ts @@ -135,6 +135,7 @@ export type XcmTransferArgs = { readonly destAddress: string readonly destAsset: string readonly destChain: string + readonly bridgeTag?: string } export const xcmTransferQuery = ( @@ -146,6 +147,7 @@ export const xcmTransferQuery = ( destAddress, destChain, destAsset, + bridgeTag, }: XcmTransferArgs, options?: UseQueryOptions, ) => { @@ -162,17 +164,28 @@ 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, - }), + + // Do not pass invalid/stale dest asset + const validDstAsset = builder.routes.some( + (r) => r.destination.asset.key === destAsset, + ) + ? destAsset + : undefined + + return builder.build({ + srcAddress, + dstAddress: destAddress, + dstAsset: validDstAsset, + tag: bridgeTag, + }) + }, enabled: !!srcAddress && !!destAddress && diff --git a/apps/main/src/components/RelativeDateText/RelativeDateText.tsx b/apps/main/src/components/RelativeDateText/RelativeDateText.tsx index 0947063a97..14e89c985d 100644 --- a/apps/main/src/components/RelativeDateText/RelativeDateText.tsx +++ b/apps/main/src/components/RelativeDateText/RelativeDateText.tsx @@ -4,13 +4,15 @@ import { useRelativeDate } from "@/hooks/useRelativeDate" export type RelativeDateTextProps = TextProps & { date: Date + shortFormat?: boolean } export const RelativeDateText: React.FC = ({ date, + shortFormat = false, ...props }) => { - const dateString = useRelativeDate(date) + const dateString = useRelativeDate(date, { shortFormat }) return {dateString} } diff --git a/apps/main/src/hooks/useRelativeDate.ts b/apps/main/src/hooks/useRelativeDate.ts index 2f958722af..fc737df817 100644 --- a/apps/main/src/hooks/useRelativeDate.ts +++ b/apps/main/src/hooks/useRelativeDate.ts @@ -4,15 +4,24 @@ import { useState } from "react" import { useTranslation } from "react-i18next" import { useHarmonicIntervalFn } from "react-use" -export function useRelativeDate(date: Date) { +type UseRelativeDateOptions = { + shortFormat?: boolean +} + +export function useRelativeDate( + date: Date, + { shortFormat = false }: UseRelativeDateOptions = {}, +) { const { t } = useTranslation("common") const [dateString, setDateString] = useState(() => - t("date.relative", { value: date }), + t(shortFormat ? "date.relative.short" : "date.relative", { value: date }), ) useHarmonicIntervalFn(() => { - setDateString(t("date.relative", { value: date })) + setDateString( + t(shortFormat ? "date.relative.short" : "date.relative", { value: date }), + ) }, getUpdateInterval(date)) return dateString diff --git a/apps/main/src/i18n/locales/en/common.json b/apps/main/src/i18n/locales/en/common.json index 7e1d4f795c..29f71d15d4 100644 --- a/apps/main/src/i18n/locales/en/common.json +++ b/apps/main/src/i18n/locales/en/common.json @@ -260,7 +260,7 @@ "date.relative.short": "{{ value, relative(format: 'short') }}", "date.time": "{{ value, date(format: 'HH:mm') }}", "date.datetime": "{{ value, date(format: 'dd/MM/yyyy HH:mm:ss') }}", - "date.datetime.short": "{{ value, date(format: 'dd/MM/yyyy HH:mm') }}", + "date.datetime.short": "{{ value, date(format: 'dd/MM/yy HH:mm') }}", "date.long": "{{ value, date(format: 'MMMM do, yyyy') }}", "date.day": "{{ value, date(format: 'MMM dd') }}", "date.daytime": "{{ value, date(format: 'MMM dd yyyy, HH:mm') }}", diff --git a/apps/main/src/i18n/locales/en/xcm.json b/apps/main/src/i18n/locales/en/xcm.json index 2d6107f8d1..ac06d63a1a 100644 --- a/apps/main/src/i18n/locales/en/xcm.json +++ b/apps/main/src/i18n/locales/en/xcm.json @@ -4,8 +4,11 @@ "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", "chainAssetSelect.button.selectAssetChain": "Select asset & chain", "chainAssetSelect.emptyState.noAssets": "No assets found", "chainAssetSelect.modal.title": "Chain & asset", diff --git a/apps/main/src/modules/transactions/hooks/useTransactionToastProcessorFn.ts b/apps/main/src/modules/transactions/hooks/useTransactionToastProcessorFn.ts index 912e19b92f..f785ad596a 100644 --- a/apps/main/src/modules/transactions/hooks/useTransactionToastProcessorFn.ts +++ b/apps/main/src/modules/transactions/hooks/useTransactionToastProcessorFn.ts @@ -1,3 +1,4 @@ +import { useAccount } from "@galacticcouncil/web3-connect" import { useQueryClient } from "@tanstack/react-query" import { useMemo } from "react" @@ -6,6 +7,7 @@ import { createToastProcessorFn } from "@/modules/transactions/utils/toasts" import { useRpcProvider } from "@/providers/rpcProvider" export const useTransactionToastProcessorFn = () => { + const { account } = useAccount() const queryClient = useQueryClient() const indexerClient = useIndexerClient() const snowbridgeClient = useSnowbridgeClient() @@ -13,7 +15,13 @@ export const useTransactionToastProcessorFn = () => { return useMemo( () => - createToastProcessorFn(queryClient, indexerClient, snowbridgeClient, evm), - [queryClient, indexerClient, snowbridgeClient, evm], + createToastProcessorFn( + account?.address ?? "", + queryClient, + indexerClient, + snowbridgeClient, + evm, + ), + [account?.address, queryClient, indexerClient, snowbridgeClient, evm], ) } 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 d54cf97a5e..20760dff16 100644 --- a/apps/main/src/modules/transactions/review/ReviewTransactionSummary.tsx +++ b/apps/main/src/modules/transactions/review/ReviewTransactionSummary.tsx @@ -6,7 +6,7 @@ import { Text, } from "@galacticcouncil/ui/components" import { getToken } from "@galacticcouncil/ui/utils" -import { HYDRATION_CHAIN_KEY } from "@galacticcouncil/utils" +import { HYDRATION_CHAIN_KEY, isValidBigSource } from "@galacticcouncil/utils" import { useAccount, useActiveMultisigConfig, @@ -135,19 +135,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) && ( { case type === TransactionType.Xcm && tags.includes(XcmTag.Snowbridge): return ToastProcessorType.Snowbridge + case type === TransactionType.Xcm && tags.includes(XcmTag.Basejump): + return ToastProcessorType.Basejump + default: return ToastProcessorType.Unknown } } export const createToastProcessorFn = ( + address: string, queryClient: QueryClient, indexerSdk: IndexerSdk, snowbridgeSdk: SnowbridgeSdk, @@ -83,6 +89,7 @@ export const createToastProcessorFn = ( const evmProcessor = processors.evm(queryClient, indexerSdk, evm) const wormholeProcessor = processors.wormhole(queryClient, indexerSdk, evm) const snowbridgeProcessor = processors.snowbridge(queryClient, snowbridgeSdk) + const basejumpProcessor = processors.basejump(address, queryClient) const invalidProcessor = processors.invalid() return async (toast) => { @@ -102,6 +109,8 @@ export const createToastProcessorFn = ( return wormholeProcessor(toast) case ToastProcessorType.Snowbridge: return snowbridgeProcessor(toast) + case ToastProcessorType.Basejump: + return basejumpProcessor(toast) case ToastProcessorType.Unknown: return invalidProcessor(toast) } diff --git a/apps/main/src/modules/transactions/utils/toasts/processors.ts b/apps/main/src/modules/transactions/utils/toasts/processors.ts index 0840309e9e..1673ed4478 100644 --- a/apps/main/src/modules/transactions/utils/toasts/processors.ts +++ b/apps/main/src/modules/transactions/utils/toasts/processors.ts @@ -11,17 +11,21 @@ import { TransferStatusToPolkadotQuery, } from "@galacticcouncil/indexer/snowbridge" import { + basejumpscan, HexString, HYDRATION_CHAIN_KEY, snowbridgescan, + stringEquals, wormholescan, } from "@galacticcouncil/utils" import { CallType } from "@galacticcouncil/xc-core" +import { XcJourney } from "@galacticcouncil/xc-scan" import { QueryClient } from "@tanstack/react-query" import { first } from "remeda" import { PublicClient } from "viem" import { getWormholeHashByExtrinsicIndex } from "@/modules/transactions/utils/wormhole" +import { createBasejumpScanQueryKey } from "@/modules/xcm/history/useBasejumpScan" import { ToastData } from "@/states/toasts" type ToastStatus = { @@ -286,10 +290,39 @@ const snowbridge = } } +const basejump = + (address: string, queryClient: QueryClient): ToastProcessorFn => + async (toast) => { + const bjScanJourneys = queryClient.getQueryData( + createBasejumpScanQueryKey(address), + ) + const completedJourney = bjScanJourneys?.find( + (journey) => + stringEquals(journey.originTxPrimary ?? "", toast.meta.txHash) && + journey.status === "received", + ) + if (completedJourney) { + return { + status: "success", + link: basejumpscan.tx(completedJourney.correlationId), + processed: true, + dateUpdated: completedJourney.recvAt + ? new Date(completedJourney.recvAt).toISOString() + : new Date().toISOString(), + } + } + return { + status: "unknown", + processed: false, + dateUpdated: new Date().toISOString(), + } + } + export const processors = { evm, substrate, wormhole, snowbridge, + basejump, invalid, } as const diff --git a/apps/main/src/modules/xcm/XcmPage.tsx b/apps/main/src/modules/xcm/XcmPage.tsx index 114b4fae4c..4cc20643e6 100644 --- a/apps/main/src/modules/xcm/XcmPage.tsx +++ b/apps/main/src/modules/xcm/XcmPage.tsx @@ -12,9 +12,9 @@ export const XcmPage = () => { const address = account?.address ?? "" const claimable = useClaimableTransactions() - const { data: all, dataUpdatedAt } = useXcScan(address) + const { data: all, isLoading: isLoadingXcScan } = useXcScan(address) - const isLoading = !!account && dataUpdatedAt === 0 + const isLoading = !!account && isLoadingXcScan const isTwoColTemplate = !!account && (all.length > 0 || isLoading) return ( diff --git a/apps/main/src/modules/xcm/history/XcJourneyCard.tsx b/apps/main/src/modules/xcm/history/XcJourneyCard.tsx index 2648dc8776..8ac5b46cec 100644 --- a/apps/main/src/modules/xcm/history/XcJourneyCard.tsx +++ b/apps/main/src/modules/xcm/history/XcJourneyCard.tsx @@ -1,5 +1,7 @@ import { ArrowRight, + Basejumper, + ExternalLinkIcon, QuestionCircleRegular, } from "@galacticcouncil/ui/assets/icons" import { @@ -13,7 +15,7 @@ import { Text, } from "@galacticcouncil/ui/components" import { getToken } from "@galacticcouncil/ui/utils" -import { xcscan } from "@galacticcouncil/utils" +import { basejumpscan, xcscan } from "@galacticcouncil/utils" import type { XcJourney } from "@galacticcouncil/xc-scan" import Big from "big.js" import { useTranslation } from "react-i18next" @@ -23,6 +25,7 @@ import { ClaimButton } from "@/modules/xcm/history/components/ClaimButton" import { JourneyAssetLogo } from "@/modules/xcm/history/components/JourneyAssetLogo" import { JourneyChainLogo } from "@/modules/xcm/history/components/JourneyChainLogo" import { JourneyDate } from "@/modules/xcm/history/components/JourneyDate" +import { JourneyProtocol } from "@/modules/xcm/history/components/JourneyProtocol" import { JourneyStatus } from "@/modules/xcm/history/components/JourneyStatus" import { usePendingClaimsStore } from "@/modules/xcm/history/hooks/usePendingClaimsStore" import { @@ -35,8 +38,15 @@ import { isOptimisticJourney } from "@/modules/xcm/history/utils/optimistic" import { toDecimal } from "@/utils/formatting" export const XcJourneyCard: React.FC = (journey) => { - const { origin, destination, sentAt, correlationId, status, totalUsd } = - journey + const { + origin, + destination, + sentAt, + correlationId, + status, + totalUsd, + originProtocol, + } = journey const { t } = useTranslation(["common", "xcm"]) const { pendingCorrelationIds } = usePendingClaimsStore() @@ -45,7 +55,10 @@ export const XcJourneyCard: React.FC = (journey) => { const transferAsset = getTransferAsset(journey) const { from, to } = getFormattedAddresses(journey) - const link = xcscan.tx(correlationId) + const link = + originProtocol === "basejump" + ? basejumpscan.tx(correlationId) + : xcscan.tx(correlationId) const isNotPending = !pendingCorrelationIds.includes(journey.correlationId) const isClaimable = isNotPending && isJourneyClaimable(journey) @@ -88,8 +101,25 @@ export const XcJourneyCard: React.FC = (journey) => { {sentAt && ( - + + {originProtocol === "basejump" && ( + + + + + )} = (journey) => { {isClaimable && } {isOptimisticJourney(journey) ? ( ) : ( )} diff --git a/apps/main/src/modules/xcm/history/XcScanHistoryTable.columns.tsx b/apps/main/src/modules/xcm/history/XcScanHistoryTable.columns.tsx index 31ecf8c672..aecdbf5fbd 100644 --- a/apps/main/src/modules/xcm/history/XcScanHistoryTable.columns.tsx +++ b/apps/main/src/modules/xcm/history/XcScanHistoryTable.columns.tsx @@ -7,7 +7,7 @@ import { Text, } from "@galacticcouncil/ui/components" import { getToken } from "@galacticcouncil/ui/utils" -import { stringEquals, xcscan } from "@galacticcouncil/utils" +import { basejumpscan, stringEquals, xcscan } from "@galacticcouncil/utils" import type { XcJourney } from "@galacticcouncil/xc-scan" import { createColumnHelper } from "@tanstack/react-table" import Big from "big.js" @@ -188,7 +188,7 @@ export const useXcScanHistoryColumns = () => { const durationMs = recvAt - sentAt - if (durationMs < 0) { + if (durationMs <= 0) { return null } @@ -203,11 +203,13 @@ export const useXcScanHistoryColumns = () => { const actionColumn = columnHelper.display({ id: XcScanHistoryTableColumnId.Action, cell: ({ row }) => { - const link = xcscan.tx(row.original.correlationId) + const { correlationId, originProtocol } = row.original + const link = + originProtocol === "basejump" + ? basejumpscan.tx(correlationId) + : xcscan.tx(correlationId) - const isNotPending = !pendingCorrelationIds.includes( - row.original.correlationId, - ) + const isNotPending = !pendingCorrelationIds.includes(correlationId) const isClaimable = isNotPending && isJourneyClaimable(row.original) return ( diff --git a/apps/main/src/modules/xcm/history/XcScanJourneyList.tsx b/apps/main/src/modules/xcm/history/XcScanJourneyList.tsx index af8b00031a..4be83add05 100644 --- a/apps/main/src/modules/xcm/history/XcScanJourneyList.tsx +++ b/apps/main/src/modules/xcm/history/XcScanJourneyList.tsx @@ -27,13 +27,12 @@ export const XcScanJourneyList = ({ data, pageSize = 10 }: Props) => { {paginatedData.map((journey) => ( ))} + - - ) } diff --git a/apps/main/src/modules/xcm/history/XcScanJourneyListSkeleton.tsx b/apps/main/src/modules/xcm/history/XcScanJourneyListSkeleton.tsx index eb75d44c47..eed82d6110 100644 --- a/apps/main/src/modules/xcm/history/XcScanJourneyListSkeleton.tsx +++ b/apps/main/src/modules/xcm/history/XcScanJourneyListSkeleton.tsx @@ -49,7 +49,13 @@ const JourneyCardSkeleton = () => { export const XcScanJourneyListSkeleton = () => { return ( - + {Array.from({ length: 4 }, (_, i) => ( ))} diff --git a/apps/main/src/modules/xcm/history/components/JourneyDate.tsx b/apps/main/src/modules/xcm/history/components/JourneyDate.tsx index b3ddc8c463..9743e54b92 100644 --- a/apps/main/src/modules/xcm/history/components/JourneyDate.tsx +++ b/apps/main/src/modules/xcm/history/components/JourneyDate.tsx @@ -24,7 +24,7 @@ export const JourneyDate: React.FC = ({ if (isWithin24Hours) { return ( - + ) } diff --git a/apps/main/src/modules/xcm/history/hooks/useProcessBasejumpScanJourneys.ts b/apps/main/src/modules/xcm/history/hooks/useProcessBasejumpScanJourneys.ts new file mode 100644 index 0000000000..b30e572080 --- /dev/null +++ b/apps/main/src/modules/xcm/history/hooks/useProcessBasejumpScanJourneys.ts @@ -0,0 +1,95 @@ +import { + safeConvertPublicKeyToSS58, + safeConvertSS58toH160, + stringEquals, +} from "@galacticcouncil/utils" +import { XcJourney } from "@galacticcouncil/xc-scan" +import { differenceInMinutes } from "date-fns" +import { useEffect } from "react" +import { filter, map } from "rxjs" +import { decodeEventLog } from "viem" + +import { + createBasejumpScanQueryKey, + useBasejumpScan, +} from "@/modules/xcm/history/useBasejumpScan" +import { + getTransferAsset, + resolveAssetIcon, +} from "@/modules/xcm/history/utils/assets" +import { + createBjscanJourneyKey, + mapTransferExecutedLogs, + TransferExecutedEvt, +} from "@/modules/xcm/history/utils/basejump" +import { useRpcProvider } from "@/providers/rpcProvider" + +const isValidForProcessing = (journey: XcJourney) => + journey.status === "sent" && + differenceInMinutes(new Date(), new Date(journey.sentAt ?? 0)) < 10 + +export const useProcessBasejumpScanJourneys = (address: string) => { + const { papi, isLoaded, queryClient } = useRpcProvider() + const { data: journeys } = useBasejumpScan(address) + + return useEffect(() => { + if (!isLoaded || !journeys) return + const journeysToProcess = journeys.filter(isValidForProcessing) + if (journeysToProcess.length === 0) return + + const sub = papi.query.System.Events.watchValue("best") + .pipe( + map(mapTransferExecutedLogs), + filter((events) => events.length > 0), + ) + .subscribe((events) => { + for (const { signatureTopic, topics, data } of events) { + const decoded = decodeEventLog({ + abi: [TransferExecutedEvt], + topics: [signatureTopic, ...topics], + data, + }) + + const ss58 = safeConvertPublicKeyToSS58(decoded.args.recipient) + const h160 = safeConvertSS58toH160(ss58) + + const completedJourneyKey = createBjscanJourneyKey({ + recipient: h160, + asset: decoded.args.sourceAsset, + amount: decoded.args.amount.toString(), + }) + + const completedJourney = journeysToProcess.find((journey) => { + const transferAsset = getTransferAsset(journey) + if (!transferAsset) return false + const assetMeta = resolveAssetIcon(transferAsset.asset) + const journeyKey = createBjscanJourneyKey({ + recipient: journey.to, + asset: assetMeta?.assetId ?? "", + amount: transferAsset.amount, + }) + return stringEquals(journeyKey, completedJourneyKey) + }) + + if (completedJourney) { + const queryKey = createBasejumpScanQueryKey(address) + queryClient.setQueryData(queryKey, (prev) => { + if (!prev) return [] + return prev.map((item) => { + if (item.correlationId === completedJourney.correlationId) { + return { + ...item, + status: "received", + } + } + return item + }) + }) + } + } + }) + return () => { + sub.unsubscribe() + } + }, [address, isLoaded, journeys, papi, queryClient]) +} diff --git a/apps/main/src/modules/xcm/history/index.ts b/apps/main/src/modules/xcm/history/index.ts index 6f1bd6cf13..411cae4d6a 100644 --- a/apps/main/src/modules/xcm/history/index.ts +++ b/apps/main/src/modules/xcm/history/index.ts @@ -1,3 +1,4 @@ +export { useBasejumpScanSubscription } from "./useBasejumpScan" export { useXcScanSubscription } from "./useXcScan" export { XcScanHistory } from "./XcScanHistory" export { xcStore } from "./xcScanStore" diff --git a/apps/main/src/modules/xcm/history/lib/BasejumpScanSseClient.ts b/apps/main/src/modules/xcm/history/lib/BasejumpScanSseClient.ts new file mode 100644 index 0000000000..58dbaf83db --- /dev/null +++ b/apps/main/src/modules/xcm/history/lib/BasejumpScanSseClient.ts @@ -0,0 +1,63 @@ +import { + createQueryString, + safeConvertAnyToH160, + safeParse, +} from "@galacticcouncil/utils" + +import { + type BasejumpScanItem, + basejumpSseEventSchema, +} from "@/modules/xcm/history/utils/basejump" + +export type BasejumpScanSubscribeOptions = { + onCreate: (transfer: BasejumpScanItem) => void + onUpdate: (transfer: BasejumpScanItem) => void +} + +export class BasejumpScanSseClient { + private _baseUrl: string + private eventSource: EventSource | null = null + private onMessage: ((event: MessageEvent) => void) | null = null + + constructor(baseUrl: string) { + this._baseUrl = baseUrl + } + + subscribe(address: string, options: BasejumpScanSubscribeOptions): void { + this.unsubscribe() + + const h160 = safeConvertAnyToH160(address) + const url = `${this._baseUrl}/api/events${createQueryString({ address: h160 })}` + const eventSource = new EventSource(url) + this.eventSource = eventSource + + this.onMessage = (event: MessageEvent) => { + const data = safeParse(event.data) + const parsed = basejumpSseEventSchema.safeParse(data) + if (!parsed.success) return + + const { kind, transfer } = parsed.data + if (kind === "created") { + options.onCreate(transfer) + } + + if (kind === "updated") { + options.onUpdate(transfer) + } + } + + eventSource.addEventListener("created", this.onMessage) + eventSource.addEventListener("updated", this.onMessage) + } + + unsubscribe(): void { + if (!this.eventSource) return + if (this.onMessage) { + this.eventSource.removeEventListener("created", this.onMessage) + this.eventSource.removeEventListener("updated", this.onMessage) + } + this.eventSource.close() + this.eventSource = null + this.onMessage = null + } +} diff --git a/apps/main/src/modules/xcm/history/useBasejumpScan.ts b/apps/main/src/modules/xcm/history/useBasejumpScan.ts new file mode 100644 index 0000000000..1f2e36358a --- /dev/null +++ b/apps/main/src/modules/xcm/history/useBasejumpScan.ts @@ -0,0 +1,117 @@ +import { + basejumpscan, + createQueryString, + safeConvertAnyToH160, +} from "@galacticcouncil/utils" +import type { XcJourney } from "@galacticcouncil/xc-scan" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useEffect } from "react" +import { isNonNullish, sortBy } from "remeda" + +import { BasejumpScanSseClient } from "@/modules/xcm/history/lib/BasejumpScanSseClient" +import { + basejumpItemToXcJourney, + basejumpScanSchema, +} from "@/modules/xcm/history/utils/basejump" +import { removeOptimisticJourney } from "@/modules/xcm/history/utils/optimistic" + +const bjscan = new BasejumpScanSseClient(basejumpscan.baseUrl) + +export const createBasejumpScanQueryKey = (address: string) => [ + "basejumpscan", + address, +] + +export const useBasejumpScan = (address: string) => { + return useQuery({ + queryKey: createBasejumpScanQueryKey(address), + queryFn: async () => { + const res = await fetch( + `${basejumpscan.transfers}${createQueryString({ + address: safeConvertAnyToH160(address), + })}`, + ) + + if (!res.ok) { + throw new Error( + `BasejumpScan API error: ${res.status} ${res.statusText}`, + ) + } + + const data = await res.json() + const parsed = basejumpScanSchema.parse(data) + return parsed.items.map(basejumpItemToXcJourney).filter(isNonNullish) + }, + enabled: !!address, + staleTime: Infinity, + refetchOnWindowFocus: false, + retry: false, + }) +} + +const journeyDate = (j: XcJourney) => j.sentAt ?? j.createdAt ?? 0 + +function upsertBasejumpJourneyInCache( + prev: XcJourney[] | undefined, + journey: XcJourney, +): XcJourney[] { + const list = prev ?? [] + const filtered = list.filter( + (j) => + j.originTxPrimary !== journey.originTxPrimary && + j.correlationId !== journey.correlationId, + ) + return sortBy([...filtered, journey], [journeyDate, "desc"]) +} + +export const useBasejumpScanSubscription = (address: string) => { + const queryClient = useQueryClient() + + useEffect(() => { + if (!address) return + + bjscan.subscribe(address, { + onCreate(transfer) { + const journey = basejumpItemToXcJourney(transfer) + if (!journey) return + + const queryKey = createBasejumpScanQueryKey(address) + if (transfer.initiated?.txHash) { + removeOptimisticJourney( + queryClient, + address, + transfer.initiated.txHash, + ) + } + queryClient.setQueryData(queryKey, (old) => + upsertBasejumpJourneyInCache(old, journey), + ) + }, + onUpdate(transfer) { + const journey = basejumpItemToXcJourney(transfer) + if (!journey) return + + const queryKey = createBasejumpScanQueryKey(address) + queryClient.setQueryData(queryKey, (old) => { + const prev = old ?? [] + + const exists = prev.some( + (j) => j.correlationId === journey.correlationId, + ) + + if (!exists) { + return upsertBasejumpJourneyInCache(prev, journey) + } + + return prev.map((j) => + j.correlationId === journey.correlationId ? journey : j, + ) + }) + }, + }) + + return () => { + bjscan.unsubscribe() + } + }, [address, queryClient]) +} diff --git a/apps/main/src/modules/xcm/history/useXcScan.ts b/apps/main/src/modules/xcm/history/useXcScan.ts index 10b7747246..e0e57f7f87 100644 --- a/apps/main/src/modules/xcm/history/useXcScan.ts +++ b/apps/main/src/modules/xcm/history/useXcScan.ts @@ -1,10 +1,15 @@ import type { XcJourney } from "@galacticcouncil/xc-scan" import { useQuery, useQueryClient } from "@tanstack/react-query" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { getClaimableJourneys } from "@/modules/xcm/history/utils/claim" -import { isOptimisticJourneyForTxHash } from "@/modules/xcm/history/utils/optimistic" +import { mergeJourneys } from "@/modules/xcm/history/utils/journey" +import { + isOptimisticJourneyForTxHash, + shouldIgnoreNewJourney, +} from "@/modules/xcm/history/utils/optimistic" +import { useBasejumpScan } from "./useBasejumpScan" import { xcStore } from "./xcScanStore" export const createXcScanQueryKey = (address: string) => ["xcscan", address] @@ -16,7 +21,7 @@ type XcScanOptions = { export const useXcScan = (address: string, options: XcScanOptions = {}) => { const { claimableOnly } = options - return useQuery({ + const xcscan = useQuery({ queryKey: createXcScanQueryKey(address), enabled: !!address, staleTime: Infinity, @@ -26,6 +31,31 @@ export const useXcScan = (address: string, options: XcScanOptions = {}) => { select: claimableOnly ? getClaimableJourneys : undefined, queryFn: () => [], }) + + const bjscan = useBasejumpScan(address) + + const isLoadingXcScan = xcscan.dataUpdatedAt === 0 + const isLoading = claimableOnly + ? isLoadingXcScan + : isLoadingXcScan || bjscan.isLoading + + const data = useMemo(() => { + if (claimableOnly) return xcscan.data + if (bjscan.isSuccess && xcscan.isSuccess) + return mergeJourneys(bjscan.data, xcscan.data) + return xcscan.data + }, [ + bjscan.data, + bjscan.isSuccess, + claimableOnly, + xcscan.data, + xcscan.isSuccess, + ]) + + return { + isLoading, + data, + } } export const useXcScanSubscription = (address: string) => { @@ -57,6 +87,10 @@ export const useXcScanSubscription = (address: string) => { if (!old) { return [journey] } + + if (shouldIgnoreNewJourney(old, journey)) { + return old + } const prev = old.filter((item) => { const isOptimisticPrimary = isOptimisticJourneyForTxHash( item, diff --git a/apps/main/src/modules/xcm/history/utils/basejump.ts b/apps/main/src/modules/xcm/history/utils/basejump.ts new file mode 100644 index 0000000000..31a4cec610 --- /dev/null +++ b/apps/main/src/modules/xcm/history/utils/basejump.ts @@ -0,0 +1,209 @@ +import { HYDRATION_CHAIN_KEY, stringEquals } from "@galacticcouncil/utils" +import { chainsMap } from "@galacticcouncil/xc-cfg" +import { AnyChain, Asset } from "@galacticcouncil/xc-core" +import type { XcJourney } from "@galacticcouncil/xc-scan" +import { isNonNullish, isNumber } from "remeda" +import { Hex, parseAbiItem, toEventSelector } from "viem" +import z from "zod" + +import { chainToUrn } from "@/modules/xcm/history/utils/optimistic" +import { Papi } from "@/providers/rpcProvider" + +type SystemEvents = Awaited< + ReturnType +> + +export const BASEJUMP_LANDING_CONTRACT = + "0x70e9b12c3b19cb5f0e59984a5866278ab69df976" + +export const TransferExecutedEvt = parseAbiItem( + "event TransferExecuted(address indexed sourceAsset, address indexed destAsset, bytes32 indexed recipient, uint256 amount)", +) + +export const basejumpScanEventSchema = z.object({ + chain: z.string(), + txHash: z.string(), + logIndex: z.number(), + blockNumber: z.string(), + blockTimestamp: z.number(), +}) + +export const basejumpScanItemSchema = z.object({ + id: z.string(), + state: z.string(), + source_asset: z.string().nullable(), + source_chain: z.string().nullable(), + dest_asset: z.string().nullable(), + dest_chain: z.string().nullable(), + dest_chain_id: z.number().nullable(), + sender: z.string(), + recipient: z.string(), + gross_amount: z.string(), + fee: z.string(), + net_amount: z.string(), + transfer_sequence: z.string(), + message_sequence: z.string(), + pending_id: z.unknown().nullable(), + initiated: basejumpScanEventSchema.nullable(), + completed: basejumpScanEventSchema.nullable(), + fulfilled: basejumpScanEventSchema.nullable(), + queued: basejumpScanEventSchema.nullable(), + updated_at: z.string(), +}) + +export const basejumpScanSchema = z.object({ + items: z.array(basejumpScanItemSchema), + total: z.number(), +}) + +export const basejumpSseEventSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("created"), + transfer: basejumpScanItemSchema, + }), + z.object({ + kind: z.literal("updated"), + transfer: basejumpScanItemSchema, + }), +]) + +export type BasejumpScanEvent = z.infer +export type BasejumpScanItem = z.infer +export type BasejumpScan = z.infer +export type BasejumpSseEvent = z.infer + +export function resolveChain(chainKey: string | null): AnyChain | undefined { + if (!chainKey) return + return chainsMap.get(chainKey) +} + +export function resolveBasejumpAsset( + chainKey: string | null, + assetId: string | null, +): { asset: Asset; decimals: number; id: string } | undefined { + if (!chainKey || !assetId) return undefined + const chain = resolveChain(chainKey) + if (!chain) return undefined + + const asset = Array.from(chain.assetsData.values()).find( + (entry) => !!entry.id && stringEquals(entry.id.toString(), assetId), + ) + + if (!asset || !asset.id || !isNumber(asset.decimals)) return undefined + return { + asset: asset.asset, + decimals: asset.decimals, + id: asset.id.toString(), + } +} + +export function mapBasejumpState(state: string): string { + return state === "completed" ? "received" : "sent" +} + +export function parseBasejumpId(id: string): { + correlationId: string + id: number +} { + const tail = id.split("-").at(-1) + const parsed = Number(tail) + return { + correlationId: id, + id: Number.isNaN(parsed) ? 0 : parsed, + } +} + +export function basejumpItemToXcJourney( + item: BasejumpScanItem, +): XcJourney | undefined { + const sourceChain = resolveChain(item.source_chain) + const destChain = resolveChain(item.dest_chain || HYDRATION_CHAIN_KEY) + if (!sourceChain || !destChain) return undefined + + const resolvedAsset = resolveBasejumpAsset( + item.source_chain, + item.source_asset, + ) + if (!resolvedAsset) return undefined + + const originTxPrimary = item.initiated?.txHash + if (!originTxPrimary) return undefined + + const { id, correlationId } = parseBasejumpId(item.id) + const originUrn = chainToUrn(sourceChain) + const destinationUrn = chainToUrn(destChain) + + return { + id, + correlationId, + status: mapBasejumpState(item.state), + type: "transfer", + originProtocol: "basejump", + destinationProtocol: "basejump", + origin: originUrn, + destination: destinationUrn, + from: item.sender, + fromFormatted: item.sender, + to: item.recipient, + toFormatted: item.recipient, + sentAt: item?.initiated?.blockTimestamp, + createdAt: item?.initiated?.blockTimestamp || Date.parse(item.updated_at), + recvAt: item?.completed?.blockTimestamp, + stops: "", + instructions: "", + transactCalls: "", + originTxPrimary, + destinationTxPrimary: item.completed?.txHash, + totalUsd: 0, + assets: [ + { + asset: `${originUrn}|${resolvedAsset.id}`, + symbol: resolvedAsset.asset.originSymbol, + amount: item.net_amount, + decimals: resolvedAsset.decimals, + role: "transfer", + sequence: 0, + }, + ], + } satisfies XcJourney +} + +export function createBjscanJourneyKey({ + recipient, + asset, + amount, +}: { + recipient: string + asset: string + amount: string +}): string { + return `${recipient}-${asset}-${amount}`.toLowerCase() +} + +export function mapTransferExecutedLogs(events: SystemEvents) { + return events + .map(({ event }) => { + if ( + event.type === "EVM" && + event.value.type === "Log" && + event.value.value.log.address.asHex() === BASEJUMP_LANDING_CONTRACT + ) { + const [signatureTopic, ...topics] = event.value.value.log.topics.map( + (topic) => topic.asHex(), + ) as Hex[] + + const isTransferExecutedTopic = + !!signatureTopic && + stringEquals(signatureTopic, toEventSelector(TransferExecutedEvt)) + + return isTransferExecutedTopic + ? { + data: event.value.value.log.data.asHex(), + signatureTopic, + topics, + } + : undefined + } + }) + .filter(isNonNullish) +} diff --git a/apps/main/src/modules/xcm/history/utils/claim.ts b/apps/main/src/modules/xcm/history/utils/claim.ts index a323b26f9a..d0868a5ed0 100644 --- a/apps/main/src/modules/xcm/history/utils/claim.ts +++ b/apps/main/src/modules/xcm/history/utils/claim.ts @@ -28,7 +28,13 @@ import { SuiCall, SuiClaim, } from "@galacticcouncil/xc-sdk" -import { minutesToMilliseconds } from "date-fns" +import { + addMilliseconds, + fromUnixTime, + hoursToMilliseconds, + isWithinInterval, + minutesToMilliseconds, +} from "date-fns" import { isString } from "remeda" import { @@ -40,13 +46,16 @@ import { XcJourneyWhStop, } from "@/modules/xcm/history/utils/journey" -const CLAIM_THRESHOLD = minutesToMilliseconds(5) +const CLAIM_MIN_AGE_MS = minutesToMilliseconds(5) // 5 minutes +const CLAIM_MAX_AGE_MS = hoursToMilliseconds(24) * 7 * 2 // 2 weeks + +function isWithinClaimWindow(emittedAtSeconds: number) { + const emittedAt = fromUnixTime(emittedAtSeconds) -function hasExceededClaimThreshold(emittedAt: number) { - const now = Date.now() - const emittedAtMs = emittedAt * 1000 - const deadline = emittedAtMs + CLAIM_THRESHOLD - return now >= deadline + return isWithinInterval(new Date(), { + start: addMilliseconds(emittedAt, CLAIM_MIN_AGE_MS), + end: addMilliseconds(emittedAt, CLAIM_MAX_AGE_MS), + }) } export function isJourneyClaimable(journey: XcJourney): boolean { @@ -59,7 +68,7 @@ export function isJourneyClaimable(journey: XcJourney): boolean { const asset = getTransferAsset(journey) if (!asset) return false - return hasExceededClaimThreshold(vaaHeader.timestamp) + return isWithinClaimWindow(vaaHeader.timestamp) } export function getClaimableJourneys(journeys: XcJourney[]) { diff --git a/apps/main/src/modules/xcm/history/utils/journey.ts b/apps/main/src/modules/xcm/history/utils/journey.ts index 2a359add63..2f45270ce1 100644 --- a/apps/main/src/modules/xcm/history/utils/journey.ts +++ b/apps/main/src/modules/xcm/history/utils/journey.ts @@ -7,6 +7,7 @@ import { SpinnerIcon } from "@galacticcouncil/ui/components" import { ThemeToken } from "@galacticcouncil/ui/theme" import { isH160Address } from "@galacticcouncil/utils" import { XcJourney } from "@galacticcouncil/xc-scan" +import { isNonNullish, sortBy } from "remeda" export type TJourneyStatus = XcJourney["status"] @@ -94,3 +95,23 @@ export function getFormattedAddresses(journey: XcJourney) { return { from, to } } + +const journeyDate = (j: XcJourney) => j.sentAt ?? j.createdAt ?? 0 + +export function mergeJourneys( + existing: XcJourney[], + incoming: XcJourney[], +): XcJourney[] { + const seen = new Set( + existing.map((j) => j.originTxPrimary).filter(isNonNullish), + ) + const filtered = incoming.filter( + (j) => !j.originTxPrimary || !seen.has(j.originTxPrimary), + ) + + if (filtered.length === 0) { + return existing + } + + return sortBy([...existing, ...filtered], [journeyDate, "desc"]) +} diff --git a/apps/main/src/modules/xcm/history/utils/optimistic.ts b/apps/main/src/modules/xcm/history/utils/optimistic.ts index 25a913afdf..36fe5de173 100644 --- a/apps/main/src/modules/xcm/history/utils/optimistic.ts +++ b/apps/main/src/modules/xcm/history/utils/optimistic.ts @@ -11,6 +11,7 @@ import type { QueryClient } from "@tanstack/react-query" import { createXcScanQueryKey } from "@/modules/xcm/history/useXcScan" import type { XcmFormValues } from "@/modules/xcm/transfer/hooks/useXcmFormSchema" +import { XcmTag } from "@/states/transactions" import { scale } from "@/utils/formatting" const OPTIMISTIC_JOURNEY_PREFIX = "optimistic:" @@ -33,6 +34,26 @@ export function isOptimisticJourneyForTxHash( ) } +export function shouldIgnoreNewJourney( + previous: XcJourney[], + incoming: XcJourney, +): boolean { + return previous.some((journey) => { + const isOptimisticPrimary = isOptimisticJourneyForTxHash( + journey, + incoming.originTxPrimary ?? "", + ) + const isOptimisticSecondary = isOptimisticJourneyForTxHash( + journey, + incoming.originTxSecondary ?? "", + ) + return ( + journey.originProtocol === "basejump" && + (isOptimisticPrimary || isOptimisticSecondary) + ) + }) +} + export function chainToUrn(chain: AnyChain): string { const ecosystem = chain.ecosystem if (!ecosystem) return "" @@ -45,7 +66,7 @@ export function convertXcmFormValuesToOptimisticJourney( txHash: string, fromAddress: string, ): XcJourney | undefined { - const { srcChain, destChain, srcAsset, srcAmount, destAddress } = values + const { srcChain, destChain, srcAsset, destAmount, destAddress } = values const decimals = transfer.source.balance.decimals const now = Date.now() @@ -59,13 +80,16 @@ export function convertXcmFormValuesToOptimisticJourney( ? safeConvertSS58toH160(fromAddress) : fromAddress + const protocol = + values.bridgeProvider === XcmTag.Basejump ? "basejump" : "xcm" + return { id: 0, correlationId: getOptimisticJourneyId(txHash), status: "pending", type: "transfer", - originProtocol: "xcm", - destinationProtocol: "xcm", + originProtocol: protocol, + destinationProtocol: protocol, origin: originUrn, destination: destinationUrn, from, @@ -83,7 +107,7 @@ export function convertXcmFormValuesToOptimisticJourney( { asset: `${originUrn}|${assetId}`, symbol: srcAsset?.originSymbol ?? "", - amount: scale(srcAmount, decimals), + amount: scale(destAmount, decimals), decimals, role: "transfer", }, diff --git a/apps/main/src/modules/xcm/history/utils/protocols.ts b/apps/main/src/modules/xcm/history/utils/protocols.ts index bc92e8aa09..ab36b6736d 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 b2f660c2cd..24918c2f97 100644 --- a/apps/main/src/modules/xcm/transfer/XcmForm.tsx +++ b/apps/main/src/modules/xcm/transfer/XcmForm.tsx @@ -20,6 +20,7 @@ import { 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 +52,7 @@ export const XcmForm = () => { dryRunError, sourceChainAssetPairs, destChainAssetPairs, + availableBridgeRoutes, isLoading, isLoadingCall, isLoadingTransfer, @@ -92,6 +94,7 @@ export const XcmForm = () => { transfer, ) } + resetAmounts() }, onTransferError: (txHash) => { @@ -298,6 +301,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..70124f27bd 100644 --- a/apps/main/src/modules/xcm/transfer/XcmProvider.tsx +++ b/apps/main/src/modules/xcm/transfer/XcmProvider.tsx @@ -28,8 +28,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 +46,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(), @@ -67,7 +77,7 @@ export const XcmProvider: React.FC = ({ children }) => { const assetSource = config.asset(asset).source(chain) return assetSource.destinationChains.length > 0 }) - return { chain, routes: [], assets } + return { chain, routes: [], assets, isTagSelect: false } }) }, [config]) @@ -91,15 +101,35 @@ export const XcmProvider: React.FC = ({ children }) => { .map((a) => a.destination.chain) return unique(destChains).map((chain) => { - const { routes } = config + const { routes, destinationAssets, isTagSelect } = config .asset(srcAsset) .source(srcChain) .destination(chain) - return { chain, routes, assets: routes.map((r) => r.destination.asset) } + return { chain, routes, assets: destinationAssets, isTagSelect } }) }, [config, srcAsset, srcChain, configService]) + const destPair = destChainAssetPairs.find( + (p) => p.chain.key === destChain?.key, + ) + + useEffect(() => { + if (!destPair?.isTagSelect) { + form.setValue("bridgeProvider", null) + return + } + + if (destPair.routes.some((r) => getPrimaryBridgeTag(r) === bridgeProvider)) + return + + const defaultRoute = + destPair.routes.find((r) => getPrimaryBridgeTag(r) === XcmTag.Basejump) ?? + destPair.routes[0] + if (defaultRoute) + form.setValue("bridgeProvider", getPrimaryBridgeTag(defaultRoute)) + }, [destPair, bridgeProvider, form]) + useEffect(() => { const validRoutes = pipe( destChainAssetPairs, @@ -189,6 +219,7 @@ export const XcmProvider: React.FC = ({ children }) => { isConnectedAccountValid, sourceChainAssetPairs, destChainAssetPairs, + availableBridgeRoutes: destPair?.isTagSelect ? destPair.routes : [], 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..a8bd1953c8 --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.styled.ts @@ -0,0 +1,38 @@ +import { css } from "@emotion/react" +import styled from "@emotion/styled" + +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; + + color: ${theme.text.medium}; + + 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` + color: ${theme.text.high}; + background-color: ${theme.controls.dim.active}; + border-color: ${theme.controls.dim.active}; + ` + : css` + &:hover:not(:disabled) { + background-color: ${theme.buttons.outlineDark.rest}; + } + `} + `, +) 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..a96ccff03f --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.tsx @@ -0,0 +1,89 @@ +import { Basejumper, WormholeLogo } 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 { 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_PRIORITY: Record = { + [XcmTag.Basejump]: 0, + [XcmTag.Wormhole]: 1, + [XcmTag.Snowbridge]: 2, +} + +const BRIDGE_TIME_ESTIMATES: Partial> = { + [XcmTag.Basejump]: "~22 sec", + [XcmTag.Wormhole]: "~30 min", + [XcmTag.Snowbridge]: "~25 min", +} + +const BRIDGE_ICONS: Partial> = { + [XcmTag.Basejump]: Basejumper, + [XcmTag.Wormhole]: WormholeLogo, +} + +type BridgeSelectorProps = { + routes: AssetRoute[] +} + +export const BridgeSelector: React.FC = ({ routes }) => { + 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: tag, + time: BRIDGE_TIME_ESTIMATES[tag] ?? "", + icon: BRIDGE_ICONS[tag], + } satisfies BridgeOption + }) + .filter(isNonNullish) + .sort( + (a, b) => (BRIDGE_PRIORITY[a.id] ?? 99) - (BRIDGE_PRIORITY[b.id] ?? 99), + ) + + if (options.length < 2) return null + + return ( + + {options.map((option) => { + const active = bridgeProvider === option.id + return ( + setValue("bridgeProvider", option.id)} + > + + {option.icon && } + + {option.label} + + + + {option.time} + + + ) + })} + + ) +} 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/ChainAssetSelect/AssetListItem.styled.ts b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/AssetListItem.styled.ts index 889fe28a3e..16166594fc 100644 --- a/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/AssetListItem.styled.ts +++ b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/AssetListItem.styled.ts @@ -1,7 +1,11 @@ import { Flex } from "@galacticcouncil/ui/components" import { css, styled } from "@galacticcouncil/ui/utils" -export const SAssetListItem = styled(Flex)<{ isSelected: boolean }>( +export const SAssetListItem = styled(Flex, { + shouldForwardProp: (prop: string) => prop !== "isSelected", +})<{ + isSelected: boolean +}>( ({ theme, isSelected }) => css` justify-content: space-between; align-items: center; diff --git a/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/AssetListItem.tsx b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/AssetListItem.tsx index 8cd51c522a..4e56991a7d 100644 --- a/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/AssetListItem.tsx +++ b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/AssetListItem.tsx @@ -54,16 +54,23 @@ export const AssetListItem: React.FC = ({ const registryId = registryChain.getBalanceAssetId(asset) const registryAsset = getAsset(registryId.toString()) + const meta = registryAsset + ? { + symbol: registryAsset.symbol, + name: registryAsset.name, + } + : { + symbol: asset.originSymbol, + name: asset.originSymbol, + } + return ( {chain && } - + {route && isBridgeAssetRoute(route) && ( )} @@ -72,6 +79,7 @@ export const AssetListItem: React.FC = ({ 0n ? getToken("text.high") diff --git a/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelect.tsx b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelect.tsx index ac8f25c146..4eed14290a 100644 --- a/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelect.tsx +++ b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelect.tsx @@ -32,6 +32,7 @@ export type ChainAssetPair = { chain: AnyChain assets: Asset[] routes: AssetRoute[] + isTagSelect: boolean } export type ChainAssetSelection = { diff --git a/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelectButton.styled.ts b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelectButton.styled.ts index 0d27e6eccc..c96ecf512e 100644 --- a/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelectButton.styled.ts +++ b/apps/main/src/modules/xcm/transfer/components/ChainAssetSelect/ChainAssetSelectButton.styled.ts @@ -2,7 +2,9 @@ import { css } from "@emotion/react" import styled from "@emotion/styled" import { Button } from "@galacticcouncil/ui/components" -export const SButton = styled(Button)<{ +export const SButton = styled(Button, { + shouldForwardProp: (prop: string) => prop !== "hasSelection", +})<{ hasSelection: boolean disabled: boolean }>( diff --git a/apps/main/src/modules/xcm/transfer/components/FormField/AmountFormField.tsx b/apps/main/src/modules/xcm/transfer/components/FormField/AmountFormField.tsx index 912bb08dcf..fd28125f9d 100644 --- a/apps/main/src/modules/xcm/transfer/components/FormField/AmountFormField.tsx +++ b/apps/main/src/modules/xcm/transfer/components/FormField/AmountFormField.tsx @@ -70,12 +70,12 @@ export const AmountFormField: React.FC = ({ > {isLoading ? ( - - + + ) : ( <> - + {t("balance")}:{" "} {balance ? t("number", { @@ -99,10 +99,10 @@ export const AmountFormField: React.FC = ({ )} - {isLoading ? ( - - ) : ( - + + {isLoading ? ( + + ) : ( = ({ )} - - )} + )} + ) } 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..b1e7ed43e0 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useSubmitXcmTransfer.ts @@ -14,6 +14,7 @@ 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 { buildTransferCall } from "@/modules/xcm/transfer/utils/transfer" @@ -55,7 +56,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,13 +80,12 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { destChain: destChain.name, } - const { build } = ConfigBuilder(configService) + const { origin } = ConfigBuilder(configService) .assets() .asset(srcAsset) .source(srcChain) .destination(destChain) - - const { origin } = build(destAsset) + .build(destAsset, bridgeProvider ?? undefined) const call = await transfer.buildCall(srcAmount) const isApprove = isEvmApproveCall(call) @@ -92,10 +99,22 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { srcAmount, ) + const sourceFee = await transfer.estimateFee(srcAmount) + const tx = srcChain.key === HYDRATION_CHAIN_KEY ? await papi.txFromCallData(Binary.fromHex(transferCall.data)) : await getExternalChainTx(srcChain, transferCall) + + const sourceFeeValue = (() => { + if (sourceFee.amount === 0n) + return t("xcm:summary.feeEstimationNotAvailable") + return t("common:currency", { + value: toDecimal(sourceFee.amount, sourceFee.decimals), + symbol: sourceFee.originSymbol, + }) + })() + return { title: t("form.title"), description: t("tx.description", i18nVars), @@ -106,14 +125,14 @@ export const useSubmitXcmTransfer = (options: XcmTransferOptions = {}) => { success: t("tx.toast.success", i18nVars), }, fee: { - feeAmount: toDecimal(source.fee.amount, source.fee.decimals), - feeSymbol: source.fee.symbol, + feeAmount: toDecimal(sourceFee.amount, sourceFee.decimals), + feeSymbol: sourceFee.symbol, }, meta: { type: TransactionType.Xcm, srcChainKey: srcChain.key, - srcChainFee: toDecimal(source.fee.amount, source.fee.decimals), - srcChainFeeSymbol: source.fee.symbol, + srcChainFee: sourceFeeValue, + srcChainFeeSymbol: sourceFee.symbol, dstChainKey: destChain.key, dstChainFee: toDecimal( destination.fee.amount, @@ -164,6 +183,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..ad0fa9f955 100644 --- a/apps/main/src/modules/xcm/transfer/hooks/useXcmTransferConfigs.ts +++ b/apps/main/src/modules/xcm/transfer/hooks/useXcmTransferConfigs.ts @@ -12,6 +12,7 @@ export const useXcmTransferConfigs = ( 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 +38,5 @@ export const useXcmTransferConfigs = ( return null } - return build(destAsset) + return build(destAsset, bridgeProvider ?? undefined) } 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..ddcfff1213 100644 --- a/apps/main/src/modules/xcm/transfer/utils/transfer.ts +++ b/apps/main/src/modules/xcm/transfer/utils/transfer.ts @@ -3,6 +3,7 @@ import { formatSourceChainAddress, HexString, isEvmChain, + isValidBigSource, } from "@galacticcouncil/utils" import { Account } from "@galacticcouncil/web3-connect" import { AnyChain, Asset, AssetRoute } from "@galacticcouncil/xc-core" @@ -16,9 +17,14 @@ 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" +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", @@ -42,6 +48,7 @@ export const getTransferStatus = ( case !!transfer && transfer.source.min.amount >= transfer.source.max.amount: return XcmTransferStatus.InsufficientBalance case !values.srcAmount: + case !values.destAmount: return XcmTransferStatus.AmountMissing case alerts.length > 0: return XcmTransferStatus.TransferInvalid @@ -60,6 +67,7 @@ export const calculateTransferDestAmount = ( transfer: Transfer, ): string => { const { destinationFee } = transfer.source + if (!isValidBigSource(amount)) return "" if (asset.isEqual(destinationFee)) { const destFee = toDecimal(destinationFee.amount, destinationFee.decimals) const amountMinusFee = Big(amount || "0").minus(destFee) @@ -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/routes/__root.tsx b/apps/main/src/routes/__root.tsx index d5c3a51802..9692851853 100644 --- a/apps/main/src/routes/__root.tsx +++ b/apps/main/src/routes/__root.tsx @@ -16,7 +16,11 @@ import { DataProviderSelect } from "@/components/DataProviderSelect/DataProvider import { LayoutSkeleton } from "@/modules/layout/components/LayoutSkeleton" import { useHasTopNavbar } from "@/modules/layout/hooks/useHasTopNavbar" import { MainLayout } from "@/modules/layout/MainLayout" -import { useXcScanSubscription } from "@/modules/xcm/history" +import { + useBasejumpScanSubscription, + useXcScanSubscription, +} from "@/modules/xcm/history" +import { useProcessBasejumpScanJourneys } from "@/modules/xcm/history/hooks/useProcessBasejumpScanJourneys" import { AssetsProvider } from "@/providers/assetsProvider" import { MultisigProvider } from "@/providers/MultisigProvider" import { RpcProvider, useRpcProvider } from "@/providers/rpcProvider" @@ -97,6 +101,8 @@ function ApiSubscriptions() { function AccountSubscriptions({ account }: { account: Account }) { useXcScanSubscription(account.address) + useBasejumpScanSubscription(account.address) + useProcessBasejumpScanJourneys(account.address) return null } 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 a11a7546c9..81390cde7b 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "@galacticcouncil/descriptors": "^1.16.0", "@galacticcouncil/sdk-next": "^0.41.0", "@galacticcouncil/xc": "^0.6.0", - "@galacticcouncil/xc-cfg": "^0.19.0", - "@galacticcouncil/xc-core": "^0.14.0", + "@galacticcouncil/xc-cfg": "^0.20.0", + "@galacticcouncil/xc-core": "^0.15.0", "@galacticcouncil/xc-scan": "^0.5.0", - "@galacticcouncil/xc-sdk": "^0.10.1", + "@galacticcouncil/xc-sdk": "^0.11.0", "@polkadot-api/tx-utils": "^0.2.2", "big.js": "^6.2.2", "date-fns": "^4.1.0", diff --git a/packages/ui/src/assets/icons/Basejumper.svg b/packages/ui/src/assets/icons/Basejumper.svg new file mode 100644 index 0000000000..7b2eec9922 --- /dev/null +++ b/packages/ui/src/assets/icons/Basejumper.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/WormholeLogo.svg b/packages/ui/src/assets/icons/WormholeLogo.svg new file mode 100644 index 0000000000..45b5798d37 --- /dev/null +++ b/packages/ui/src/assets/icons/WormholeLogo.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..4f7343f1f3 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -1,4 +1,5 @@ export { default as ArrowRightLong } from "./ArrowRightLong.svg?react" +export { default as Basejumper } from "./Basejumper.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" @@ -32,4 +33,5 @@ export { default as TriangleAlert } from "./TriangleAlert.svg?react" export { default as TwoColorClock } from "./TwoColorClock.svg?react" export { default as Union } from "./Union.svg?react" export { default as Warning } from "./Warning.svg?react" +export { default as WormholeLogo } from "./WormholeLogo.svg?react" export * from "lucide-react" diff --git a/packages/ui/src/components/Input/NumberInput.tsx b/packages/ui/src/components/Input/NumberInput.tsx index a6e08ed493..5593c892a7 100644 --- a/packages/ui/src/components/Input/NumberInput.tsx +++ b/packages/ui/src/components/Input/NumberInput.tsx @@ -7,18 +7,24 @@ import { Input, InputProps } from "./Input" * For detailed props documentation * @see https://s-yadav.github.io/react-number-format/docs/numeric_format */ -export type NumberInputProps = NumericFormatProps & { +export type NumberInputProps = Omit< + NumericFormatProps, + "isAllowed" +> & { ref?: Ref keepInvalidInput?: boolean } -export const NumberInput: FC = ({ - ref, - value, - keepInvalidInput, - onValueChange, - ...props -}) => { +export const NumberInput: FC = (props) => { + const { + ref, + value, + keepInvalidInput, + onValueChange, + allowNegative = true, + ...rest + } = props + // string input allows to keep invalid values when typing -> e.g. 200 -> 00 -> 300 const [inputValue, setInputValue] = useState(value?.toString()) @@ -41,7 +47,16 @@ export const NumberInput: FC = ({ customInput={Input} allowedDecimalSeparators={[".", ","]} inputMode="decimal" - {...props} + allowNegative={allowNegative} + isAllowed={(values) => { + const s = values.value.replace(/\s+/g, "").replace(/,/g, ".") + if (s === "") return true + // disallow minus sign if negative number is not allowed + if (s === "-") return allowNegative + // disallow invalid numbers + return Number.isFinite(Number(s)) + }} + {...rest} /> ) } diff --git a/packages/ui/src/components/Notification/Notification.tsx b/packages/ui/src/components/Notification/Notification.tsx index bb748c9f96..af9ee28bd5 100644 --- a/packages/ui/src/components/Notification/Notification.tsx +++ b/packages/ui/src/components/Notification/Notification.tsx @@ -17,7 +17,7 @@ import { ExternalLink, Flex, Icon, - Spinner, + SpinnerIcon, Stack, Text, Tooltip, @@ -66,7 +66,7 @@ function renderBold(text: string) { } const notificationIcons: Record = { - pending: Spinner, + pending: SpinnerIcon, success: CircleCheck, error: CircleAlert, info: Info, diff --git a/packages/utils/src/helpers/basejumpscan.ts b/packages/utils/src/helpers/basejumpscan.ts new file mode 100644 index 0000000000..f055966d17 --- /dev/null +++ b/packages/utils/src/helpers/basejumpscan.ts @@ -0,0 +1,12 @@ +const BASEJUMPSCAN_URL = "https://bjscan-api.play.hydration.cloud" + +export const basejumpscan = { + baseUrl: BASEJUMPSCAN_URL, + transfers: `${BASEJUMPSCAN_URL}/api/transfers`, + link: (data: string | number): string => { + return `${BASEJUMPSCAN_URL}/${encodeURIComponent(data)}` + }, + tx: (id: string) => { + return basejumpscan.link(id) + }, +} diff --git a/packages/utils/src/helpers/index.ts b/packages/utils/src/helpers/index.ts index 5f6f1dbadf..b1cbfbec2c 100644 --- a/packages/utils/src/helpers/index.ts +++ b/packages/utils/src/helpers/index.ts @@ -1,5 +1,6 @@ export * from "./address" export * from "./array" +export * from "./basejumpscan" export * from "./big" export * from "./device" export * from "./evm" diff --git a/packages/utils/src/helpers/intl.ts b/packages/utils/src/helpers/intl.ts index 2ab12f81d8..f6d440e698 100644 --- a/packages/utils/src/helpers/intl.ts +++ b/packages/utils/src/helpers/intl.ts @@ -1,6 +1,7 @@ import Big, { BigSource } from "big.js" import { differenceInSeconds, + Duration, format as formatDateFns, FormatDistanceToken, formatDuration, @@ -323,11 +324,23 @@ export const formatRelativeTime = ( return "Now" } + const format: (keyof Duration)[] = (() => { + switch (true) { + case diffInSec < 60: + return ["seconds"] + case diffInSec < 24 * 60 * 60: + return ["hours", "minutes"] + case !!duration.years: + return ["years"] + case !!duration.months: + return ["months"] + default: + return ["days"] + } + })() + const formatted = formatDuration(duration, { - format: - diffInSec < 60 - ? ["seconds"] - : ["years", "months", "days", "hours", "minutes"], + format, locale: options?.format === "short" ? shortEnLocale : undefined, }) diff --git a/yarn.lock b/yarn.lock index fb9ff03208..13330fa552 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.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-cfg/-/xc-cfg-0.19.0.tgz#97a334f8188008462500331b36411d0234b1ebb3" - integrity sha512-K4QEuqqKiex0mTNVjqn8Y0J2UgVpogzEDvYNVhKYVC/1YLhQEYhSsOVE4Z9+V7H6zeF79xjuwkv6YYt9YeEUtA== +"@galacticcouncil/xc-cfg@^0.20.0": + version "0.20.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-cfg/-/xc-cfg-0.20.0.tgz#82292a2a8baa2a146f7c18cbb23dfe11ce01e249" + integrity sha512-X4Yw49arARtlRbI1BZ01LNohSvJQiEULdDDSKU89lXEoigqlr+FyZdUaZtVE1mK6Wkx07U+BY7ktCo2GX4gfpg== dependencies: - "@galacticcouncil/xc-core" "^0.14.0" + "@galacticcouncil/xc-core" "^0.15.0" -"@galacticcouncil/xc-core@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-core/-/xc-core-0.14.0.tgz#25ebdc5eed8c0e6f721316536139c428e9485b45" - integrity sha512-rLvCaSWtN07nKkn5Erh3vsIciKhh3Mc9avLchjFSxjqMijj0x6C3qIWdiYl54fFldaYU8f2nESvTcWcqTfdz6w== +"@galacticcouncil/xc-core@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-core/-/xc-core-0.15.0.tgz#c46f1226808b5f06d378cd1621380d5bc7cc87c5" + integrity sha512-YzBjGs/YX9U1pMNrULto7VgWozb0G0U7JzNcM2OxbwoY1X+76r5aykrkSzvliUFPfnw2LEaYW8RqXgB2klXGuQ== 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.5.0.tgz#2b8b9d48fc6d5ab9f5e5b09617e3ed84e1fa9f9c" integrity sha512-OS/TfBaToyHSN6VqlfDxjXnBeI4Nx03OBGNrlS7a6qlutGF28lqGFiQgzqEGRYo5iQcTV2/WmKcBsWaMOl31fg== -"@galacticcouncil/xc-sdk@^0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-sdk/-/xc-sdk-0.10.1.tgz#5f284e6b42b8d629bb846cee4b1d71bbd8a7c5b9" - integrity sha512-DMbDdm3dEWGsMvMWLHsn0bdHlJLyusPlGW/XkJC161N0dnMQEgGPedt0FNVIwsoayfqdRv8TeH1Tf/Pijb4cbQ== +"@galacticcouncil/xc-sdk@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@galacticcouncil/xc-sdk/-/xc-sdk-0.11.0.tgz#aca6788a53afde0f41c0eb0b0b33fa6f03f1fdc8" + integrity sha512-GN2ZVwcjhV0x5ftVukg/xDywu2ijJQ+JEdmtKdBjxBOKvBz4QHuO3A6rjz/KkUypezz8ECdHLWnumgKGH5wdmQ== dependencies: - "@galacticcouncil/xc-core" "^0.14.0" + "@galacticcouncil/xc-core" "^0.15.0" "@galacticcouncil/xc@^0.6.0": version "0.6.0"