feat: teleport integration with Paraspell (#153)

* integrate teleport with paraspell

* add some style

* finish up styles

* format token decimals, token input component

* fill in genesis to paraspell map

* add link to paraspell, fix invalid address on destination chain check

* codesplit teleport page

* add button to setup wallet

* chore: update dependencies

* add hash params state

* remove console logs, remove support for negative values in token input
This commit is contained in:
Victor Oliva
2026-06-11 11:32:44 +02:00
committed by GitHub
parent b91e589c75
commit 0a97404be2
13 changed files with 2898 additions and 1074 deletions

View File

@@ -14,42 +14,43 @@
"semi": false
},
"dependencies": {
"@acala-network/chopsticks-core": "^1.4.1",
"@ledgerhq/hw-transport-webusb": "^6.34.2",
"@acala-network/chopsticks-core": "^1.4.2",
"@ledgerhq/hw-transport-webusb": "^6.34.3",
"@monaco-editor/react": "^4.7.0",
"@paraspell/sdk": "^13.7.1",
"@polkadot-api/descriptors": "file:.papi/descriptors",
"@polkadot-api/json-rpc-provider-proxy": "^0.4.0",
"@polkadot-api/metadata-builders": "^0.14.3",
"@polkadot-api/observable-client": "^0.18.6",
"@polkadot-api/observable-client": "^0.18.7",
"@polkadot-api/react-builder": "0.5.3",
"@polkadot-api/react-components": "^0.4.2",
"@polkadot-api/substrate-bindings": "^0.20.3",
"@polkadot-api/tx-utils": "^0.3.3",
"@polkahub/ui-components": "^0.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.0",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-toggle": "^1.1.11",
"@radix-ui/react-toggle-group": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.9",
"@react-rxjs/core": "^0.10.8",
"@react-rxjs/utils": "^0.9.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb-keyval": "^6.2.4",
"idb-keyval": "^6.2.5",
"lucide-react": "^1.17.0",
"polkadot-api": "^2.1.4",
"polkadot-api": "^2.1.6",
"polkahub": "^0.7.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-portal": "^4.3.0",
"react-router-dom": "^7.16.0",
"react-router-dom": "^7.17.0",
"react-svg": "^17.2.4",
"react-virtuoso": "^4.18.7",
"react18-json-view": "^0.2.10",
@@ -62,8 +63,8 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/node": "^25.9.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-portal": "^4.0.7",
"@vitejs/plugin-react": "^6.0.2",
@@ -74,7 +75,7 @@
"prettier": "^3.8.3",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
"typescript-eslint": "^8.60.1",
"vite": "^8.0.16"
},
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",

