diff --git a/src/components/pages/homepage/wallets/EmptyWalletsState.tsx b/src/components/pages/homepage/wallets/EmptyWalletsState.tsx index 779c6017..254d0df1 100644 --- a/src/components/pages/homepage/wallets/EmptyWalletsState.tsx +++ b/src/components/pages/homepage/wallets/EmptyWalletsState.tsx @@ -41,12 +41,15 @@ export default function EmptyWalletsState() { -
+
+
diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/index.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/index.tsx new file mode 100644 index 00000000..94fe6ef0 --- /dev/null +++ b/src/components/pages/homepage/wallets/import-wallet-flow/index.tsx @@ -0,0 +1,50 @@ +/** + * Import Wallet wizard — single-page, three internal steps. + * + * Mirrors the visual shell of new-wallet-flow but skips the NewWallet + * draft round trip: the import sources arrive with a fully-resolved + * wallet config (or, for CBOR paste, enough to reconstruct one), so we + * collect, review, then write straight to Wallet via importWallet. + */ + +import WalletFlowPageLayout from "@/components/pages/homepage/wallets/new-wallet-flow/shared/WalletFlowPageLayout"; +import ProgressIndicator from "@/components/pages/homepage/wallets/new-wallet-flow/shared/ProgressIndicator"; + +import { useWalletImportFlowState } from "./shared/useWalletImportFlowState"; +import SourceStep from "./source"; +import ReviewStep from "./review"; +import ReadyStep from "./ready"; + +const STEPS = [ + { label: "Source", description: "Choose where the wallet comes from" }, + { label: "Review", description: "Confirm signers and policy" }, + { label: "Ready", description: "Wallet appears in your sidebar" }, +] as const; + +export default function PageImportWallet() { + const flow = useWalletImportFlowState(); + const currentStep = flow.step === "source" ? 1 : flow.step === "review" ? 2 : 3; + + return ( +
+
+
+

+ Import Wallet +

+
+
+ +
+
+ {flow.step === "source" && } + {flow.step === "review" && } + {flow.step === "ready" && } +
+
+
+ ); +} + +// Re-export the layout in case future steps want to drop into it directly. +export { WalletFlowPageLayout }; diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/ready/index.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/ready/index.tsx new file mode 100644 index 00000000..48bc0c1b --- /dev/null +++ b/src/components/pages/homepage/wallets/import-wallet-flow/ready/index.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; +import { CheckCircle2 } from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +import type { WalletImportFlowState } from "../shared/useWalletImportFlowState"; + +interface Props { + flow: WalletImportFlowState; +} + +export default function ReadyStep({ flow }: Props) { + const walletId = flow.createdWalletId; + return ( + <> + + + + Wallet imported + + + +
+ +
+

+ Your imported wallet is ready to use. +

+

+ It resolves to the same on-chain address as the source — + balances, transactions, and governance state are derived + from chain on demand. +

+
+
+
+
+ +
+ + {walletId && ( + + )} +
+ + ); +} diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/review/index.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/review/index.tsx new file mode 100644 index 00000000..307e0e86 --- /dev/null +++ b/src/components/pages/homepage/wallets/import-wallet-flow/review/index.tsx @@ -0,0 +1,213 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { getFirstAndLast } from "@/utils/strings"; + +import type { WalletImportFlowState } from "../shared/useWalletImportFlowState"; + +interface Props { + flow: WalletImportFlowState; +} + +export default function ReviewStep({ flow }: Props) { + const { resolvedPayload, cborInput, sourceMeta } = flow; + + const config = resolvedPayload + ? { + name: resolvedPayload.name, + description: resolvedPayload.description, + signersAddresses: resolvedPayload.signersAddresses, + signersStakeKeys: resolvedPayload.signersStakeKeys, + signersDescriptions: resolvedPayload.signersDescriptions, + numRequiredSigners: resolvedPayload.numRequiredSigners, + scriptCbor: resolvedPayload.scriptCbor, + type: resolvedPayload.type, + } + : cborInput + ? { + name: cborInput.name, + description: cborInput.description, + signersAddresses: cborInput.signersAddresses, + signersStakeKeys: cborInput.signersStakeKeys, + signersDescriptions: cborInput.signersDescriptions, + numRequiredSigners: cborInput.numRequiredSigners, + scriptCbor: cborInput.scriptCbor, + type: cborInput.scriptType, + } + : null; + + if (!config || !sourceMeta) { + return ( + + + Nothing to review + + + Start over from the source step. +
+ +
+
+
+ ); + } + + return ( + <> + + + + + Wallet info + + + + + + + + + + + + Signers ({config.signersAddresses.length}) + + + + {config.signersAddresses.map((addr, i) => ( +
+
+ {config.signersDescriptions[i] || `Signer ${i + 1}`} +
+
+ {addr || "—"} +
+ {config.signersStakeKeys[i] && ( +
+ stake: {config.signersStakeKeys[i]} +
+ )} +
+ ))} +
+
+ + + + Script + + +

+ Importing this wallet will create a new local record that + resolves to the same on-chain address as the source. No funds + move and no transaction is signed. +

+

+ scriptCbor: {getFirstAndLast(config.scriptCbor, 24, 24)} +

+
+
+ +
+ + +
+ + ); +} + +function OriginPanel({ + sourceMeta, +}: { + sourceMeta: NonNullable; +}) { + return ( + + + Origin + + + {sourceMeta.source === "instance" && ( + <> + + + + + + )} + {sourceMeta.source === "json" && ( + <> + + + + + )} + {sourceMeta.source === "cbor" && ( + <> + + + + )} + + + ); +} + +function Row({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + // Stack on mobile (label above value) — long stake/script values mangle + // a side-by-side layout below ~480px. Side-by-side returns on sm+. + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function shortenAddr(a: string) { + if (a.length <= 30) return a; + return `${a.slice(0, 16)}…${a.slice(-10)}`; +} diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/shared/useWalletImportFlowState.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/shared/useWalletImportFlowState.tsx new file mode 100644 index 00000000..7588a5a4 --- /dev/null +++ b/src/components/pages/homepage/wallets/import-wallet-flow/shared/useWalletImportFlowState.tsx @@ -0,0 +1,248 @@ +/** + * useWalletImportFlowState + * + * Cross-step state for the import-wallet wizard. The wizard is intentionally + * single-page (source/review/ready as internal steps) because the import + * flow has no NewWallet draft round trip and we want state to survive + * step transitions without serializing megabyte-scale payloads through + * sessionStorage or URL params. + */ + +import { useCallback, useMemo, useState } from "react"; +import { useRouter } from "next/router"; + +import { useToast } from "@/hooks/use-toast"; +import { useUserStore } from "@/lib/zustand/user"; +import { api } from "@/utils/api"; + +export type ImportSource = "summon" | "instance" | "cbor" | "json"; + +export type ImportStep = "source" | "review" | "ready"; + +export type InstanceSourceMeta = { + source: "instance"; + originUrl: string; + originalWalletId: string; + verifiedSigner: string; +}; + +export type JsonSourceMeta = { + source: "json"; + sourceInstance: string; + payloadHash: string; +}; + +export type CborSourceMeta = { + source: "cbor"; + verifiedSigner: string; +}; + +export type SourceMeta = InstanceSourceMeta | JsonSourceMeta | CborSourceMeta; + +export type ResolvedWalletPayload = { + schemaVersion: 1; + id: string; + name: string; + description: string; + signersAddresses: string[]; + signersStakeKeys: string[]; + signersDRepKeys: string[]; + signersDescriptions: string[]; + numRequiredSigners: number | null; + scriptCbor: string; + stakeCredentialHash: string | null; + type: string; + rawImportBodies: unknown; +}; + +export type CborImportInput = { + name: string; + description: string; + signersAddresses: string[]; + signersStakeKeys: string[]; + signersDRepKeys: string[]; + signersDescriptions: string[]; + scriptCbor: string; + numRequiredSigners: number; + scriptType: "all" | "any" | "atLeast"; + stakeCredentialHash?: string | null; +}; + +export interface WalletImportFlowState { + router: ReturnType; + userAddress: string | undefined; + loading: boolean; + step: ImportStep; + createdWalletId: string | null; + + resolvedPayload: ResolvedWalletPayload | null; + cborInput: CborImportInput | null; + sourceMeta: SourceMeta | null; + + setInstanceResult: (payload: ResolvedWalletPayload, meta: InstanceSourceMeta) => void; + setJsonResult: (payload: ResolvedWalletPayload, meta: JsonSourceMeta) => void; + setCborResult: (input: CborImportInput, meta: CborSourceMeta) => void; + backToSource: () => void; + reset: () => void; + + submitImport: () => Promise; +} + +export function useWalletImportFlowState(): WalletImportFlowState { + const router = useRouter(); + const { toast } = useToast(); + const userAddress = useUserStore((s) => s.userAddress); + const [loading, setLoading] = useState(false); + const [step, setStep] = useState("source"); + const [createdWalletId, setCreatedWalletId] = useState(null); + + const [resolvedPayload, setResolvedPayload] = useState(null); + const [cborInput, setCborInput] = useState(null); + const [sourceMeta, setSourceMeta] = useState(null); + + const { mutateAsync: importWallet } = api.wallet.importWallet.useMutation(); + + const setInstanceResult = useCallback( + (payload: ResolvedWalletPayload, meta: InstanceSourceMeta) => { + setResolvedPayload(payload); + setCborInput(null); + setSourceMeta(meta); + setStep("review"); + }, + [], + ); + + const setJsonResult = useCallback( + (payload: ResolvedWalletPayload, meta: JsonSourceMeta) => { + setResolvedPayload(payload); + setCborInput(null); + setSourceMeta(meta); + setStep("review"); + }, + [], + ); + + const setCborResult = useCallback( + (input: CborImportInput, meta: CborSourceMeta) => { + setResolvedPayload(null); + setCborInput(input); + setSourceMeta(meta); + setStep("review"); + }, + [], + ); + + const backToSource = useCallback(() => { + setStep("source"); + }, []); + + const reset = useCallback(() => { + setResolvedPayload(null); + setCborInput(null); + setSourceMeta(null); + setLoading(false); + setCreatedWalletId(null); + setStep("source"); + }, []); + + const submitImport = useCallback(async () => { + if (!sourceMeta) { + toast({ + title: "Nothing to import", + description: "Start over from the source step.", + variant: "destructive", + }); + return; + } + setLoading(true); + try { + let wallet; + if (sourceMeta.source === "instance" && resolvedPayload) { + wallet = await importWallet({ + source: "instance", + originUrl: sourceMeta.originUrl, + originalWalletId: sourceMeta.originalWalletId, + verifiedSigner: sourceMeta.verifiedSigner, + payload: resolvedPayload, + }); + } else if (sourceMeta.source === "json" && resolvedPayload) { + wallet = await importWallet({ + source: "json", + sourceInstance: sourceMeta.sourceInstance, + payload: resolvedPayload, + payloadHash: sourceMeta.payloadHash, + }); + } else if (sourceMeta.source === "cbor" && cborInput) { + wallet = await importWallet({ + source: "cbor", + name: cborInput.name, + description: cborInput.description, + signersAddresses: cborInput.signersAddresses, + signersStakeKeys: cborInput.signersStakeKeys, + signersDRepKeys: cborInput.signersDRepKeys, + signersDescriptions: cborInput.signersDescriptions, + scriptCbor: cborInput.scriptCbor, + numRequiredSigners: cborInput.numRequiredSigners, + scriptType: cborInput.scriptType, + stakeCredentialHash: cborInput.stakeCredentialHash ?? null, + verifiedSigner: sourceMeta.verifiedSigner, + }); + } else { + throw new Error("Inconsistent import state"); + } + toast({ + title: "Wallet imported", + description: "The wallet now appears in your sidebar.", + duration: 3000, + }); + setCreatedWalletId(wallet.id); + setStep("ready"); + } catch (err) { + const message = err instanceof Error ? err.message : "Import failed"; + toast({ + title: "Import failed", + description: message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }, [sourceMeta, resolvedPayload, cborInput, importWallet, toast]); + + const state = useMemo( + () => ({ + router, + userAddress, + loading, + step, + createdWalletId, + resolvedPayload, + cborInput, + sourceMeta, + setInstanceResult, + setJsonResult, + setCborResult, + backToSource, + reset, + submitImport, + }), + [ + router, + userAddress, + loading, + step, + createdWalletId, + resolvedPayload, + cborInput, + sourceMeta, + setInstanceResult, + setJsonResult, + setCborResult, + backToSource, + reset, + submitImport, + ], + ); + + return state; +} diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/cbor-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/cbor-tab.tsx new file mode 100644 index 00000000..71e2b7ed --- /dev/null +++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/cbor-tab.tsx @@ -0,0 +1,294 @@ +import { useMemo, useState } from "react"; +import { useWallet } from "@meshsdk/react"; +import { resolveNativeScriptHash } from "@meshsdk/core"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; + +import { + collectSigKeyHashes, + computeRequiredSigners, + decodeNativeScriptFromCbor, + decodedToNativeScript, + detectTypeFromSigParents, + normalizeCborHex, + scriptHashFromCbor, +} from "@/utils/nativeScriptUtils"; +import { tryResolveKeyHash } from "@/utils/addressCompatibility"; +import type { WalletImportFlowState } from "../shared/useWalletImportFlowState"; + +interface Props { + flow: WalletImportFlowState; +} + +/** + * Manual reconstruction tab. + * + * No origin to verify against — instead we sanity-check that the pasted + * CBOR's script hash matches the keys derived from the supplied signer + * list, and require the importer's own stake address to appear so we + * don't accept anonymous garbage rows. + */ +export default function CborTab({ flow }: Props) { + const { wallet, connected } = useWallet(); + const { toast } = useToast(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [scriptCbor, setScriptCbor] = useState(""); + const [signersRaw, setSignersRaw] = useState(""); + const [busy, setBusy] = useState(false); + + const decoded = useMemo(() => { + if (!scriptCbor.trim()) return null; + try { + const dec = decodeNativeScriptFromCbor(scriptCbor); + const sigKeys = collectSigKeyHashes(dec); + return { + type: detectTypeFromSigParents(dec), + required: computeRequiredSigners(dec), + keyHashes: sigKeys, + hash: scriptHashFromCbor(scriptCbor) ?? null, + }; + } catch { + return null; + } + }, [scriptCbor]); + + const signers = useMemo(() => { + return signersRaw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + }, [signersRaw]); + + const handleContinue = async () => { + if (!name.trim()) { + toast({ title: "Name required", variant: "destructive" }); + return; + } + if (!connected || !wallet) { + toast({ + title: "Connect a wallet first", + description: "We pin imports to the connected signer to keep the table clean.", + variant: "destructive", + }); + return; + } + if (!scriptCbor.trim() || !decoded) { + toast({ + title: "Invalid script CBOR", + description: "We couldn't decode the pasted hex as a native script.", + variant: "destructive", + }); + return; + } + + setBusy(true); + try { + const stakeAddress = await ( + wallet as { getRewardAddresses: () => Promise } + ).getRewardAddresses(); + const verifiedSigner = stakeAddress[0]; + if (!verifiedSigner) { + throw new Error("Wallet did not return a reward (stake) address"); + } + + const resolved = signers + .map((addr) => ({ addr, hash: tryResolveKeyHash(addr) })) + .filter((r) => r.hash !== null) as { + addr: string; + hash: { keyHash: string; type: "payment" | "staking" }; + }[]; + + if (resolved.length < signers.length) { + toast({ + title: "One or more signers couldn't be decoded", + description: "Each line must be a Cardano address or stake address.", + variant: "destructive", + }); + return; + } + + const declaredHashes = new Set( + resolved.map((r) => r.hash.keyHash.toLowerCase()), + ); + const scriptHashes = new Set( + decoded.keyHashes.map((h) => h.toLowerCase()), + ); + const missing = [...scriptHashes].filter((h) => !declaredHashes.has(h)); + if (missing.length > 0) { + toast({ + title: "Script references signers you didn't list", + description: `${missing.length} keyhash(es) in the CBOR are not represented in the signer list.`, + variant: "destructive", + }); + return; + } + + // Re-derive the script hash from the declared signers + policy and + // confirm it matches the pasted CBOR's hash. This catches the case + // where the signer list looks superficially valid but doesn't + // reconstruct the same script. + const sigScripts = resolved.map((r) => ({ + type: "sig" as const, + keyHash: r.hash.keyHash, + })); + const reconstructed = + decoded.type === "atLeast" + ? ({ + type: "atLeast", + required: decoded.required, + scripts: sigScripts, + } as const) + : ({ type: decoded.type, scripts: sigScripts } as const); + const reconstructedHash = resolveNativeScriptHash( + decodedToNativeScript(reconstructed as never), + ).toLowerCase(); + if (decoded.hash && reconstructedHash !== decoded.hash.toLowerCase()) { + toast({ + title: "Signer order doesn't match the script", + description: + "The same keys are present but the script's structure or signer order differs. Use the JSON or instance tab if you can.", + variant: "destructive", + }); + return; + } + + const verifiedKey = tryResolveKeyHash(verifiedSigner); + if ( + !verifiedKey || + !declaredHashes.has(verifiedKey.keyHash.toLowerCase()) + ) { + toast({ + title: "Your wallet isn't in the signer list", + description: + "Add the stake address of your connected wallet to the signers, then try again.", + variant: "destructive", + }); + return; + } + + flow.setCborResult( + { + name: name.trim(), + description: description.trim(), + signersAddresses: resolved.map((r) => r.addr), + signersStakeKeys: resolved.map((r) => + r.hash.type === "staking" ? r.addr : "", + ), + signersDRepKeys: resolved.map(() => ""), + signersDescriptions: resolved.map(() => ""), + scriptCbor: normalizeCborHex(scriptCbor), + numRequiredSigners: + decoded.type === "atLeast" ? decoded.required : resolved.length, + scriptType: decoded.type, + }, + { source: "cbor", verifiedSigner }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Validation failed"; + toast({ + title: "Couldn't build wallet", + description: message, + variant: "destructive", + }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

Reconstruct from native-script CBOR

+

+ Use this when you have the wallet's script CBOR (e.g. from a + chain explorer) and the full signer list, but no origin + instance to verify against. Your connected stake address must + appear in the signer list. +

+
+ +
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ +