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/i18n/locales/en/xcm.json b/apps/main/src/i18n/locales/en/xcm.json index 2d6107f8d1..87adf7342e 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 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) && ( { 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..d8f6adda5b 100644 --- a/apps/main/src/modules/xcm/history/XcJourneyCard.tsx +++ b/apps/main/src/modules/xcm/history/XcJourneyCard.tsx @@ -1,5 +1,6 @@ import { ArrowRight, + JetSki, QuestionCircleRegular, } from "@galacticcouncil/ui/assets/icons" import { @@ -13,7 +14,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 +24,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 +37,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 +54,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 +100,25 @@ export const XcJourneyCard: React.FC = (journey) => { {sentAt && ( - + + {originProtocol === "basejump" && ( + + + + + )} { 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/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/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..4c6cfb0648 --- /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" + +export 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..df8ed44c88 --- /dev/null +++ b/apps/main/src/modules/xcm/history/utils/basejump.ts @@ -0,0 +1,156 @@ +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 { isNumber } from "remeda" +import z from "zod" + +import { chainToUrn } from "@/modules/xcm/history/utils/optimistic" + +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 +} 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..37d7c0aef8 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 "" @@ -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, 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..7e40165a0d 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) => { @@ -298,6 +310,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..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..afad86bfab --- /dev/null +++ b/apps/main/src/modules/xcm/transfer/components/BridgeSelector/BridgeSelector.tsx @@ -0,0 +1,102 @@ +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_PRIORITY: Record = { + [XcmTag.Basejump]: 0, + [XcmTag.Wormhole]: 1, + [XcmTag.Snowbridge]: 2, +} + +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) + .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.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/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/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..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/routes/__root.tsx b/apps/main/src/routes/__root.tsx index d5c3a51802..dd4ab031cf 100644 --- a/apps/main/src/routes/__root.tsx +++ b/apps/main/src/routes/__root.tsx @@ -16,7 +16,10 @@ 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 { AssetsProvider } from "@/providers/assetsProvider" import { MultisigProvider } from "@/providers/MultisigProvider" import { RpcProvider, useRpcProvider } from "@/providers/rpcProvider" @@ -97,6 +100,7 @@ function ApiSubscriptions() { function AccountSubscriptions({ account }: { account: Account }) { useXcScanSubscription(account.address) + useBasejumpScanSubscription(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/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/packages/utils/src/helpers/basejumpscan.ts b/packages/utils/src/helpers/basejumpscan.ts new file mode 100644 index 0000000000..34f0370ede --- /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}/${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/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"