2551
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,9 @@ import { RpcCalls } from "./pages/RpcCalls"
import { RuntimeCalls } from "./pages/RuntimeCalls"
import { Storage } from "./pages/Storage"
import { ViewFns } from "./pages/ViewFns"
import { lazy } from "react"
const Teleport = lazy(() => import("./pages/Teleport/Teleport"))
export default function App() {
return (
@@ -26,6 +29,7 @@ export default function App() {
<Route path="metadata/*" element={<Metadata />} />
<Route path="rpcCalls/*" element={<RpcCalls />} />
<Route path="accounts/*" element={<Accounts />} />
<Route path="teleport" element={<Teleport />} />
<Route path="*" element={<Navigate to="/explorer" replace />} />
</Routes>
</AppShell>

View File

@@ -19,6 +19,7 @@ export const SearchableSelect = <T,>({
className,
contentClassName,
allowCustomValue,
disabled,
}: {
value: T | null
setValue: (val: T | null) => void
@@ -27,6 +28,7 @@ export const SearchableSelect = <T,>({
contentClassName?: string
// only for T=string
allowCustomValue?: boolean
disabled?: boolean
}) => {
const [open, setOpen] = useState(false)
const [filterValue, setFilterValue] = useState("")
@@ -80,6 +82,7 @@ export const SearchableSelect = <T,>({
"flex w-52 justify-between overflow-hidden bg-input border border-border",
className,
)}
disabled={disabled}
>
{value ? (
<span className="text-ellipsis overflow-hidden">

View File

@@ -0,0 +1,215 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ChangeEvent, FC, useEffect, useRef, useState } from "react"
type TokenUnit = "token" | "planck"
export const TokenInput: FC<{
value: bigint | null
onValueChange: (value: bigint | null) => void
token?: {
decimals: number
symbol?: string
}
}> = ({ value, onValueChange, token }) => {
const [unit, setUnit] = useState<TokenUnit>(() =>
token ? "token" : "planck",
)
const userSelectedUnit = useRef(false)
const [isFocused, setIsFocused] = useState(false)
const [rawText, setRawText] = useState("")
useEffect(() => {
const nextUnit =
!token && unit === "token"
? "planck"
: token && !userSelectedUnit.current && unit === "planck"
? "token"
: unit
if (nextUnit === unit) return
setUnit(nextUnit)
if (isFocused) {
setRawText(
value == null ? "" : formatValue(value, nextUnit, token, false),
)
}
}, [token, unit, isFocused, value])
const displayValue = isFocused
? rawText
: value == null
? ""
: formatValue(value, unit, token, true)
const handleUnitChange = (nextUnit: TokenUnit) => {
if (nextUnit === "token" && !token) return
userSelectedUnit.current = true
setUnit(nextUnit)
setRawText(value == null ? "" : formatValue(value, nextUnit, token, false))
}
const handleFocus = () => {
setIsFocused(true)
setRawText(value == null ? "" : formatValue(value, unit, token, false))
}
const handleBlur = () => {
setIsFocused(false)
}
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
const input = evt.target.value
if (unit === "token" && token) {
const parsed = parseTokenInput(input, token.decimals)
if (!parsed.accepted) return
setRawText(parsed.text)
onValueChange(parsed.value)
return
}
const parsed = parsePlanckInput(input)
if (!parsed.accepted) return
setRawText(parsed.text)
onValueChange(parsed.value)
}
return (
<div className="flex min-w-0 items-center rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<input
type="text"
inputMode={unit === "token" && token?.decimals ? "decimal" : "numeric"}
value={displayValue}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
className="h-10 min-w-0 flex-1 rounded-l-md rounded-r-none border border-border border-r-0 bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<Select
value={unit}
onValueChange={(value) => handleUnitChange(value as TokenUnit)}
disabled={!token}
>
<SelectTrigger className="h-10 w-24 shrink-0 rounded-l-none bg-background text-xs font-medium focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{token ? (
<SelectItem value="token">{token.symbol ?? "Token"}</SelectItem>
) : null}
<SelectItem value="planck">Planck</SelectItem>
</SelectContent>
</Select>
</div>
)
}
const parsePlanckInput = (
value: string,
): {
accepted: boolean
text: string
value: bigint | null
} => {
const text = value.replace(/[,. ']/g, "")
if (text === "") {
return { accepted: true, text, value: null }
}
if (!/^\d+$/.test(text)) {
return { accepted: false, text, value: null }
}
return { accepted: true, text, value: BigInt(text) }
}
const parseTokenInput = (
value: string,
decimals: number,
): {
accepted: boolean
text: string
value: bigint | null
} => {
const tokenDecimals = normalizeDecimals(decimals)
const text = value.replace(/[, ']/g, "")
if (text === "" || text === ".") {
return { accepted: true, text, value: null }
}
if (!/^(?:\d+|\d*\.\d*)$/.test(text)) {
return { accepted: false, text, value: null }
}
const [whole = "0", fractional = ""] = text.split(".")
if (fractional.length > tokenDecimals) {
return { accepted: false, text, value: null }
}
const scale = getScale(tokenDecimals)
const wholePlancks = BigInt(whole || "0") * scale
const fractionalPlancks =
fractional.length > 0 ? BigInt(fractional.padEnd(tokenDecimals, "0")) : 0n
const plancks = wholePlancks + fractionalPlancks
return {
accepted: true,
text,
value: plancks,
}
}
const formatValue = (
value: bigint,
unit: TokenUnit,
token: { decimals: number; symbol?: string } | undefined,
withSeparators: boolean,
) => {
if (unit === "token" && token) {
return formatTokenInputValue(value, token.decimals, withSeparators)
}
return withSeparators ? formatIntegerWithSeparators(value) : value.toString()
}
const formatTokenInputValue = (
value: bigint,
decimals: number,
withSeparators: boolean,
) => {
const tokenDecimals = normalizeDecimals(decimals)
const scale = getScale(tokenDecimals)
const whole = value / scale
const fractional = value % scale
const wholeText = withSeparators
? formatIntegerWithSeparators(whole)
: whole.toString()
const fractionalText = tokenDecimals
? fractional.toString().padStart(tokenDecimals, "0").replace(/0+$/, "")
: ""
return `${wholeText}${fractionalText ? `.${fractionalText}` : ""}`
}
const formatIntegerWithSeparators = (value: bigint) => {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}
const normalizeDecimals = (decimals: number) =>
Number.isFinite(decimals) ? Math.max(0, Math.trunc(decimals)) : 0
const getScale = (decimals: number) =>
10n ** BigInt(normalizeDecimals(decimals))

View File

@@ -2,14 +2,16 @@ import {
GlobalCommandPalette,
type NavigationItem,
} from "@/components/GlobalCommandPalette"
import { HistoryDrawer, HistoryDrawerTrigger } from "@/components/Workspace"
import { GithubIcon } from "@/components/Icons"
import SliderToggle from "@/components/Toggle"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { HistoryDrawer, HistoryDrawerTrigger } from "@/components/Workspace"
import { Link } from "@/hashParams"
import { cn } from "@/lib/utils"
import { changeTheme, useTheme } from "@/ThemeProvider"
import {
ArrowRightLeft,
BookOpenText,
Cable,
DatabaseSearch,
@@ -26,7 +28,6 @@ import { FC, PropsWithChildren, useState } from "react"
import { useLocation } from "react-router-dom"
import { twMerge } from "tailwind-merge"
import { NetworkSwitcher } from "./Network/Network"
import { cn } from "@/lib/utils"
const navigationGroups: Array<{
label: string
@@ -91,6 +92,11 @@ const navigationGroups: Array<{
// Alt Wallet
icon: UserRound,
},
{
path: "/teleport",
label: "Teleport",
icon: ArrowRightLeft,
},
],
},
]

View File

@@ -387,7 +387,7 @@ export const SubmitExtrinsic = forwardRef<HTMLElement>((_, ref) => {
)
})
const SignerSetupDialog: FC = () => {
export const SignerSetupDialog: FC = () => {
const [open, setOpen] = useState(false)
const { contentStack, contextValue } = usePolkaHubModalState(() =>

View File

@@ -0,0 +1,253 @@
import { TXcmFeeBase } from "@paraspell/sdk"
import { formatToken } from "@polkadot-api/react-components"
import { useStateObservable } from "@react-rxjs/core"
import {
ArrowRight,
CheckCircle2,
CircleDot,
Loader2,
Route,
} from "lucide-react"
import { FC, ReactNode } from "react"
import { from, startWith, switchMap } from "rxjs"
import { paraspellBuilder$ } from "./Setup"
export const routeInfo$ = paraspellBuilder$.pipeState(
switchMap((builder) =>
builder
? from(
builder.getTransferInfo().catch((ex) => {
console.error(ex)
return null
}),
).pipe(startWith("loading" as const))
: [null],
),
)
export const RoutePreview = () => {
const routeInfo = useStateObservable(routeInfo$)
if (!routeInfo)
return (
<Panel title="Route preview">
<EmptyState
icon={<Route className="h-4 w-4" />}
title="Complete transfer setup"
description="Select an asset, destination, amount, and recipient to preview the route."
/>
</Panel>
)
if (routeInfo === "loading")
return (
<Panel title="Route preview">
<EmptyState
icon={<Loader2 className="h-4 w-4 animate-spin" />}
title="Loading route"
description="Fetching route and fee information."
/>
</Panel>
)
const routeNodes = [
routeInfo.chain.origin,
...routeInfo.hops.map((hop) => hop.chain),
routeInfo.chain.destination,
]
return (
<Panel title="Route preview">
<RoutePath nodes={routeNodes} />
<DetailGroup title="Origin">
<DetailRow label="Chain" value={routeInfo.chain.origin} />
<DetailRow
label="XCM fee"
value={<XcmFee fee={routeInfo.origin.xcmFee} />}
/>
</DetailGroup>
{routeInfo.hops.map((hop, i) => (
<DetailGroup title={`Hop ${i + 1}`} key={i}>
<DetailRow label="Chain" value={hop.chain} />
<DetailRow
label="XCM fee"
value={<XcmFee fee={hop.result.xcmFee} />}
/>
</DetailGroup>
))}
<DetailGroup title="Destination">
<DetailRow label="Chain" value={routeInfo.chain.destination} />
<DetailRow
label="XCM fee"
value={<XcmFee fee={routeInfo.destination.xcmFee} />}
/>
<DetailRow
label="Received"
value={
typeof routeInfo.destination.receivedCurrency.receivedAmount ===
"bigint"
? formatToken(
routeInfo.destination.receivedCurrency.receivedAmount,
routeInfo.destination.receivedCurrency.asset,
)
: "N/A"
}
/>
</DetailGroup>
</Panel>
)
}
const RoutePath: FC<{
nodes: Array<string>
}> = ({ nodes }) => {
if (nodes.length <= 2) return <DirectRoutePath nodes={nodes} />
return <MultiHopRoutePath nodes={nodes} />
}
const DirectRoutePath: FC<{
nodes: Array<string>
}> = ({ nodes }) => (
<ol className="space-y-2 @lg:flex @lg:items-stretch @lg:space-y-0 @lg:gap-1">
{nodes.map((node, i) => (
<li key={i} className="flex items-center gap-1 @lg:flex-1">
<RouteNodeCard label={node} kind={i === 0 ? "origin" : "destination"} />
{i < nodes.length - 1 ? (
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground @lg:block" />
) : null}
</li>
))}
</ol>
)
const MultiHopRoutePath: FC<{
nodes: Array<string>
}> = ({ nodes }) => (
<div className="rounded-md border border-border bg-background/60 p-3 space-y-3">
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<h3 className="font-medium uppercase tracking-wide">Route</h3>
<span className="rounded-md border border-border bg-background px-2 py-0.5">
{nodes.length - 2} hop{nodes.length === 3 ? "" : "s"}
</span>
</div>
<div className="relative">
{/* Timeline that goes through the icons */}
<div
className="absolute bottom-8 left-4 top-8 w-px bg-border"
aria-hidden
/>
<ol className="space-y-3">
{nodes.map((node, i) => {
const isFirst = i === 0
const isLast = i === nodes.length - 1
const kind = isFirst ? "origin" : isLast ? "destination" : "hop"
return (
<li
key={i}
className="relative grid grid-cols-[2rem_minmax(0,1fr)] items-center gap-3"
>
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-border bg-card text-muted-foreground">
<RouteNodeIcon kind={kind} />
</div>
<div className="flex min-h-16 items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
<div>
<div className="truncate text-sm font-medium">{node}</div>
<div className="truncate text-xs text-muted-foreground">
{isFirst ? "Origin" : isLast ? "Destination" : `Hop ${i}`}
</div>
</div>
</div>
</li>
)
})}
</ol>
</div>
</div>
)
const RouteNodeCard: FC<{
label: string
kind: "origin" | "hop" | "destination"
}> = ({ label, kind }) => (
<div className="flex flex-1 items-center gap-3 rounded-md border border-border bg-background px-3 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-foreground/5 text-muted-foreground">
<RouteNodeIcon kind={kind} />
</div>
<div>
<div className="truncate text-sm font-medium">{label}</div>
<div className="truncate text-xs text-muted-foreground capitalize">
{kind}
</div>
</div>
</div>
)
const RouteNodeIcon: FC<{
kind: "origin" | "hop" | "destination"
}> = ({ kind }) => {
if (kind === "origin") return <CircleDot className="h-4 w-4" />
if (kind === "destination") return <CheckCircle2 className="h-4 w-4" />
return <Route className="h-4 w-4" />
}
const XcmFee: FC<{
fee: TXcmFeeBase
}> = ({ fee }) => (
<span className="truncate tabular-nums">
{formatToken(fee.fee, fee.asset)}
</span>
)
const Panel: FC<{
title: string
children: ReactNode
}> = ({ title, children }) => (
<section className="min-w-0 rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<div className="space-y-4 p-4">{children}</div>
</section>
)
const EmptyState: FC<{
icon: ReactNode
title: string
description: string
}> = ({ icon, title, description }) => (
<div className="rounded-md border border-dashed border-border bg-background/60 p-4">
<div className="flex items-start gap-3 text-sm">
<div className="mt-0.5 text-muted-foreground">{icon}</div>
<div>
<div className="font-medium">{title}</div>
<div className="mt-1 text-muted-foreground">{description}</div>
</div>
</div>
</div>
)
const DetailGroup: FC<{
title: string
children: ReactNode
}> = ({ title, children }) => (
<section className="space-y-2 rounded-md border border-border bg-background/60 p-3">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</h3>
<div className="space-y-2">{children}</div>
</section>
)
const DetailRow: FC<{
label: string
value: ReactNode
}> = ({ label, value }) => (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 wrap-break-word text-right">{value}</span>
</div>
)

View File

@@ -0,0 +1,394 @@
import { SearchableSelect } from "@/components/Select"
import { TokenInput } from "@/components/TokenInput"
import { getHashParams, setHashParams } from "@/hashParams"
import { createState } from "@/lib/externalState"
import { client$ } from "@/state/chains/chain.state"
import { selectedAccount$ } from "@/state/polkahub"
import {
Builder,
getAssets,
getBalance,
getSupportedDestinations,
InvalidAddressError,
TAssetInfo,
TChain,
} from "@paraspell/sdk"
import { formatToken } from "@polkadot-api/react-components"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import {
Banknote,
CircleAlert,
Coins,
MapPin,
Send,
UserRound,
Wallet,
} from "lucide-react"
import { jsonSerialize } from "polkadot-api/utils"
import { AddressInput } from "polkahub"
import { FC, PropsWithChildren, ReactNode } from "react"
import {
catchError,
combineLatest,
concat,
defer,
filter,
from,
map,
ObservableInput,
startWith,
switchMap,
take,
tap,
withLatestFrom,
} from "rxjs"
import { SignerSetupDialog } from "../Extrinsics/SubmitTx/SubmitExtrinsic"
import { SelectAccount } from "../Extrinsics/SubmitTx/SubmitTxForm"
import { genesisHashToParaspell } from "./genesisToParaspell"
export const origin$ = client$.pipeState(
switchMap((client) => client.getChainSpecData().then((r) => r.genesisHash)),
map((genesis) => genesisHashToParaspell[genesis.slice(0, 10)] ?? null),
)
export const Setup = () => {
return (
<section className="min-w-0 rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Transfer setup</h2>
</div>
<div className="space-y-3 p-4">
<SetupCard title="Origin chain" icon={<MapPin className="h-4 w-4" />}>
<OriginChain />
</SetupCard>
<SetupCard title="Sender" icon={<Wallet className="h-4 w-4" />}>
<div className="flex items-center gap-1">
<SelectAccount />
<SignerSetupDialog />
</div>
</SetupCard>
<SetupCard title="Asset" icon={<Coins className="h-4 w-4" />}>
<AssetPicker />
</SetupCard>
<SetupCard
title="Destination chain"
icon={<Send className="h-4 w-4" />}
>
<DestPicker />
</SetupCard>
<SetupCard title="Amount" icon={<Banknote className="h-4 w-4" />}>
<AmountPicker />
</SetupCard>
<SetupCard title="Recipient" icon={<UserRound className="h-4 w-4" />}>
<RecipientPicker />
</SetupCard>
</div>
</section>
)
}
const OriginChain = () => {
const origin = useStateObservable(origin$)
return (
<div className="rounded-md border border-border bg-background px-3 py-2">
<div className="truncate text-sm font-medium">{origin}</div>
<div className="text-xs text-muted-foreground">Connected origin</div>
</div>
)
}
const initialHashParams = getHashParams()
const assetKey = (info: TAssetInfo) =>
JSON.stringify(info.location, jsonSerialize)
const assetList$ = origin$.pipeState(
map((origin) => (origin ? getAssets(origin) : [])),
map((list) =>
Object.fromEntries(list.map((asset) => [assetKey(asset), asset])),
),
)
const [changeAsset$, changeAsset] = createSignal<TAssetInfo>()
const selectedAsset$ = state(
combineLatest([
assetList$,
concat(
// Try get from hashParams
assetList$.pipe(
take(1),
map((assets) => assets[initialHashParams.get("asset") ?? ""] ?? null),
),
changeAsset$,
),
]).pipe(
map(([list, asset]) => (asset ? (list[assetKey(asset)] ?? null) : null)),
tap((v) => setHashParams({ asset: v && assetKey(v) })),
),
)
const balance$ = selectedAsset$.pipeState(
switchMap((asset) =>
combineLatest([selectedAccount$, origin$, client$]).pipe(
switchMap(([selectedAccount, origin, client]) => {
if (!asset || !selectedAccount) return [null]
return getBalance({
address: selectedAccount.address,
chain: origin,
api: client,
currency: asset,
})
}),
),
),
)
const AssetPicker = () => {
const assets = useStateObservable(assetList$)
const selectedAsset = useStateObservable(selectedAsset$)
const balance = useStateObservable(balance$)
const selectedAssetKey = selectedAsset ? assetKey(selectedAsset) : null
// TODO explore custom assets
return (
<div className="space-y-2">
<SearchableSelect
value={selectedAssetKey}
setValue={(v) => {
if (v && assets[v]) changeAsset(assets[v])
}}
options={Object.entries(assets).map(([key, asset]) => ({
value: key,
text: asset.assetId
? `${asset.symbol} (${asset.assetId})`
: asset.symbol,
}))}
className="h-10 w-full bg-background"
contentClassName="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)]"
/>
<KeyValue
label="Balance"
value={
balance != null && selectedAsset
? formatToken(balance, selectedAsset)
: ""
}
/>
</div>
)
}
const supportedDestinations$ = state(
combineLatest([origin$, selectedAsset$]).pipe(
map(([origin, selectedAsset]) => {
if (!origin || !selectedAsset) return []
return getSupportedDestinations(origin, {
location: selectedAsset.location,
})
}),
),
)
const [changeDest$, changeDest] = createSignal<TChain>()
const selectedDest$ = state(
combineLatest([
supportedDestinations$,
changeDest$.pipe(
startWith((initialHashParams.get("destination") as TChain) ?? null),
),
]).pipe(
map(([list, dest]) => (list?.includes(dest) ? dest : null)),
tap((destination) => setHashParams({ destination })),
),
)
const selectedDestError$ = selectedDest$.pipeState(
withLatestFrom(client$, origin$, selectedAsset$, selectedAccount$),
switchMap(([dest, client, origin, asset, account]) =>
defer(() => {
if (!dest || !client || !origin || !asset || !account) return [null]
const tryWithRecipient = (address: string) =>
from(
Builder(client)
.from(origin)
.to(dest)
.currency({ location: asset.location, amount: 0n })
.recipient(address)
.sender(account.address)
.getTransferInfo(),
).pipe(map(() => null))
const mapError = catchError<string | null, ObservableInput<null>>(
(ex) => [ex.message ?? "Uknown error"],
)
return tryWithRecipient(account.address).pipe(
map(() => null),
catchError((ex) => {
if (ex instanceof InvalidAddressError) {
return recipient$.pipe(
filter((v) => v != null),
switchMap((v) =>
tryWithRecipient(v).pipe(startWith(null), mapError),
),
)
}
throw ex
}),
mapError,
)
}).pipe(startWith(null)),
),
withDefault(null),
)
const DestPicker = () => {
const chains = useStateObservable(supportedDestinations$)
const selectedDest = useStateObservable(selectedDest$)
const selectedAsset = useStateObservable(selectedAsset$)
const destError = useStateObservable(selectedDestError$)
// TODO explore custom chains
return (
<div className="space-y-2">
<SearchableSelect
value={selectedDest}
setValue={(v) => {
if (v) changeDest(v)
}}
options={chains.map((chain) => ({
value: chain,
text: chain,
}))}
disabled={!selectedAsset}
className={`h-10 w-full bg-background ${
destError
? "border-red-500/50 bg-red-500/5 text-red-950 dark:text-red-100"
: ""
}`}
contentClassName="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)]"
/>
{destError ? (
<div
className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-sm text-red-700 dark:text-red-300"
role="alert"
>
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<span>{destError}</span>
</div>
) : null}
</div>
)
}
const [amountChange$, changeAmount] = createSignal<bigint | null>()
const amount$ = state(
combineLatest([origin$, selectedAsset$.pipe(filter((v) => v != null))]).pipe(
switchMap((_, i) =>
// If it changes afterwards, reset to 0
i == 0 ? amountChange$ : amountChange$.pipe(startWith(0n)),
),
tap((amount) =>
setHashParams({ amount: amount == null ? null : String(amount) }),
),
),
BigInt(initialHashParams.get("amount") ?? "0"),
)
const AmountPicker = () => {
const amount = useStateObservable(amount$)
const selectedAsset = useStateObservable(selectedAsset$)
const token =
selectedAsset && typeof selectedAsset.decimals === "number"
? {
decimals: selectedAsset.decimals,
symbol: selectedAsset.symbol,
}
: undefined
return (
<TokenInput value={amount} onValueChange={changeAmount} token={token} />
)
}
const [recipient$, setRecipient] = createState<string | null>(
initialHashParams.get("recipient") ?? null,
)
recipient$.subscribe((recipient) => setHashParams({ recipient }))
const RecipientPicker = () => {
const recipient = useStateObservable(recipient$)
return (
<AddressInput
className="max-w-full"
triggerClassName="h-10 bg-background"
value={recipient}
onChange={setRecipient}
disableClear
/>
)
}
export const setupConfig$ = state(
combineLatest([
origin$,
selectedAccount$,
selectedAsset$,
selectedDest$,
amount$,
recipient$,
]).pipe(
map(([origin, account, asset, dest, amount, recipient]) => {
if (
!origin ||
!account ||
!asset ||
!dest ||
amount == null ||
!recipient
)
return null
return { origin, account, asset, dest, amount, recipient }
}),
),
null,
)
export const paraspellBuilder$ = setupConfig$.pipeState(
withLatestFrom(client$),
map(([v, client]) =>
v
? Builder({
abstractDecimals: false,
apiOverrides: {
[v.origin]: client,
},
})
.from(v.origin)
.to(v.dest)
.currency({ location: v.asset.location, amount: v.amount })
.recipient(v.recipient)
.sender(v.account.address)
: null,
),
)
const SetupCard: FC<
PropsWithChildren<{
title: string
icon?: ReactNode
}>
> = ({ title, icon, children }) => (
<section className="rounded-md border border-border bg-background/60 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{icon ? <span className="text-muted-foreground">{icon}</span> : null}
<span>{title}</span>
</div>
{children}
</section>
)
const KeyValue: FC<{ label: string; value: ReactNode }> = ({
label,
value,
}) => (
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right font-mono">{value}</span>
</div>
)

View File

@@ -0,0 +1,372 @@
import { Link } from "@/hashParams"
import {
TDryRunChainResult,
TDryRunResult,
TPapiTransaction,
} from "@paraspell/sdk"
import { formatToken } from "@polkadot-api/react-components"
import { Button } from "@polkahub/ui-components"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import {
CheckCircle2,
CircleAlert,
ExternalLink,
Loader2,
LockKeyhole,
Play,
TriangleAlert,
XCircle,
} from "lucide-react"
import { PolkadotClient } from "polkadot-api"
import { jsonSerialize, toHex } from "polkadot-api/utils"
import { useSelectedAccount } from "polkahub"
import { FC, ReactNode } from "react"
import {
catchError,
filter,
from,
map,
startWith,
switchMap,
withLatestFrom,
} from "rxjs"
import { trackTx } from "../Extrinsics/ExtrinsicsWorkspaceEntry"
import { routeInfo$ } from "./RoutePreview"
import { paraspellBuilder$, setupConfig$ } from "./Setup"
export const Submit = () => {
return (
<section className="min-w-0 rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Validation & submit</h2>
</div>
<div className="space-y-4 p-4">
<Validation />
<DryRun />
<Export />
</div>
</section>
)
}
const Validation = () => {
const setupConfig = useStateObservable(setupConfig$)
const routeInfo = useStateObservable(routeInfo$)
const formatChecks = setupConfig ? !!routeInfo : null
const balanceFeeChecks =
routeInfo && routeInfo !== "loading"
? routeInfo.origin.xcmFee.sufficient &&
(typeof routeInfo.destination.receivedCurrency.receivedAmount ===
"bigint"
? routeInfo.destination.receivedCurrency.receivedAmount > 0n
: null)
: null
return (
<section className="space-y-3 rounded-md border border-border bg-background/60 p-3">
<SectionTitle>Validation</SectionTitle>
<div className="space-y-2">
<ValidationRow label="Format checks" value={formatChecks} />
<ValidationRow label="Balances & fees" value={balanceFeeChecks} />
</div>
</section>
)
}
const [dryRun$, dryRun] = createSignal()
const dryRunResult$ = state(
dryRun$.pipe(
withLatestFrom(paraspellBuilder$),
map(([, b]) => b),
filter((v) => v != null),
switchMap((builder) =>
from(builder.dryRun()).pipe(
map((value) => ({ type: "success" as const, value })),
startWith({ type: "loading" as const }),
catchError((ex: any) => [
{ type: "error" as const, value: ex.message },
]),
),
),
),
null,
)
const DryRun = () => {
const builder = useStateObservable(paraspellBuilder$)
const dryRunResult = useStateObservable(dryRunResult$)
return (
<section className="space-y-3 rounded-md border border-border bg-background/60 p-3">
<div className="flex items-center justify-between gap-3">
<SectionTitle>Dry run</SectionTitle>
<Button
type="button"
onClick={() => dryRun()}
disabled={!builder || dryRunResult?.type === "loading"}
className="inline-flex h-8 items-center gap-2 rounded-md px-3 text-xs"
>
{dryRunResult?.type === "loading" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
Dry run
</Button>
</div>
<div>
{!dryRunResult ? (
<div className="rounded-md border border-dashed border-border p-3 text-sm text-muted-foreground">
Not run
</div>
) : dryRunResult.type === "loading" ? (
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Running simulation
</div>
) : dryRunResult.type === "success" ? (
<DryRunResult result={dryRunResult.value} />
) : (
<DryRunError message={dryRunResult.value} />
)}
</div>
</section>
)
}
const DryRunError: FC<{ message: string }> = ({ message }) => (
<div
className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-700 dark:text-red-300"
role="alert"
>
<div className="flex items-start gap-2">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 space-y-1">
<div className="font-medium">Dry run failed</div>
<code className="block whitespace-pre-wrap wrap-break-word rounded-md border border-red-500/20 bg-background/70 px-2 py-1.5 text-xs text-red-800 dark:text-red-200">
{message || "Unknown error"}
</code>
</div>
</div>
</div>
)
const DryRunResult: FC<{ result: TDryRunResult }> = ({ result }) => (
<div className="space-y-3">
<DryRunSection title="Origin">
<DryRunChainResult result={result.origin} />
</DryRunSection>
{result.hops.map((hop, i) => (
<DryRunSection title={hop.chain} key={i}>
<DryRunChainResult result={hop.result} />
</DryRunSection>
))}
{result.destination ? (
<DryRunSection title="Destination">
<DryRunChainResult result={result.destination} />
</DryRunSection>
) : null}
</div>
)
const DryRunChainResult: FC<{ result: TDryRunChainResult }> = ({ result }) => {
if (!result.success) {
return (
<div className="space-y-2 text-sm">
<StatusBadge value={false} />
<div className="text-red-700 dark:text-red-300">
{result.failureReason}
</div>
{result.failureSubReason ? <div>{result.failureSubReason}</div> : null}
</div>
)
}
return (
<div className="space-y-2 text-sm">
<StatusBadge value />
<DetailRow label="Fee" value={formatToken(result.fee, result.asset)} />
{result.destParaId != null ? (
<DetailRow label="Dest ParaId" value={result.destParaId} />
) : null}
{result.forwardedXcms &&
(!Array.isArray(result.forwardedXcms) ||
result.forwardedXcms.length > 0) ? (
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Forwarded XCM
</div>
<code className="block max-h-32 overflow-auto rounded-md border border-border bg-foreground/5 p-2 text-xs whitespace-pre-wrap break-all">
{JSON.stringify(result.forwardedXcms, jsonSerialize, 2)}
</code>
</div>
) : null}
</div>
)
}
const resultingTransactions$ = paraspellBuilder$.pipeState(
switchMap(
(builder) =>
builder?.buildAll().catch((ex) => {
console.error(ex)
return null
}) ?? [null],
),
switchMap(async (transactions) => {
if (!transactions) return null
const encodedDatas = await Promise.all(
transactions.map(({ tx }) => tx.getEncodedData()),
)
return transactions.map(({ tx, api, chain }, i) => ({
tx,
api,
chain,
encodedData: encodedDatas[i],
}))
}),
withDefault(null),
)
const Export = () => {
const resultTransactions = useStateObservable(resultingTransactions$)
return (
<div className="space-y-3">
<DevelopmentDisclaimer />
{resultTransactions ? (
resultTransactions.map((result, i) => (
<ExportTx
key={i}
encodedData={result.encodedData}
tx={result.tx}
api={result.api === resultTransactions[0].api ? null : result.api}
number={resultTransactions.length > 1 ? i + 1 : null}
/>
))
) : (
<Button
type="button"
disabled
className="flex h-10 w-full items-center justify-center gap-2 rounded-md text-sm font-semibold"
>
<LockKeyhole className="h-4 w-4" />
Sign and submit
</Button>
)}
</div>
)
}
const DevelopmentDisclaimer = () => (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-sm text-amber-800 dark:text-amber-200">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<div className="font-medium">This feature is under development</div>
<div className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
Verify the generated call data before signing.
</div>
</div>
</div>
)
const ExportTx: FC<{
tx: TPapiTransaction
encodedData: Uint8Array
api?: PolkadotClient | null
number?: number | null
}> = ({ tx, encodedData, api, number }) => {
const [account] = useSelectedAccount()
const submit = async () => {
if (!account?.signer) return
const signed = await tx.sign(account.signer)
trackTx(signed, tx.decodedCall, account)
}
return (
<div className="space-y-3">
{number != null ? <div>#{number}</div> : null}
<Button
type="button"
disabled={!account?.signer}
onClick={submit}
className="flex h-10 w-full items-center justify-center gap-2 rounded-md text-sm font-semibold"
>
<LockKeyhole className="h-4 w-4" />
Sign and submit
</Button>
{api ? null : (
<Link
to={`/extrinsics/#data=${toHex(encodedData)}`}
className="flex h-10 w-full items-center justify-center gap-2 rounded-md border border-border bg-background text-sm font-medium text-foreground hover:bg-foreground/5"
>
<ExternalLink className="h-4 w-4" />
Open in extrinsics
</Link>
)}
</div>
)
}
const SectionTitle: FC<{ children: ReactNode }> = ({ children }) => (
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{children}
</h3>
)
const ValidationRow: FC<{ label: string; value: boolean | null }> = ({
label,
value,
}) => (
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<StatusBadge value={value} />
</div>
)
const StatusBadge: FC<{ value: boolean | null }> = ({ value }) => {
if (value == null) {
return (
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
<CircleAlert className="h-3 w-3" />
Not run
</span>
)
}
return value ? (
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-3 w-3" />
Passed
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300">
<XCircle className="h-3 w-3" />
Failed
</span>
)
}
const DryRunSection: FC<{
title: string
children: ReactNode
}> = ({ title, children }) => (
<section className="space-y-2 rounded-md border border-border/70 bg-background/70 p-3">
<h4 className="text-sm font-medium">{title}</h4>
{children}
</section>
)
const DetailRow: FC<{
label: string
value: ReactNode
}> = ({ label, value }) => (
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 wrap-break-word text-right font-mono">
{value}
</span>
</div>
)

View File

@@ -0,0 +1,63 @@
import { withSubscribe } from "@/components/withSuspense"
import { useStateObservable } from "@react-rxjs/core"
import { AlertTriangle } from "lucide-react"
import { CenteredScrollContainer } from "../AppShell"
import { RoutePreview } from "./RoutePreview"
import { origin$, Setup } from "./Setup"
import { Submit } from "./Submit"
const Teleport = withSubscribe(() => {
const origin = useStateObservable(origin$)
if (!origin) {
// TODO explore custom chains
return (
<CenteredScrollContainer className="p-4">
<div className="mx-auto mt-8 max-w-lg rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-800 dark:text-amber-200">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<h1 className="font-semibold">Chain not supported</h1>
<p className="mt-1 text-amber-800/80 dark:text-amber-200/80">
Teleport is currently available only for chains supported by
Paraspell.
</p>
</div>
</div>
</div>
</CenteredScrollContainer>
)
}
return (
<CenteredScrollContainer>
<div className="flex min-h-full flex-col gap-4 p-4">
<header className="flex flex-col gap-3 @lg:flex-row @lg:items-start @lg:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Teleport</h1>
<p className="text-sm text-muted-foreground">
Send assets from the connected chain to a supported destination.
</p>
</div>
<a
href="https://paraspell.xyz/"
target="_blank"
rel="noreferrer"
className="w-fit shrink-0 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
>
Powered by Paraspell
</a>
</header>
<div className="grid min-h-0 gap-4 items-start @5xl:grid-cols-[minmax(0,1fr)_minmax(21rem,0.85fr)] @7xl:grid-cols-[minmax(0,1.05fr)_minmax(24rem,0.95fr)]">
<Setup />
<div className="flex min-w-0 flex-col gap-4">
<RoutePreview />
<Submit />
</div>
</div>
</div>
</CenteredScrollContainer>
)
})
export default Teleport

View File

@@ -0,0 +1,57 @@
import { TChain } from "@paraspell/sdk"
export const genesisHashToParaspell: Record<string, TChain> = {
"0xfc41b9bd": "Acala",
"0xe358eb1d": "Ajuna",
"0x48239ef6": "AssetHubKusama",
"0xd6eec261": "AssetHubPaseo",
"0x68d56f15": "AssetHubPolkadot",
"0x67f97233": "AssetHubWestend",
"0x9eb76c51": "Astar",
"0xa85cfb9b": "Basilisk",
"0x9f28c6a6": "BifrostKusama",
"0xec39b15e": "BifrostPaseo",
"0x262e1b2a": "BifrostPolkadot",
"0x00dcb981": "BridgeHubKusama",
"0xcc624979": "BridgeHubPaseo",
"0xdcf691b5": "BridgeHubPolkadot",
"0x0441383e": "BridgeHubWestend",
"0xb3db4142": "Centrifuge",
"0x46ee89aa": "Collectives",
"0x713daf19": "CollectivesWestend",
"0x638cd2b9": "CoretimeKusama",
"0xc806038c": "CoretimePaseo",
"0xefb56e30": "CoretimePolkadot",
"0xf938510e": "CoretimeWestend",
"0x4319cc49": "Crust",
"0xd4c0c08c": "CrustShadow",
"0xf0b8924b": "Darwinia",
"0x7dd99936": "Encointer",
"0x5a51e04b": "EnergyWebX",
"0x2fc8bb6e": "Heima",
"0x40d175ca": "HeimaPaseo",
"0xafdc188f": "Hydration",
"0x5f52a76d": "HydrationPaseo",
"0xbf88efe7": "Interlay",
"0xbb9233e2": "Jamton",
"0xbaf5aabe": "Karura",
"0x9af9a64e": "Kintsugi",
"0xb0a8d493": "Kusama",
"0xfe58ea77": "Moonbeam",
"0x401a1f9d": "Moonriver",
"0xe7e09623": "NeuroWeb",
"0xf2b8faef": "NeuroWebPaseo",
"0x77afd619": "Paseo",
"0x5d3c2986": "Pendulum",
"0xafb18a62": "Penpal",
"0xc1af4cb4": "PeopleKusama",
"0xe6c30d6e": "PeoplePaseo",
"0x67fa177a": "PeoplePolkadot",
"0x1eb6fb0b": "PeopleWestend",
"0x91b171bb": "Polkadot",
"0x29f4371d": "RobonomicsPolkadot",
"0xf1cf9022": "Shiden",
"0x84322d9c": "Unique",
"0xe143f238": "Westend",
"0xb2985e77": "Xode",
}

View File

@@ -210,6 +210,7 @@ export const chainClient$ = state(
getMetadata: (id) => firstValueFrom(getMetadata(id)),
setMetadata,
})
const chainHead: ChainHead$ = (client as any).___INTERNAL_DO_NOT_USE
return concat(
i === 0 ? EMPTY : of(SUSPENSE),