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:
47
package.json
47
package.json
@@ -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
2551
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
215
src/components/TokenInput.tsx
Normal file
215
src/components/TokenInput.tsx
Normal 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))
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
253
src/pages/Teleport/RoutePreview.tsx
Normal file
253
src/pages/Teleport/RoutePreview.tsx
Normal 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>
|
||||
)
|
||||
394
src/pages/Teleport/Setup.tsx
Normal file
394
src/pages/Teleport/Setup.tsx
Normal 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>
|
||||
)
|
||||
372
src/pages/Teleport/Submit.tsx
Normal file
372
src/pages/Teleport/Submit.tsx
Normal 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>
|
||||
)
|
||||
63
src/pages/Teleport/Teleport.tsx
Normal file
63
src/pages/Teleport/Teleport.tsx
Normal 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
|
||||
57
src/pages/Teleport/genesisToParaspell.ts
Normal file
57
src/pages/Teleport/genesisToParaspell.ts
Normal 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",
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user