+ 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.
+
+ 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.
+
+ Detected signing policy: {type}. If this is wrong,
+ the pasted CBOR doesn't match what you expect — double-check the
+ source.
+
+ );
+}
diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/index.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/index.tsx
new file mode 100644
index 00000000..abbe1f5a
--- /dev/null
+++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/index.tsx
@@ -0,0 +1,85 @@
+import { useState } from "react";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+import type { WalletImportFlowState } from "../shared/useWalletImportFlowState";
+import InstanceTab from "./instance-tab";
+import SummonTab from "./summon-tab";
+import CborTab from "./cbor-tab";
+import JsonTab from "./json-tab";
+
+type Tab = "instance" | "summon" | "cbor" | "json";
+
+interface Props {
+ flow: WalletImportFlowState;
+}
+
+const TAB_OPTIONS: { value: Tab; label: string }[] = [
+ { value: "instance", label: "Another instance" },
+ { value: "summon", label: "Summon" },
+ { value: "cbor", label: "Paste CBOR" },
+ { value: "json", label: "Upload JSON" },
+];
+
+export default function SourceStep({ flow }: Props) {
+ const [tab, setTab] = useState("instance");
+
+ return (
+
+
+
+ Where is the wallet coming from?
+
+
+
+ setTab(v as Tab)}>
+ {/* Mobile: select dropdown (4 tabs in two rows looked broken; a
+ single select reads better at narrow widths). Desktop: the
+ standard Radix tab bar. */}
+
+
+
+
+ {TAB_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx
new file mode 100644
index 00000000..ee5f73d1
--- /dev/null
+++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx
@@ -0,0 +1,314 @@
+import { useState } from "react";
+import { useWallet } from "@meshsdk/react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useToast } from "@/hooks/use-toast";
+
+import type {
+ ResolvedWalletPayload,
+ WalletImportFlowState,
+} from "../shared/useWalletImportFlowState";
+
+interface Props {
+ flow: WalletImportFlowState;
+}
+
+type RemoteWalletSummary = {
+ id: string;
+ name: string;
+ description: string;
+ type: string;
+ numRequiredSigners: number | null;
+ numSigners: number;
+};
+
+type Mode =
+ | { kind: "input" }
+ | { kind: "pick"; origin: string; stakeAddress: string; wallets: RemoteWalletSummary[] };
+
+/**
+ * Cross-instance import tab.
+ *
+ * Accepts either a deep link to a wallet (https://other/wallets/) or
+ * an instance root URL. In deep-link mode it's a single nonce-sign round
+ * trip; in root mode it fetches the user's wallet list from the origin
+ * and lets them pick before signing.
+ */
+export default function InstanceTab({ flow }: Props) {
+ const { wallet, connected } = useWallet();
+ const { toast } = useToast();
+ const [urlInput, setUrlInput] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [mode, setMode] = useState({ kind: "input" });
+
+ const handleContinue = async () => {
+ if (!connected || !wallet) {
+ toast({
+ title: "Connect a wallet first",
+ description: "We need your wallet extension to sign the origin nonce.",
+ variant: "destructive",
+ });
+ return;
+ }
+ const parsed = parseInput(urlInput);
+ if (!parsed) {
+ toast({
+ title: "Couldn't read that URL",
+ description: "Paste an instance origin or a /wallets/ link.",
+ variant: "destructive",
+ });
+ return;
+ }
+ setBusy(true);
+ try {
+ const stakeAddress = await getStakeAddress(wallet);
+ if (!stakeAddress) {
+ throw new Error("Wallet did not return a reward (stake) address");
+ }
+
+ if (parsed.walletId) {
+ await runRedeem(parsed.origin, parsed.walletId, stakeAddress);
+ } else {
+ const list = await fetchList(parsed.origin, stakeAddress);
+ if (list.length === 0) {
+ toast({
+ title: "No wallets to import",
+ description:
+ "The origin says you're not a signer on any wallet there.",
+ variant: "destructive",
+ });
+ return;
+ }
+ setMode({
+ kind: "pick",
+ origin: parsed.origin,
+ stakeAddress,
+ wallets: list,
+ });
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Import failed";
+ toast({
+ title: "Origin request failed",
+ description: message,
+ variant: "destructive",
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handlePick = async (walletId: string) => {
+ if (mode.kind !== "pick") return;
+ setBusy(true);
+ try {
+ await runRedeem(mode.origin, walletId, mode.stakeAddress);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Import failed";
+ toast({
+ title: "Sign-in failed",
+ description: message,
+ variant: "destructive",
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ async function runRedeem(
+ origin: string,
+ walletId: string,
+ stakeAddress: string,
+ ) {
+ if (!wallet) throw new Error("Wallet not connected");
+ const nonce = await fetchNonce(origin, walletId, stakeAddress);
+ const signed = (await (wallet as any).signData(nonce, stakeAddress)) as {
+ signature: string;
+ key: string;
+ };
+ if (!signed?.signature || !signed?.key) {
+ throw new Error("Wallet returned an invalid signature");
+ }
+ const result = await fetchRedeem(origin, {
+ address: stakeAddress,
+ walletId,
+ signature: signed.signature,
+ key: signed.key,
+ });
+ flow.setInstanceResult(result.payload, {
+ source: "instance",
+ originUrl: origin,
+ originalWalletId: walletId,
+ verifiedSigner: stakeAddress,
+ });
+ }
+
+ return (
+
+
+
From another multisig instance
+
+ Paste the origin URL. We'll ask your wallet to sign a nonce from
+ that instance — the origin will only release the wallet config
+ if your connected stake address is in its signer list.
+
+ An instance root or a deep link to a wallet both work.
+
+
+
+
+
+ >
+ )}
+
+ {mode.kind === "pick" && (
+
+
+ Pick a wallet to import from{" "}
+ {mode.origin}
+
+
+ {mode.wallets.map((w) => (
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+}
+
+function parseInput(raw: string): { origin: string; walletId?: string } | null {
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+ let url: URL;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
+ const walletMatch = url.pathname.match(/\/wallets\/([^/?#]+)/);
+ return {
+ origin: url.origin,
+ walletId: walletMatch ? walletMatch[1] : undefined,
+ };
+}
+
+async function getStakeAddress(wallet: unknown): Promise {
+ const rewardAddresses = await (
+ wallet as { getRewardAddresses: () => Promise }
+ ).getRewardAddresses();
+ return rewardAddresses[0] ?? null;
+}
+
+async function fetchNonce(
+ origin: string,
+ walletId: string,
+ address: string,
+): Promise {
+ const url = `${origin}/api/v1/exportWallet/getNonce?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(address)}`;
+ const res = await fetch(url, { credentials: "omit" });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(body.error || `Origin returned ${res.status}`);
+ }
+ if (typeof body.nonce !== "string") {
+ throw new Error("Origin did not return a nonce");
+ }
+ return body.nonce;
+}
+
+async function fetchRedeem(
+ origin: string,
+ body: {
+ address: string;
+ walletId: string;
+ signature: string;
+ key: string;
+ },
+): Promise<{ payload: ResolvedWalletPayload; payloadHash: string }> {
+ const res = await fetch(`${origin}/api/v1/exportWallet/redeem`, {
+ method: "POST",
+ credentials: "omit",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(json.error || `Origin returned ${res.status}`);
+ }
+ if (!json.payload) {
+ throw new Error("Origin response missing payload");
+ }
+ return json as { payload: ResolvedWalletPayload; payloadHash: string };
+}
+
+async function fetchList(
+ origin: string,
+ address: string,
+): Promise {
+ const url = `${origin}/api/v1/exportWallet/listMine?address=${encodeURIComponent(address)}`;
+ const res = await fetch(url, { credentials: "omit" });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(body.error || `Origin returned ${res.status}`);
+ }
+ return Array.isArray(body.wallets) ? body.wallets : [];
+}
+
+function policyLabel(w: RemoteWalletSummary): string {
+ if (w.type === "atLeast") {
+ return `${w.numRequiredSigners ?? "?"} of ${w.numSigners} signers required`;
+ }
+ if (w.type === "all") return `All ${w.numSigners} signers required`;
+ if (w.type === "any") return `Any signer (of ${w.numSigners}) can sign`;
+ return `${w.numSigners} signers`;
+}
diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/json-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/json-tab.tsx
new file mode 100644
index 00000000..52833a3f
--- /dev/null
+++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/json-tab.tsx
@@ -0,0 +1,114 @@
+import { useRef, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { useToast } from "@/hooks/use-toast";
+
+import type {
+ ResolvedWalletPayload,
+ WalletImportFlowState,
+} from "../shared/useWalletImportFlowState";
+
+interface Props {
+ flow: WalletImportFlowState;
+}
+
+/**
+ * Upload-JSON tab.
+ *
+ * Expects the file produced by the "Download JSON backup" action on the
+ * wallet info page: an envelope with `payload` and `payloadHash`. We
+ * verify the hash server-side too (during importWallet), but checking
+ * client-side gives faster feedback if the file is corrupt.
+ */
+export default function JsonTab({ flow }: Props) {
+ const inputRef = useRef(null);
+ const { toast } = useToast();
+ const [sourceInstance, setSourceInstance] = useState("");
+
+ const handleFile = async (file: File) => {
+ try {
+ const text = await file.text();
+ const parsed = JSON.parse(text) as {
+ payload?: ResolvedWalletPayload;
+ payloadHash?: string;
+ sourceInstance?: string;
+ };
+ if (!parsed.payload || typeof parsed.payloadHash !== "string") {
+ throw new Error("File is missing payload or payloadHash");
+ }
+ if (parsed.payload.schemaVersion !== 1) {
+ throw new Error("Unsupported schema version");
+ }
+ const inferredOrigin =
+ sourceInstance.trim() ||
+ parsed.sourceInstance?.trim() ||
+ "unknown";
+ flow.setJsonResult(parsed.payload, {
+ source: "json",
+ sourceInstance: inferredOrigin,
+ payloadHash: parsed.payloadHash,
+ });
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "Failed to read JSON file";
+ toast({
+ title: "Invalid backup file",
+ description: message,
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+
+
+
From a JSON backup
+
+ Drop in a file produced by the Download JSON backup{" "}
+ action on the wallet info page. We'll verify the payload hash
+ before creating the local record.
+
+
+
+
+
+ setSourceInstance(e.target.value)}
+ />
+
+ Used only as a label on the imported wallet's provenance.
+
+ );
+}
diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/summon-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/summon-tab.tsx
new file mode 100644
index 00000000..d45a71e1
--- /dev/null
+++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/summon-tab.tsx
@@ -0,0 +1,100 @@
+import Link from "next/link";
+import { useState } from "react";
+import { useRouter } from "next/router";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useToast } from "@/hooks/use-toast";
+
+/**
+ * Summon tab.
+ *
+ * The Summon platform's ejection flow already posts the wallet payload to
+ * /api/v1/import/summon on this instance, which creates a NewWallet draft
+ * and returns an invite URL like /wallets/invite/. There's no extra
+ * validation step to build here — we just hand-hold the user toward the
+ * existing invite flow.
+ *
+ * If the user has a Summon-ejection invite URL, paste it; we forward to
+ * the existing invite handler. If they're starting fresh, point them at
+ * Summon's "Eject to Mesh" button.
+ */
+export default function SummonTab() {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [url, setUrl] = useState("");
+
+ const handleContinue = () => {
+ const id = extractInviteId(url);
+ if (!id) {
+ toast({
+ title: "Couldn't read that link",
+ description:
+ "Paste a Summon-generated invite URL ending in /wallets/invite/.",
+ variant: "destructive",
+ });
+ return;
+ }
+ void router.push(`/wallets/invite/${id}`);
+ };
+
+ return (
+
+
+
Coming from Summon?
+
+ On the Summon side, hit the Eject to Mesh action. Summon
+ will hand you back an invite link on this instance — paste it
+ below to pick up the import where Summon left off.
+
+
+ The actual signer review and on-chain handover happen on the
+ existing wallet invite page; this tab is just the doorway.
+
+
+
+
+
+ setUrl(e.target.value)}
+ />
+
+ Either a full URL or just the invite id works.
+
+
+
+
+
+
+
+
+ );
+}
+
+function extractInviteId(input: string): string | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+ const match = trimmed.match(/\/wallets\/invite\/([^/?#]+)/);
+ if (match && match[1]) return match[1];
+ // Allow pasting just the id
+ if (/^[a-z0-9_-]{8,}$/i.test(trimmed)) return trimmed;
+ return null;
+}
diff --git a/src/components/pages/homepage/wallets/index.tsx b/src/components/pages/homepage/wallets/index.tsx
index e6a6d379..463a47e8 100644
--- a/src/components/pages/homepage/wallets/index.tsx
+++ b/src/components/pages/homepage/wallets/index.tsx
@@ -123,6 +123,9 @@ export default function PageWallets() {
+
{wallets && wallets.some((wallet) => wallet.isArchived) && (